diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 26abaee..f00e8be 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -36,4 +36,36 @@ jobs: run: npx tsx scripts/test-insights.ts env: INSIGHTS_TEST_DATABASE_URL: mongodb://127.0.0.1:27017/studio_insights_test?replicaSet=rs0 + - name: Verify transactional content recovery with MongoDB + run: npm run test:recovery + env: + RECOVERY_TEST_DATABASE_URL: mongodb://127.0.0.1:27017/studio_recovery_test?replicaSet=rs0 + - name: Seed synthetic browser-test content + run: npx tsx scripts/seed-e2e.ts + env: + E2E_DATABASE_URL: mongodb://127.0.0.1:27017/studio_e2e_test?replicaSet=rs0 + - name: Create browser-test indexes + run: npx prisma db push --skip-generate + env: + DATABASE_URL: mongodb://127.0.0.1:27017/studio_e2e_test?replicaSet=rs0 + - name: Verify content check refuses an unavailable database + run: | + if DATABASE_URL='' npm run content:check; then + echo 'Content check unexpectedly accepted an unavailable database' + exit 1 + fi - run: npm run build + env: + DATABASE_URL: mongodb://127.0.0.1:27017/studio_e2e_test?replicaSet=rs0 + - run: npx playwright install --with-deps chromium + - run: npm run test:e2e + env: + E2E_DATABASE_URL: mongodb://127.0.0.1:27017/studio_e2e_test?replicaSet=rs0 + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: browser-test-results + path: | + playwright-report/ + test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 2b8632d..3c24219 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,7 @@ firestore-debug.log /src/generated/prisma !.env.example + +# Browser test output +/playwright-report/ +/test-results/ diff --git a/README.md b/README.md index e08b379..91adf3b 100644 --- a/README.md +++ b/README.md @@ -268,9 +268,12 @@ The admin console, first login and day‑to‑day content workflow are covered i 1. Import the repo in Vercel. The Next.js preset is picked up automatically. 2. Add the variables above under **Settings → Environment Variables**. -3. Deploy. `postinstall` generates the Prisma client during the build. -4. Once, from your machine, run `DATABASE_URL="" npm run db:push` to create the collections and indexes. -5. Log in, set a new PIN in **Security**, then press **GitHub → Sync now**. +3. From your machine, run `DATABASE_URL="" npm run db:push` to create the collections and indexes, including content history. +4. For a new database, use the local admin console (`npm run dev`) to save your real profile (name, title and bio) and at least one project or visible repository. Existing populated installations can skip this step. Do not seed example content into production. +5. Deploy. `postinstall` generates Prisma; `prebuild` verifies the content/database before a release. A failed check keeps the previous successful deployment active. +6. Log in, set a new PIN in **Security**, then press **GitHub → Sync now**. + +See [availability, admin recovery and browser tests](docs/portfolio-improvements.md) for Trash, revisions, backup limits and test commands. > [!TIP] > Seeing `Server selection timeout` or `bad auth`? In Atlas, open **Network Access** to Vercel (for example `0.0.0.0/0`), and check that the database name is part of `DATABASE_URL`. diff --git a/SETUP.md b/SETUP.md index a0a52eb..ebab5d9 100644 --- a/SETUP.md +++ b/SETUP.md @@ -46,3 +46,7 @@ npm test # unit tests npm run typecheck npm run lint ``` + +## Content availability and recovery + +Builds now require a reachable, populated database to avoid releasing an empty portfolio. See [portfolio improvements](docs/portfolio-improvements.md) for the content check, schema/index setup, admin Trash/history, backup limits, and browser tests. diff --git a/docs/portfolio-improvements.md b/docs/portfolio-improvements.md new file mode 100644 index 0000000..00f4716 --- /dev/null +++ b/docs/portfolio-improvements.md @@ -0,0 +1,54 @@ +# Portfolio backlog: selected October improvements + +Implements issue #20 items 1, 2, 4–10. The Naruto/cyber design remains. Authentication changes and portfolio case-study content are separate work. + +## Publishing and availability + +- The 90-day archive job targets only slugs in the durable Security Insights import ledger. Original write-ups, including older posts discussing CVEs, are preserved. Imported content is archived reversibly. +- `npm run build` first runs `content:check`. A working database, a populated public profile (name/title/bio), and at least one visible project/repository are required. A connection failure or incomplete source data fails the release; Vercel keeps the previous successful deployment. No demo data is inserted by this check. +- Home-page database errors now fail static generation/revalidation rather than silently generating an empty portfolio. Runtime ISR can retain its last successful page. This protects releases; it does not make dynamic pages available during a database outage. +- The blog reads 12 summaries per page, never whole article bodies in listing queries. Search matches titles/excerpts case-insensitively; tag filtering matches an exact tag. GET forms and pagination URLs preserve filters and work without client-side JavaScript. + +## Accessibility and mobile projects + +The entrance dialog is static and defaults to safe mode before paint. It has no loading delay or flicker. Keyboard focus starts on Safe Mode, remains in the dialog, and moves to the page's main content on dismissal. Escape selects safe mode. Reduced-motion preferences override a stored glitch choice and update when the OS setting changes. Storage-denied browsing still works. + +Shared reveals, text decoding, parallax and tilt honor the same mode; existing WebGL viewport pausing and reduced-motion fallbacks remain. Mobile grid and list cards expose Source/Visit, Demo (when present), and Details links as siblings of selection buttons, without nesting interactive elements. Keyboard selection updates the preview too. + +## Search discovery and fonts + +- Canonical URLs use the public production origin. Home-page Person and article BlogPosting JSON-LD escape content-controlled markup. Articles include publication/modification metadata and a social image. +- `/sitemap.xml` includes only public pages/published articles. `/robots.txt` and noindex headers exclude API/admin/decoy paths. Filtered searches are noindex, follow. +- The four existing font families remain. Requested weights drop from 15 to 9; Japanese UI labels use a 106-glyph subset. When adding Japanese labels, extend the `text=` subset in layout.tsx. Google Fonts still uses `display=swap` and preconnects. +- `scripts/measure-mobile.cjs` records LCP, CLS, observed long-task blocking, TTFB, font bytes, page errors and overflow using Chromium Pixel 7 emulation, 4× CPU slowdown, 1.6 Mbps download and 150 ms latency. Results are laboratory measurements against synthetic local data, not real-user Core Web Vitals or a production speed guarantee. + +## Admin history, Trash and backups + +Open **History & recovery** in the real admin console. New writes to profile, projects, skills, certificates, education, experience, CTFs, GitHub repositories, blog, bounty findings and audio settings store full before/after revisions in the same Mongo transaction as the content edit. Automatic content syncs are recorded too. Existing records have no historical revisions until their next write. Actor labels are session-token hash prefixes or `automation`; raw session tokens are never logged. + +Deleting these content records sets optional `deletedAt`; normal reads/counts exclude both newly deleted records and retain compatibility with older Mongo documents that omit the field. Trash shows retained records by collection. Review a revision, compare current/before/after, and explicitly restore a side. If the record changed since review, restoration fails with 409. Every restore creates a new undo revision; conflicting writes or a failed audit insert roll back the transaction. + +The **Download content backup** action exports the 11 content collections (including drafts/Trash/private bounty details) plus the import deduplication ledger. The upload path validates the entire file in a dry run before enabling restore. Restore merges by durable ID, keeps records absent from the file, rejects unique-field/singleton conflicts and records every overwrite. It is atomic; it never partially restores a file. + +Limits: 3 MB / 1,000 records per content backup. Credentials, sessions, login attempts, inbox messages, client requests and old revision history are excluded. Uploaded photos/resumes are URL references, not copied media binaries. Keep downloaded files private. Use the database provider's separate backups and media retention for full disaster recovery or larger datasets. Content history itself is retained without automatic pruning. + +Schema changes: optional `deletedAt` on the 11 content models and a new `ContentRevision` collection with indexes. Run the normal `npx prisma db push` deployment/schema setup to create its indexes; existing content fields need no data rewrite. Prisma generation remains the existing install step. + +## Reproduce verification + +The integration scripts refuse any URL except their exact localhost disposable database. They never fall back to `DATABASE_URL`. + +1. Start a local MongoDB 7 replica set on port 27017 named `rs0`. +2. `npm ci`; `npm run typecheck`; `npm run lint`; `npm test -- --runInBand`. +3. Set `RECOVERY_TEST_DATABASE_URL=mongodb://127.0.0.1:27017/studio_recovery_test?replicaSet=rs0`; run `npm run test:recovery`. +4. Set `INSIGHTS_TEST_DATABASE_URL=mongodb://127.0.0.1:27017/studio_insights_test?replicaSet=rs0`; run `npx tsx scripts/test-insights.ts`. +5. Set `E2E_DATABASE_URL=mongodb://127.0.0.1:27017/studio_e2e_test?replicaSet=rs0`; run `npx tsx scripts/seed-e2e.ts`. +6. Set `DATABASE_URL` to that same e2e database; run `npx prisma db push --skip-generate` and `npm run build`. +7. `npx playwright install --with-deps chromium`; `npm run test:e2e`. The runner starts the production server, uses a fixture PIN, and tests desktop/mobile against real APIs and Mongo. Each browser project resets only synthetic test data, preserving indexes. Screenshots/traces are retained on failure. +8. To measure mobile performance, start the production server on port 3100 with the e2e database and run `node scripts/measure-mobile.cjs`. `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` can select an already installed Chromium. + +CI performs the database integrations and all 12 browser scenarios. Missing-database release refusal is checked explicitly. CI stores browser failure artifacts for seven days. + +## Recorded laboratory check (16 October 2026) + +With the conditions above, the synthetic home page recorded LCP 888 ms, CLS 0.0167, local-server TTFB 53 ms, and five font requests totaling 131,626 transferred bytes. Fonts finished loading, safe mode was active, and there were no page errors or horizontal overflow. Observed long-task blocking over the sample remained 2,268 ms under 4× CPU throttling; this is a remaining scripting/rendering cost, not a claim that all mobile performance work is complete. No comparable production before/after field measurement was available. diff --git a/docs/security-insights.md b/docs/security-insights.md index 2f7d985..d039b23 100644 --- a/docs/security-insights.md +++ b/docs/security-insights.md @@ -7,7 +7,7 @@ Uses the official CISA KEV JSON mirror. No AI key, paid scheduler, or publishing - First successful run publishes up to eight briefs from the preceding 30 days, distributed across weeks. Existing manual CVE coverage is skipped. - Subsequent runs publish at most three new briefs per UTC day. Past-month imports also publish automatically. The publishing approval switch has been removed. - On the first successful sync after this update, existing unpublished imported briefs from the past 90 days publish automatically. Archived posts and manual drafts are excluded. Later syncs preserve editorial changes, including deliberate unpublishing. -- Every successful sync archives blog posts older than 90 days using their publication date, or creation date for drafts without a publication date. Archived content is retained in the database and admin blog list, marked with `archivedAt`, and hidden from the homepage, `/blog`, and individual public post URLs. Nothing is permanently deleted. +- Every successful sync archives imported briefs older than 90 days using their publication date, or creation date for drafts without a publication date. Archived content is retained in the database and admin blog list, marked with `archivedAt`, and hidden from the homepage, `/blog`, and individual public post URLs. Nothing is permanently deleted. - Admin → Blog: daily collection switch, Sync now, Import past month, last success and errors. The manual editor still controls manually authored posts. - A shared ten-minute MongoDB lease prevents concurrent cron/admin imports. Automatic retries have a twenty-minute cooldown. The endpoint runs for at most five minutes. Paused collection also pauses automatic archiving; Sync now can still run it. - Stable slugs and a durable CVE ledger prevent duplicates, preserve edits, and keep deleted imported posts from returning. @@ -23,3 +23,5 @@ Daily collection and publishing are enabled by default. Existing pause settings ## Verification Unit tests cover source validation, date windows, budgets, automatic publishing, deletion tracking, lease skips, failures, admin authentication and cron authentication. CI runs `scripts/test-insights.ts` against a disposable MongoDB replica set to verify atomic concurrency, persisted caps, automatic backfill publishing, legacy documents with missing fields, one-time draft release, retained archived content, deletion tracking and editorial preservation. It cannot connect to production. + +Retention applies only to slugs recorded in InsightImport. Original write-ups are never selected by the 90-day automatic archive. diff --git a/e2e/portfolio.spec.ts b/e2e/portfolio.spec.ts new file mode 100644 index 0000000..4849828 --- /dev/null +++ b/e2e/portfolio.spec.ts @@ -0,0 +1,156 @@ +import { execFileSync } from 'node:child_process'; +import { expect, test, type Page } from '@playwright/test'; + +test.beforeAll(() => { execFileSync(process.execPath, ['node_modules/tsx/dist/cli.mjs', 'scripts/seed-e2e.ts'], { env: process.env }); }); + +async function calmVisit(page: Page, path = '/') { + await page.addInitScript(() => { localStorage.setItem('nd-experience-mode', 'safe'); localStorage.setItem('nd-theme-muted-v1', 'true'); }); + await page.goto(path, { waitUntil: 'domcontentloaded' }); +} +async function login(page: Page) { + await calmVisit(page, '/bikram'); + const pin = page.getByLabel('Admin PIN'); + await expect(pin).toBeFocused(); // the login effect confirms hydration before typing + await pin.fill('84627193'); + await expect(page.getByRole('button', { name: 'Authorize', exact: true })).toBeEnabled(); + await page.getByRole('button', { name: 'Authorize', exact: true }).click(); + await expect(page.getByRole('heading', { name: 'Admin console' })).toBeVisible(); +} +async function section(page: Page, name: string, id: string) { + const picker = page.getByRole('combobox', { name: 'Section', exact: true }); + if (await picker.isVisible()) await picker.selectOption(id); + else await page.getByRole('navigation', { name: 'Admin sections' }).getByRole('button', { name: new RegExp(`^${name}(?: \\d+)?$`) }).click(); +} + +test('calm entrance, focus trap, Escape and preference persistence', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await page.goto('/'); + const dialog = page.getByRole('dialog', { name: 'Choose your experience' }); + await expect(dialog).toBeVisible(); + await expect(page.locator('html')).toHaveClass(/safe-mode/); + await expect(page.getByRole('button', { name: 'Use safe mode', exact: true })).toBeFocused(); + await page.keyboard.press('Shift+Tab'); + await expect(page.getByRole('button', { name: 'Enable glitch effect' })).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('button', { name: 'Use safe mode', exact: true })).toBeFocused(); + await page.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible(); + await expect(page.locator('main')).toBeFocused(); + await page.reload(); + await expect(dialog).not.toBeVisible(); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await expect(page.locator('html')).toHaveClass(/safe-mode/); +}); + +test('reduced motion wins over an earlier glitch choice', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('nd-experience-mode', 'glitch')); + await page.goto('/'); + await expect(page.locator('html')).toHaveClass(/safe-mode/); + await expect(page.getByRole('dialog')).not.toBeVisible(); +}); + +test('navigation and project selection work with direct mobile links', async ({ page }, info) => { + await calmVisit(page); + if (info.project.name === 'mobile') { + await page.getByRole('button', { name: 'Open menu' }).click(); + await page.getByRole('dialog').getByRole('link', { name: /Projects/ }).click(); + await expect(page.getByRole('dialog')).not.toBeVisible(); + await expect(page.getByRole('link', { name: 'Source: fixture-repo', exact: true })).toHaveAttribute('href', 'https://github.com/example/fixture'); + await expect(page.getByRole('link', { name: 'Demo: fixture-repo', exact: true })).toHaveAttribute('href', 'https://example.com/demo'); + await page.getByRole('link', { name: 'Details: Browser fixture toolkit', exact: true }).click(); + } else { + await page.locator('header nav').getByRole('link', { name: /Projects/ }).click(); + await page.getByRole('button', { name: /project \/ 02 Browser fixture toolkit/ }).click(); + } + await expect(page.locator('#project-preview')).toContainText('Browser fixture toolkit'); + await expect(page.locator('#project-preview a').filter({ hasText: 'View Project' })).toHaveAttribute('href', 'https://example.com/toolkit'); + await expect(page.locator('body')).toHaveJSProperty('scrollWidth', await page.locator('body').evaluate(node => node.clientWidth)); +}); + +test('blog search, exact tags, pagination, details and SEO', async ({ page, request }) => { + await calmVisit(page, '/blog'); + await expect(page.getByRole('status')).toHaveText('15 articles · Page 1 of 2'); + await expect(page.locator('main a[href^="/blog/browser-article-"]')).toHaveCount(12); + await page.getByRole('link', { name: 'Next page' }).click(); + await expect(page.getByRole('status')).toHaveText('15 articles · Page 2 of 2'); + await page.getByLabel('Search articles').fill('Cryptography'); + await page.getByLabel('Tag', { exact: true }).fill('Crypto'); + await page.getByRole('button', { name: 'Search', exact: true }).click(); + await expect(page.getByRole('status')).toHaveText('7 articles · Page 1 of 1'); + await expect(page.locator('main')).not.toContainText('Private browser draft'); + await page.locator('main a[href^="/blog/browser-article-"]').first().click(); + await expect(page.locator('article')).toContainText('Synthetic article'); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', /https:\/\/bikram-cyber.vercel.app\/blog\/browser-article-/); + expect(await page.locator('script[type="application/ld+json"]').first().textContent()).toContain('BlogPosting'); + const sitemap = await request.get('/sitemap.xml'); + expect(sitemap.status()).toBe(200); expect(await sitemap.text()).not.toContain('private-browser-draft'); + expect(await (await request.get('/robots.txt')).text()).toContain('Disallow: /api/'); + const privatePage = await request.get('/bikram'); + expect(privatePage.headers()['x-robots-tag']).toContain('noindex'); +}); + +test('contact form submits to the real API and saves to admin inbox', async ({ page }, info) => { + await calmVisit(page, '/#contact'); + await page.getByLabel('Name', { exact: true }).fill('Browser Tester'); + await page.getByRole('textbox', { name: 'Email', exact: true }).fill('browser@example.com'); + const subject = `Browser contact ${info.project.name}`; + await page.getByLabel('Subject', { exact: true }).fill(subject); + await page.getByLabel('Message', { exact: true }).fill('Synthetic local browser message to verify the actual persistence flow.'); + await page.getByRole('button', { name: 'Send Message' }).click(); + await expect(page.getByRole('heading', { name: 'Message Sent!' })).toBeVisible(); + await login(page); await section(page, 'Inbox', 'inbox'); + await expect(page.getByText(subject, { exact: true })).toBeVisible(); +}); + +test('admin create, publish, trash, restore, backup and revoked-session handling', async ({ page, request }, info) => { + expect((await request.get('/api/admin/history')).status()).toBe(401); + expect((await request.get('/api/admin/backup')).status()).toBe(401); + await login(page); + await page.reload(); + await expect(page.getByRole('heading', { name: 'Admin console' })).toBeVisible(); + await section(page, 'Blog', 'blog'); + const title = `Recovery article ${info.project.name}`; + const slug = `recovery-article-${info.project.name}`; + await page.getByRole('button', { name: 'New post', exact: true }).click(); + await page.getByLabel('Title *', { exact: true }).fill(title); + await page.getByLabel('URL slug', { exact: true }).fill(slug); + await page.getByLabel('Content (Markdown)', { exact: true }).fill('## Recovery test\n\nA real browser-created article.'); + await page.getByRole('button', { name: /^Save$/ }).click(); + const row = page.locator('li').filter({ has: page.getByRole('button', { name: `Edit ${title}`, exact: true }) }); + await expect(row).toContainText('Draft'); + expect((await page.request.get(`/blog/${slug}`)).status()).toBe(404); + await row.getByRole('button', { name: `Edit ${title}`, exact: true }).click(); + await page.getByRole('switch', { name: 'Published', exact: true }).click(); + await page.getByRole('button', { name: /^Save$/ }).click(); + await expect(row).toContainText('Published'); + const article = await page.request.get(`/blog/${slug}`); + expect(article.status()).toBe(200); expect(await article.text()).toContain('A real browser-created article.'); + page.on('dialog', dialog => dialog.accept()); + await row.getByRole('button', { name: `Delete ${title}`, exact: true }).click(); + await expect(row).toHaveCount(0); + expect((await page.request.get(`/blog/${slug}`)).status()).toBe(404); + await section(page, 'History & recovery', 'history'); + await page.getByRole('button', { name: 'Trash', exact: true }).click(); + const deletedRow = page.locator('li').filter({ has: page.getByText(title, { exact: true }) }); + await expect(deletedRow).toHaveCount(1); + await deletedRow.getByRole('button', { name: 'Review version', exact: true }).click(); + await page.getByRole('button', { name: 'Restore before version', exact: true }).click(); + await expect(page.getByRole('status').filter({ hasText: 'Version restored' })).toBeVisible(); + expect((await page.request.get(`/blog/${slug}`)).status()).toBe(200); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Download content backup' }).click(); + const download = await downloadPromise; + const stream = await download.createReadStream(); const chunks: Buffer[] = []; + for await (const chunk of stream!) chunks.push(Buffer.from(chunk)); + const backup = Buffer.concat(chunks); + const parsed = JSON.parse(backup.toString()); expect(parsed.format).toBe('own-verse-content'); expect(parsed.collections.AdminCredential).toBeUndefined(); + await page.getByLabel('Choose content backup', { exact: true }).setInputFiles({ name: 'content.json', mimeType: 'application/json', buffer: backup }); + await expect(page.getByText(/records validated. No changes applied/)).toBeVisible(); + await page.getByRole('button', { name: 'Restore validated backup', exact: true }).click(); + await expect(page.getByRole('status').filter({ hasText: 'records restored.' })).toBeVisible(); + // Revoke via the real endpoint while keeping the dashboard mounted. + expect((await page.request.post('/api/admin/logout')).ok()).toBe(true); + await page.getByRole('button', { name: 'Refresh history', exact: true }).click(); + await expect(page.getByLabel('Admin PIN')).toBeVisible(); + await expect(page.getByRole('status').filter({ hasText: 'session expired' })).toBeVisible(); +}); diff --git a/jest.config.js b/jest.config.js index 73b7978..aff43a7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -11,6 +11,7 @@ const customJestConfig = { testEnvironment: 'jsdom', // the standalone build copies package.json into .next/, which Jest's haste map reports as a name collision modulePathIgnorePatterns: ['/.next/'], + testPathIgnorePatterns: ['/node_modules/', '/e2e/'], moduleNameMapper: { '^@/(.*)$': '/src/$1', }, diff --git a/jest.setup.js b/jest.setup.js index c44951a..132bb28 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1 +1,8 @@ import '@testing-library/jest-dom' + +// Shared browser media API used by the consent/reduced-motion hook. +if (typeof window !== 'undefined') Object.defineProperty(window, 'matchMedia', { writable: true, value: jest.fn(query => ({ + matches: false, media: query, onchange: null, + addEventListener: jest.fn(), removeEventListener: jest.fn(), + addListener: jest.fn(), removeListener: jest.fn(), dispatchEvent: jest.fn(), +})) }); diff --git a/package-lock.json b/package-lock.json index eeac02d..a5f9fd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@types/jest": "^30.0.0", @@ -3167,6 +3168,22 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@prisma/client": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.14.0.tgz", @@ -11700,6 +11717,52 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index abd589b..bfa51b5 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,11 @@ "prisma:seed": "tsx prisma/seed.ts", "postinstall": "prisma generate", "db:push": "prisma db push", - "github:sync": "tsx scripts/sync-github.ts" + "github:sync": "tsx scripts/sync-github.ts", + "content:check": "tsx scripts/check-content.ts", + "prebuild": "npm run content:check", + "test:e2e": "playwright test", + "test:recovery": "tsx scripts/test-recovery.ts" }, "dependencies": { "@hookform/resolvers": "^5.9.1", @@ -63,6 +67,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@types/jest": "^30.0.0", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..a66c642 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, devices } from '@playwright/test'; +import { testDatabase } from './scripts/test-database'; + +const database = testDatabase('e2e'); +export default defineConfig({ + testDir: './e2e', fullyParallel: false, workers: 1, retries: process.env.CI ? 1 : 0, + timeout: 60000, expect: { timeout: 10000 }, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + use: { baseURL: 'http://127.0.0.1:3100', contextOptions: { reducedMotion: 'reduce' }, trace: 'retain-on-failure', screenshot: 'only-on-failure', + launchOptions: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, args: ['--no-sandbox'] } : {} }, + projects: [ + { name: 'desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 1000 } } }, + { name: 'mobile', use: { ...devices['Pixel 7'], defaultBrowserType: 'chromium' } }, + ], + webServer: { + command: 'npm run start -- -p 3100', url: 'http://127.0.0.1:3100', reuseExistingServer: false, timeout: 120000, + env: { DATABASE_URL: database, ADMIN_PIN: '84627193', GITHUB_USERNAME: 'modhack2003' }, + }, +}); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index afd65ac..5d848dc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,6 +11,7 @@ generator client { // were created before it existed keep loading. model PersonalData { + deletedAt DateTime? id String @id @default(auto()) @map("_id") @db.ObjectId name String title String @@ -26,6 +27,7 @@ model PersonalData { } model Project { + deletedAt DateTime? id String @id @default(auto()) @map("_id") @db.ObjectId title String description String @@ -34,6 +36,7 @@ model Project { } model Skill { + deletedAt DateTime? id String @id @default(auto()) @map("_id") @db.ObjectId languages String[] tools String[] @@ -41,6 +44,7 @@ model Skill { } model Certificate { + deletedAt DateTime? id String @id @default(auto()) @map("_id") @db.ObjectId name String issuer String @@ -49,6 +53,7 @@ model Certificate { } model Education { + deletedAt DateTime? id String @id @default(auto()) @map("_id") @db.ObjectId institution String degree String @@ -56,6 +61,7 @@ model Education { } model Experience { + deletedAt DateTime? id String @id @default(auto()) @map("_id") @db.ObjectId company String role String @@ -69,6 +75,7 @@ model Experience { } model CtfEvent { + deletedAt DateTime? id String @id @default(auto()) @map("_id") @db.ObjectId name String organizer String @@ -81,6 +88,7 @@ model CtfEvent { } model GitHubRepository { + deletedAt DateTime? id String @id @default(auto()) @map("_id") @db.ObjectId githubId Int @unique // GitHub repository ID name String @@ -119,6 +127,7 @@ model GitHubRepository { } model BlogPost { + deletedAt DateTime? id String @id @default(auto()) @map("_id") @db.ObjectId title String slug String @unique @@ -135,6 +144,7 @@ model BlogPost { /// A reported vulnerability shown in the public Hall of Fame. /// When privateProgram is true the program name and link never leave the server. model BountyFinding { + deletedAt DateTime? id String @id @default(auto()) @map("_id") @db.ObjectId title String program String @@ -275,6 +285,7 @@ model InsightImport { /// Shared site-wide playback settings; public visitors can only mute their theme. model SiteAudioConfig { + deletedAt DateTime? id String @id @map("_id") music Float @default(0.75) dialogue Float @default(1) @@ -284,3 +295,19 @@ model SiteAudioConfig { fadeSeconds Float @default(4) enabledDialogueIds String[] } + +/// Append-only content revisions, including reversible trash operations. +model ContentRevision { + id String @id @default(auto()) @map("_id") @db.ObjectId + model String + recordId String + action String + actor String + label String + before Json? + after Json? + createdAt DateTime @default(now()) + + @@index([model, recordId, createdAt]) + @@index([createdAt]) +} diff --git a/public/social-preview.jpg b/public/social-preview.jpg new file mode 100644 index 0000000..04382fb Binary files /dev/null and b/public/social-preview.jpg differ diff --git a/scripts/check-content.ts b/scripts/check-content.ts new file mode 100644 index 0000000..16a18e3 --- /dev/null +++ b/scripts/check-content.ts @@ -0,0 +1,21 @@ +import { PrismaClient } from '@prisma/client'; + +const db = new PrismaClient(); +async function main() { +try { + if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required to build public content.'); + const active = { OR: [{ deletedAt: null }, { deletedAt: { isSet: false } }] }; + const [profile, projects, repos] = await Promise.all([ + db.personalData.findFirst({ where: active, select: { name: true, title: true, bio: true } }), + db.project.count({ where: active }), + db.gitHubRepository.count({ where: { AND: [active, { displayInPortfolio: true }] } }), + ]); + if (!profile?.name.trim() || !profile.title.trim() || !profile.bio.trim() || projects + repos < 1) throw new Error('Public content is incomplete: a named profile, title, bio and at least one project/repository are required.'); + console.log(`Content check passed: profile and ${projects + repos} projects/repositories are available.`); +} catch { + console.error('Content check failed. Build stopped to preserve the current deployment. Verify DATABASE_URL, database access, and a populated public profile with at least one project/repository.'); + process.exitCode = 1; +} finally { await db.$disconnect(); } + +} +void main(); diff --git a/scripts/measure-mobile.cjs b/scripts/measure-mobile.cjs new file mode 100644 index 0000000..16a2e6b --- /dev/null +++ b/scripts/measure-mobile.cjs @@ -0,0 +1,35 @@ +/* Run after building and starting the synthetic e2e server, never against production. */ +const { chromium, devices } = require('@playwright/test'); +const fs = require('node:fs/promises'); +(async () => { + const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH || undefined, args: ['--no-sandbox'] }); + try { + const context = await browser.newContext({ ...devices['Pixel 7'], reducedMotion: 'reduce' }); + const page = await context.newPage(); + const session = await context.newCDPSession(page); + await session.send('Network.enable'); + await session.send('Network.emulateNetworkConditions', { offline: false, latency: 150, downloadThroughput: 200000, uploadThroughput: 93750 }); + await session.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + const fonts = new Map(); + session.on('Network.responseReceived', event => { if (event.type === 'Font') fonts.set(event.requestId, { url: event.response.url, bytes: 0 }); }); + session.on('Network.loadingFinished', event => { if (fonts.has(event.requestId)) fonts.get(event.requestId).bytes = event.encodedDataLength; }); + await page.addInitScript(() => { + localStorage.setItem('nd-experience-mode', 'safe'); localStorage.setItem('nd-theme-muted-v1', 'true'); + window.__mobileMetrics = { lcp: 0, cls: 0, tbt: 0 }; + new PerformanceObserver(list => { for (const entry of list.getEntries()) window.__mobileMetrics.lcp = entry.startTime; }).observe({ type: 'largest-contentful-paint', buffered: true }); + new PerformanceObserver(list => { for (const entry of list.getEntries()) if (!entry.hadRecentInput) window.__mobileMetrics.cls += entry.value; }).observe({ type: 'layout-shift', buffered: true }); + new PerformanceObserver(list => { for (const entry of list.getEntries()) window.__mobileMetrics.tbt += Math.max(0, entry.duration - 50); }).observe({ type: 'longtask', buffered: true }); + }); + const errors = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto('http://127.0.0.1:3100/', { waitUntil: 'domcontentloaded', timeout: 60000 }); + await page.waitForFunction(() => document.fonts.status === 'loaded', { timeout: 20000 }).catch(() => {}); + await page.waitForTimeout(3000); + const metrics = await page.evaluate(() => ({ ...window.__mobileMetrics, navigation: performance.getEntriesByType('navigation').map(n => ({ ttfb: n.responseStart, domContentLoaded: n.domContentLoadedEventEnd })), fontStatus: document.fonts.status, safeMode: document.documentElement.classList.contains('safe-mode'), overflow: document.documentElement.scrollWidth > innerWidth })); + const result = { conditions: 'Chromium Pixel 7 emulation, 4x CPU slowdown, 1.6 Mbps down / 150 ms latency; synthetic local data; laboratory observation, not field Core Web Vitals', ...metrics, fontRequests: fonts.size, fontBytes: [...fonts.values()].reduce((sum, font) => sum + font.bytes, 0), errors }; + await fs.mkdir('test-results', { recursive: true }); + await fs.writeFile('test-results/mobile-performance.json', JSON.stringify(result, null, 2)); + await page.screenshot({ path: 'test-results/mobile-home.png', fullPage: false }); + console.log(JSON.stringify(result, null, 2)); + } finally { await browser.close(); } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/seed-e2e.ts b/scripts/seed-e2e.ts new file mode 100644 index 0000000..0887d0d --- /dev/null +++ b/scripts/seed-e2e.ts @@ -0,0 +1,21 @@ +import { Prisma, PrismaClient } from '@prisma/client'; +import { testDatabase } from './test-database'; + +const db = new PrismaClient({ datasourceUrl: testDatabase('e2e') }); +async function seed() { + // Exact allowlisted localhost URL checked above, and only synthetic fixture data is written. + for (const model of Prisma.dmmf.datamodel.models) await db.$runCommandRaw({ delete: model.name, deletes: [{ q: {}, limit: 0 }] }); + await db.personalData.create({ data: { name: 'Bikram Dey', title: 'Cybersecurity Analyst & Penetration Tester', bio: 'Synthetic browser test profile. Not production content.', github: 'https://github.com/modhack2003', linkedin: '', email: 'test@example.com', resumeUrl: '', bountyProfiles: [] } }); + await db.project.createMany({ data: [ + { title: 'Browser fixture scanner', description: 'A local-only test project.', tags: ['Security'], link: 'https://example.com/scanner' }, + { title: 'Browser fixture toolkit', description: 'Another test project.', tags: ['Testing'], link: 'https://example.com/toolkit' }, + ] }); + const now = new Date(); + await db.gitHubRepository.create({ data: { githubId: 1, name: 'fixture-repo', fullName: 'fixture/fixture-repo', description: 'Synthetic repository', htmlUrl: 'https://github.com/example/fixture', cloneUrl: 'https://github.com/example/fixture.git', language: 'TypeScript', topics: ['Security'], customTags: [], stargazersCount: 1, forksCount: 0, createdAt: now, updatedAt: now, pushedAt: now, size: 1, defaultBranch: 'main', visibility: 'public', displayInPortfolio: true, homepage: 'https://example.com/demo' } }); + await db.skill.create({ data: { languages: ['Python'], tools: ['Nmap'], areas: ['Web Security'] } }); + await db.blogPost.createMany({ data: Array.from({ length: 15 }, (_, i) => ({ title: `Browser article ${String(i + 1).padStart(2, '0')}`, slug: `browser-article-${i + 1}`, excerpt: i % 2 ? 'Cryptography field notes' : 'Web security field notes', content: `## Browser test\n\nSynthetic article ${i + 1}.`, tags: [i % 2 ? 'Crypto' : 'Web'], published: true, publishedAt: new Date(now.getTime() - i * 60000) })) }); + await db.blogPost.create({ data: { title: 'Private browser draft', slug: 'private-browser-draft', excerpt: 'Must not leak', content: 'Private draft text', tags: [], published: false } }); + await db.gitHubSyncState.create({ data: { id: 'github', username: 'modhack2003', lastSuccess: now, lastAttempt: now } }); + console.log('Synthetic browser fixtures seeded in disposable studio_e2e_test.'); +} +seed().finally(() => db.$disconnect()).catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/test-database.ts b/scripts/test-database.ts new file mode 100644 index 0000000..7679faf --- /dev/null +++ b/scripts/test-database.ts @@ -0,0 +1,7 @@ +/** Test scripts never accept production database URLs, including through fallback env vars. */ +export function testDatabase(name: 'e2e' | 'recovery') { + const env = name === 'e2e' ? 'E2E_DATABASE_URL' : 'RECOVERY_TEST_DATABASE_URL'; + const url = process.env[env]; + if (url !== `mongodb://127.0.0.1:27017/studio_${name}_test?replicaSet=rs0`) throw new Error(`Set ${env} to the disposable localhost studio_${name}_test replica-set URL.`); + return url; +} diff --git a/scripts/test-insights.ts b/scripts/test-insights.ts index da7a7f8..f7c12f3 100644 --- a/scripts/test-insights.ts +++ b/scripts/test-insights.ts @@ -29,6 +29,8 @@ async function main() { _id: { $oid: '000000000000000000000001' }, title: 'Old post', slug: 'old-post', excerpt: '', content: 'Retain me', tags: [], published: true, publishedAt: { $date: oldDate.toISOString() }, createdAt: { $date: oldDate.toISOString() }, updatedAt: { $date: oldDate.toISOString() }, }] }); + await db.blogPost.create({ data: { title: 'Old imported brief', slug: 'old-import', excerpt: '', content: 'Retain imported content', tags: [], published: true, publishedAt: oldDate } }); + await db.insightImport.create({ data: { id: 'CVE-2026-99999', postSlug: 'old-import', sourceDate: oldDate } }); const recent = await db.blogPost.findFirstOrThrow({ where: { published: true } }); await db.blogPost.update({ where: { id: recent.id }, data: { published: false, publishedAt: null } }); await db.insightSyncState.update({ where: { id: 'security-insights' }, data: { publishingMigratedAt: null, autoPublish: false } }); @@ -36,8 +38,11 @@ async function main() { assert.equal(migration.archived, 1); assert.equal(migration.published, 1); const old = await db.blogPost.findUniqueOrThrow({ where: { slug: 'old-post' } }); - assert.equal(old.published, false); - assert.ok(old.archivedAt); + assert.equal(old.published, true, 'manual write-ups must survive retention'); + assert.equal(old.archivedAt, null); + const archived = await db.blogPost.findUniqueOrThrow({ where: { slug: 'old-import' } }); + assert.equal(archived.published, false); + assert.ok(archived.archivedAt); assert.equal(old.content, 'Retain me'); assert.equal(await db.insightImport.count({ where: { postSlug: edited.slug } }), 1); await db.blogPost.update({ where: { id: recent.id }, data: { published: false } }); diff --git a/scripts/test-recovery.ts b/scripts/test-recovery.ts new file mode 100644 index 0000000..7466159 --- /dev/null +++ b/scripts/test-recovery.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { PrismaClient } from '@prisma/client'; +import { createContentClient, contentTransaction, restoreData, CONTENT_MODELS } from '../src/lib/content-history'; +import { checksum, exportBackup, importBackup, restoreRevision, validateBackup } from '../src/lib/content-recovery'; +import { testDatabase } from './test-database'; + +const raw = new PrismaClient({ datasourceUrl: testDatabase('recovery') }); +const db = createContentClient(raw); +async function main() { + await raw.$runCommandRaw({ dropDatabase: 1 }); + // Create the revision collection/index before transactions (Mongo cannot create collections in all transaction contexts). + await raw.$runCommandRaw({ create: 'ContentRevision' }); + await raw.$runCommandRaw({ createIndexes: 'BlogPost', indexes: [{ key: { slug: 1 }, name: 'BlogPost_slug_key', unique: true }] }); + const project = await db.project.create({ data: { title: 'Version one', description: 'Original', tags: [] } }); + await db.project.update({ where: { id: project.id }, data: { title: 'Version two' } }); + assert.equal(await raw.contentRevision.count(), 2); + await db.project.deleteMany({ where: { id: project.id } }); + assert.equal(await db.project.count(), 0, 'trash must disappear from public/admin reads'); + assert.equal(await db.project.findUnique({ where: { id: project.id } }), null); + assert.ok((await raw.project.findUniqueOrThrow({ where: { id: project.id } })).deletedAt, 'soft deletion must retain original content'); + const deletion = await raw.contentRevision.findFirstOrThrow({ where: { action: 'deleted' } }); + let current = await raw.project.findUnique({ where: { id: project.id } }); + await restoreRevision(raw, deletion.id, 'before', checksum(current), 'test-admin'); + assert.equal((await db.project.findUniqueOrThrow({ where: { id: project.id } })).title, 'Version two'); + await assert.rejects(restoreRevision(raw, deletion.id, 'before', checksum(current), 'test-admin'), /changed/); + // A failing audit insert must roll back its corresponding content edit. + const failing = new Proxy(raw, { get(target, key) { + if (key !== '$transaction') return Reflect.get(target, key); + return (work: (tx: object) => unknown) => raw.$transaction(tx => work(new Proxy(tx, { get(inner, property) { + if (property === 'contentRevision') return { create: () => { throw new Error('forced audit failure'); } }; + return Reflect.get(inner, property); + } })) as never); + } }); + await assert.rejects(createContentClient(failing).project.update({ where: { id: project.id }, data: { title: 'Must roll back' } }), /forced audit failure/); + assert.equal((await raw.project.findUniqueOrThrow({ where: { id: project.id } })).title, 'Version two'); + // The LinkedIn path shares its existing transaction and logs each write once. + await raw.$transaction(async tx => { const client = contentTransaction(tx, 'test-import'); await client.project.create({ data: { title: 'Imported', description: '', tags: [] } }); }); + assert.equal(await raw.contentRevision.count({ where: { actor: 'test-import' } }), 1); + const revisionsBeforeFailure = await raw.contentRevision.count(); + await assert.rejects(db.$transaction(async tx => { + await tx.project.create({ data: { title: 'Outer rollback', description: '', tags: [] } }); + throw new Error('outer rollback'); + }), /outer rollback/); + assert.equal(await raw.project.count({ where: { title: 'Outer rollback' } }), 0); + assert.equal(await raw.contentRevision.count(), revisionsBeforeFailure, 'outer rollback must include revisions'); + await db.personalData.create({ data: { name: 'Test Operator', title: 'Test', bio: 'Fixture', github: '', linkedin: '', email: 'test@example.com', resumeUrl: '', bountyProfiles: [] } }); + await db.skill.create({ data: { languages: ['Python'], tools: [], areas: [] } }); + await db.blogPost.create({ data: { title: 'Published test', slug: 'published-test', excerpt: '', content: 'Safe content', tags: ['Test'], published: true, publishedAt: new Date() } }); + await db.bountyFinding.create({ data: { title: 'Private finding', program: 'Hidden Program', privateProgram: true } }); + await db.siteAudioConfig.create({ data: { id: 'site-audio', enabledDialogueIds: [] } }); + await raw.insightImport.create({ data: { id: 'CVE-2026-12345', postSlug: 'published-test', sourceDate: new Date() } }); + await db.certificate.create({ data: { name: 'Test certificate', issuer: 'Fixture', year: 2026 } }); + await db.education.create({ data: { institution: 'Fixture university', degree: 'Test degree', duration: '2026' } }); + await db.experience.create({ data: { company: 'Fixture company', role: 'Test role', startDate: new Date() } }); + await db.ctfEvent.create({ data: { name: 'Fixture CTF', organizer: 'Fixture', date: new Date(), categories: ['Web'] } }); + await db.gitHubRepository.create({ data: { githubId: 1, name: 'fixture', fullName: 'fixture/test', htmlUrl: 'https://github.com/example/test', cloneUrl: 'https://github.com/example/test.git', topics: [], customTags: [], createdAt: new Date(), updatedAt: new Date(), pushedAt: new Date(), size: 1, defaultBranch: 'main', visibility: 'public' } }); + const backup = await exportBackup(raw); + const prepared = validateBackup(JSON.parse(JSON.stringify(backup))); + assert.throws(() => validateBackup({ ...backup, collections: { ...backup.collections, AdminCredential: [] } }), /supported/); + assert.throws(() => restoreData('Project', { id: project.id, title: 'Bad', description: '', tags: [], deletedAt: 'not-a-date' }), /Invalid/); + const beforeRows = await raw.project.findMany(); + // Simulate a fresh content database by removing only the exact test collections. + for (const model of CONTENT_MODELS) { + const collection = model; + await raw.$runCommandRaw({ delete: collection, deletes: [{ q: {}, limit: 0 }] }); + } + await raw.insightImport.deleteMany(); + await importBackup(raw, prepared, 'test-recovery'); + assert.equal(await raw.project.count(), beforeRows.length); + for (const model of CONTENT_MODELS) { + const delegate = (raw as unknown as Record }>)[model[0].toLowerCase() + model.slice(1)]; + assert.ok(await delegate.count(), `${model} must round-trip`); + } + current = await raw.project.findUnique({ where: { id: project.id } }); + assert.equal(current?.title, 'Version two'); + assert.equal(await raw.insightImport.count(), 1); + assert.equal((await db.bountyFinding.findFirstOrThrow()).privateProgram, true); + assert.equal((await db.siteAudioConfig.findUniqueOrThrow({ where: { id: 'site-audio' } })).music, 0.75); + assert.ok(await raw.contentRevision.count({ where: { action: 'backup-restored' } })); + assert.equal(await raw.adminCredential.count(), 0); + // A conflicting slug rolls back every earlier row from the same backup. + const copy = JSON.parse(JSON.stringify(backup)); + copy.collections.Project[0].title = 'Must not persist'; + copy.collections.BlogPost[0].id = '000000000000000000000099'; + await assert.rejects(importBackup(raw, validateBackup(copy), 'test-conflict')); + assert.equal((await raw.project.findUniqueOrThrow({ where: { id: project.id } })).title, 'Version two'); + console.log('Content recovery Mongo integration passed: atomic history, audit failure rollback, soft delete, stale restore rejection, import history, backup round trip, private content, ledger, and conflicting restore rollback.'); +} +main().finally(() => raw.$disconnect()).catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/scripts/test-throttles.ts b/scripts/test-throttles.ts index 9dc0c35..3c7cbd8 100644 --- a/scripts/test-throttles.ts +++ b/scripts/test-throttles.ts @@ -12,7 +12,7 @@ async function main() { 'Refusing to run outside the dedicated local studio_throttle_test database.'); process.env.DATABASE_URL = uri; process.env.ADMIN_PIN = '835194'; - const { prisma } = await import('../src/lib/prisma'); + const { prisma, rawPrisma } = await import('../src/lib/prisma'); const { checkAdminPin } = await import('../src/lib/admin-auth'); const { throttleTransaction } = await import('../src/lib/throttle-transaction'); const { POST: engagement } = await import('../src/app/api/engagements/route'); @@ -22,15 +22,15 @@ async function main() { const { NextRequest } = await import('next/server'); const clear = async () => { - await prisma.loginAttempt.deleteMany(); - await prisma.contactMessage.deleteMany(); - await prisma.engagement.deleteMany(); - await prisma.gitHubSyncState.deleteMany(); - await prisma.gitHubRepository.deleteMany(); - await prisma.experience.deleteMany(); - await prisma.skill.deleteMany(); - await prisma.adminCredential.deleteMany(); - await prisma.throttleGuard.deleteMany(); + await rawPrisma.loginAttempt.deleteMany(); + await rawPrisma.contactMessage.deleteMany(); + await rawPrisma.engagement.deleteMany(); + await rawPrisma.gitHubSyncState.deleteMany(); + await rawPrisma.gitHubRepository.deleteMany(); + await rawPrisma.experience.deleteMany(); + await rawPrisma.skill.deleteMany(); + await rawPrisma.adminCredential.deleteMany(); + await rawPrisma.throttleGuard.deleteMany(); }; const failures = async (count: number, sameIp: boolean) => { await prisma.loginAttempt.createMany({ data: Array.from({ length: count }, (_, i) => ({ diff --git a/src/app/api/admin/backup/route.ts b/src/app/api/admin/backup/route.ts new file mode 100644 index 0000000..caca1d8 --- /dev/null +++ b/src/app/api/admin/backup/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { rawPrisma } from '@/lib/prisma'; +import { requireAdminSession } from '@/lib/admin-auth'; +import { jsonError, revalidatePublic } from '@/lib/api'; +import { contentActor } from '@/lib/content-history'; +import { exportBackup, importBackup, MAX_BACKUP_BYTES, validateBackup } from '@/lib/content-recovery'; + +export const dynamic = 'force-dynamic'; +export const maxDuration = 60; +export async function GET(request: NextRequest) { + const denied = await requireAdminSession(request); if (denied) return denied; + try { + const backup = await exportBackup(rawPrisma); + return NextResponse.json(backup, { headers: { 'Cache-Control': 'private, no-store', 'Content-Disposition': `attachment; filename="own-verse-content-${new Date().toISOString().slice(0, 10)}.json"` } }); + } catch { return jsonError('Could not export content. Check the database connection and the 1,000-record / 3 MB backup limits.', 503); } +} +export async function POST(request: NextRequest) { + const denied = await requireAdminSession(request); if (denied) return denied; + if (Number(request.headers.get('content-length')) > MAX_BACKUP_BYTES) return jsonError('Backup too large (max 3 MB).', 413); + let prepared; + try { + // Bound the stream as well as Content-Length; a client can omit that header. + const reader = request.body?.getReader(); if (!reader) return jsonError('Backup required', 400); + const chunks: Uint8Array[] = []; let bytes = 0; + try { for (;;) { const { value, done } = await reader.read(); if (done) break; bytes += value.byteLength; if (bytes > MAX_BACKUP_BYTES) return jsonError('Backup too large (max 3 MB).', 413); chunks.push(value); } } + finally { await reader.cancel(); } + const body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + // Dry-run validates every record before making the restore button available. + prepared = validateBackup(body); + } catch { return jsonError('Invalid content backup. No records were changed.', 400); } + if (request.nextUrl.searchParams.get('preview') === 'true') return NextResponse.json({ records: prepared.count, collections: prepared.collections.map(({ model, rows }) => ({ model, count: rows.length })) }, { headers: { 'Cache-Control': 'private, no-store' } }); + try { + const result = await importBackup(rawPrisma, prepared, await contentActor()); + revalidatePublic(); + return NextResponse.json(result, { headers: { 'Cache-Control': 'private, no-store' } }); + } catch { return jsonError('Restore failed and was rolled back. Check for conflicting unique fields or database availability.', 409); } +} diff --git a/src/app/api/admin/history/route.ts b/src/app/api/admin/history/route.ts new file mode 100644 index 0000000..5b37e55 --- /dev/null +++ b/src/app/api/admin/history/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { rawPrisma } from '@/lib/prisma'; +import { requireAdminSession } from '@/lib/admin-auth'; +import { handleError, jsonError, OBJECT_ID, readJson, revalidatePublic } from '@/lib/api'; +import { contentActor, contentDelegate, isContentModel } from '@/lib/content-history'; +import { checksum, RecoveryConflict, restoreRevision } from '@/lib/content-recovery'; + +export const dynamic = 'force-dynamic'; +const headers = { 'Cache-Control': 'private, no-store' }; +export async function GET(request: NextRequest) { + const denied = await requireAdminSession(request); if (denied) return denied; + try { + const id = request.nextUrl.searchParams.get('id'); + if (id) { + if (!OBJECT_ID.test(id)) return jsonError('Invalid revision id', 400); + const revision = await rawPrisma.contentRevision.findUnique({ where: { id } }); + if (!revision || !isContentModel(revision.model)) return jsonError('Revision not found', 404); + const current = await contentDelegate(rawPrisma, revision.model).findFirst({ where: { id: revision.recordId } }); + return NextResponse.json({ revision, current, expected: checksum(current) }, { headers }); + } + const model = request.nextUrl.searchParams.get('model'); + if (model && !isContentModel(model)) return jsonError('Invalid collection', 400); + const page = Math.max(1, Math.min(10000, Number(request.nextUrl.searchParams.get('page')) || 1)); + if (request.nextUrl.searchParams.get('trash') === 'true') { + if (!model || !isContentModel(model)) return jsonError('Choose a trash collection', 400); + const delegate = contentDelegate(rawPrisma, model); + const where = { deletedAt: { not: null } }; + const [rows, total] = await Promise.all([delegate.findMany({ where, orderBy: { deletedAt: 'desc' }, skip: (Math.floor(page) - 1) * 30, take: 30 }), delegate.count({ where })]); + const items = await Promise.all(rows.map(async row => { + const revision = await rawPrisma.contentRevision.findFirst({ where: { model, recordId: row.id, action: 'deleted' }, orderBy: { createdAt: 'desc' }, select: { id: true } }); + return { id: revision?.id ?? '', model, recordId: row.id, action: 'deleted', actor: '', label: String(row.title ?? row.name ?? model), createdAt: row.deletedAt }; + })); + return NextResponse.json({ items, total, page: Math.floor(page) }, { headers }); + } + const where = model ? { model } : {}; + const [items, total] = await Promise.all([ + rawPrisma.contentRevision.findMany({ where, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], skip: (Math.floor(page) - 1) * 30, take: 30, select: { id: true, model: true, recordId: true, action: true, actor: true, label: true, createdAt: true } }), + rawPrisma.contentRevision.count({ where }), + ]); + return NextResponse.json({ items, total, page: Math.floor(page) }, { headers }); + } catch (error) { return handleError(error, 'Content history'); } +} +export async function POST(request: NextRequest) { + const denied = await requireAdminSession(request); if (denied) return denied; + const body = await readJson(request) as { id?: string; side?: string; expected?: string } | undefined; + if (!body?.id || !OBJECT_ID.test(body.id) || !['before', 'after'].includes(body.side ?? '') || !/^[a-f0-9]{64}$/.test(body.expected ?? '')) return jsonError('Invalid restore request', 400); + try { + await restoreRevision(rawPrisma, body.id, body.side as 'before' | 'after', body.expected!, await contentActor()); + revalidatePublic(); + return NextResponse.json({ success: true }, { headers }); + } catch (error) { + if (error instanceof RecoveryConflict) return jsonError(error.message, 409); + return handleError(error, 'Content restore'); + } +} diff --git a/src/app/api/import/linkedin/route.ts b/src/app/api/import/linkedin/route.ts index 5fa6070..bd32667 100644 --- a/src/app/api/import/linkedin/route.ts +++ b/src/app/api/import/linkedin/route.ts @@ -1,5 +1,6 @@ +import { contentTransaction, contentActor } from '@/lib/content-history'; import { NextRequest, NextResponse } from 'next/server'; -import { prisma } from '@/lib/prisma'; +import { prisma, rawPrisma } from '@/lib/prisma'; import { requireAdminSession } from '@/lib/admin-auth'; import { handleError, jsonError, readJson, revalidatePublic } from '@/lib/api'; import { importLinkedIn, parseLinkedInExport } from '@/lib/linkedin-import'; @@ -44,9 +45,10 @@ export async function POST(request: NextRequest) { overwriteProfile: body.overwriteProfile !== false, }; // A preview never writes; a real import commits all sections together. + const actor = await contentActor(); const summary = options.dryRun ? await importLinkedIn(prisma, parsed, options) - : await prisma.$transaction((tx) => importLinkedIn(tx, parsed, options), { timeout: 45_000 }); + : await rawPrisma.$transaction((tx) => importLinkedIn(contentTransaction(tx, actor), parsed, options), { timeout: 45_000 }); if (!summary.dryRun) revalidatePublic(); return NextResponse.json({ success: true, summary }); } catch (error) { diff --git a/src/app/api/site-audio/__tests__/route.test.ts b/src/app/api/site-audio/__tests__/route.test.ts index cff2b8f..91dffa2 100644 --- a/src/app/api/site-audio/__tests__/route.test.ts +++ b/src/app/api/site-audio/__tests__/route.test.ts @@ -21,10 +21,10 @@ test('public read returns defaults before the first admin save; writes require a }); test('admin saves a durable singleton; a separate public read receives the saved settings only', async () => { const config = { ...DEFAULT_AUDIO_SETTINGS, music: 0.3, intervalSeconds: 35, enabledDialogueIds: [] }; - jest.mocked(prisma.siteAudioConfig.upsert).mockResolvedValue({ id: 'site-audio', ...config }); + jest.mocked(prisma.siteAudioConfig.upsert).mockResolvedValue({ id: 'site-audio', deletedAt: null, ...config }); expect((await PUT(req(config))).status).toBe(200); expect(prisma.siteAudioConfig.upsert).toHaveBeenCalledWith({ where: { id: 'site-audio' }, update: config, create: { id: 'site-audio', ...config } }); - jest.mocked(prisma.siteAudioConfig.findUnique).mockResolvedValue({ id: 'site-audio', ...config }); + jest.mocked(prisma.siteAudioConfig.findUnique).mockResolvedValue({ id: 'site-audio', deletedAt: null, ...config }); expect(await (await GET()).json()).toEqual(config); }); test.each([{ music: 2 }, { intervalSeconds: 0 }, { fadeSeconds: -1 }, { enabledDialogueIds: ['external'] }, { enabledDialogueIds: ['pain', 'pain'] }, { src: 'https://example.com/file.mp3' }])('rejects malformed settings %o', async patch => { diff --git a/src/app/blog/[slug]/page.tsx b/src/app/blog/[slug]/page.tsx index 53212d3..587ff92 100644 --- a/src/app/blog/[slug]/page.tsx +++ b/src/app/blog/[slug]/page.tsx @@ -1,24 +1,19 @@ +import { cache } from 'react'; +import { jsonLd, publicMetadata, SITE_URL } from '@/lib/seo'; import Link from 'next/link'; import { notFound } from 'next/navigation'; import { ArrowLeft } from 'lucide-react'; import { HeaderNav } from '@/components/header-nav'; import { Markdown } from '@/lib/markdown'; import { prisma } from '@/lib/prisma'; -import { getHiddenSections, isBuildPhase, readTime } from '@/lib/portfolio-data'; +import { getHiddenSections, readTime } from '@/lib/portfolio-data'; export const revalidate = 60; export const dynamicParams = true; type Props = { params: Promise<{ slug: string }> }; -async function getPost(slug: string) { - try { - return await prisma.blogPost.findFirst({ where: { slug, published: true } }); - } catch (error) { - if (isBuildPhase()) return null; - throw error; - } -} +const getPost = cache((slug: string) => prisma.blogPost.findFirst({ where: { slug, published: true } })); export async function generateStaticParams() { return []; @@ -27,8 +22,9 @@ export async function generateStaticParams() { export async function generateMetadata({ params }: Props) { const { slug } = await params; const post = await getPost(slug); - if (!post) return { title: 'Not found' }; - return { title: `${post.title} | Blog`, description: post.excerpt || undefined }; + if (!post) return { title: 'Not found', robots: { index: false, follow: false } }; + const metadata = publicMetadata(`${post.title} | Bikram Dey`, post.excerpt, `/blog/${encodeURIComponent(post.slug)}`); + return { ...metadata, openGraph: { ...metadata.openGraph, type: 'article', publishedTime: post.publishedAt?.toISOString(), modifiedTime: post.updatedAt.toISOString(), authors: ['Bikram Dey'], tags: post.tags } }; } export default async function BlogPostPage({ params }: Props) { @@ -43,7 +39,8 @@ export default async function BlogPostPage({ params }: Props) { return (