diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 9c04028..975eb16 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -120,21 +120,34 @@ return 404 when accessed directly. Upload URLs are public and immutable, so upload a replacement rather than overwrite an existing URL. Deleting content does not recall files already cached by a visitor. -## Import this repository's original portfolio +## Import private portfolio content -New installations show generic samples. To preserve this repository's original -English/Persian content and project URLs, run the explicit import **before editing -any content**: +Personal content is supplied separately, never embedded in the application image. +A version-1 bundle contains `bundle.json` with `content` (settings, languages, +sections, entries) and `files` (original URL, relative file path, SHA-256, MIME, +width, height). Keep the bundle outside the Git checkout. ```sh -npm run maintenance -- import-legacy -# Or in Docker: -docker compose -f compose.selfhost.yaml exec portfolio-app node maintenance.mjs import-legacy +npm run maintenance -- import-legacy /absolute/path/to/private-bundle ``` -The import is transactional and only replaces untouched samples. It refuses to -run twice or overwrite edits. Creating the owner account does not prevent import. -The source fixture is `src/seed/legacy.json`; startup never imports it implicitly. +The import validates the bundle before writing, preserves project IDs, copies +images into persistent uploads, and replaces only untouched sample content. +It refuses repeated imports or edited portfolios. Creating an owner is independent. + +For an existing, edited installation, migrate only references to the old bundled +images before upgrading away from those assets: + +```sh +npm run maintenance -- migrate-legacy-media /absolute/path/to/private-bundle +``` + +This operation preserves content and existing uploads and is safe to repeat. +Back up first. For Docker, mount the external bundle read-only at `/import` and +run `node maintenance.mjs import-legacy /import` (or `migrate-legacy-media`). +Do not copy the bundle into the image or repository. Git history is unchanged. + +`create-user` is an alias for `create-owner`; neither runs at application startup. ## Backups and restore diff --git a/scripts/maintenance.ts b/scripts/maintenance.ts index 98c1eda..de54724 100644 --- a/scripts/maintenance.ts +++ b/scripts/maintenance.ts @@ -29,14 +29,17 @@ async function main() { if (!argument || !target) throw new Error('Usage: restore BACKUP_DIRECTORY NEW_DATA_DIRECTORY'); restore(argument, target); console.log('Restored into the new directory. Stop the app before switching its data volume.'); return; } - if (!['init', 'create-owner', 'reset-password', 'import-legacy', 'backup'].includes(command)) throw new Error('Commands: init, create-owner, reset-password, import-legacy, backup DIRECTORY, restore BACKUP_DIRECTORY NEW_DATA_DIRECTORY'); + if (!['init', 'create-owner', 'create-user', 'reset-password', 'import-legacy', 'migrate-legacy-media', 'backup'].includes(command)) throw new Error('Commands: init, create-owner (alias: create-user), reset-password, import-legacy BUNDLE_DIRECTORY, migrate-legacy-media BUNDLE_DIRECTORY, backup DIRECTORY, restore BACKUP_DIRECTORY NEW_DATA_DIRECTORY'); const connection = initialize(); try { - if (command === 'create-owner' || command === 'reset-password') { + if (command === 'create-owner' || command === 'create-user' || command === 'reset-password') { const { email, password } = await credentials(); await withContentLock(() => provisionOwner(connection, email, password, command === 'reset-password')); - console.log(command === 'create-owner' ? 'Owner created.' : 'Password updated. All sessions revoked.'); - } else if (command === 'import-legacy') { await withContentLock(() => importLegacy(connection)); console.log('Existing portfolio imported.'); } + console.log(command === 'reset-password' ? 'Password updated. All sessions revoked.' : 'Owner created.'); + } else if (command === 'import-legacy' || command === 'migrate-legacy-media') { + await withContentLock(() => importLegacy(connection, argument, command === 'migrate-legacy-media')); + console.log(command === 'import-legacy' ? 'Portfolio imported.' : 'Legacy media references migrated; content preserved.'); + } else if (command === 'backup') { if (!argument) throw new Error('Supply a new backup directory'); await backup(connection, dataDirectory(), argument); console.log('Backup complete.'); } else console.log('Database initialized.'); } finally { connection.sqlite.close(); } diff --git a/src/app/media/[key]/route.ts b/src/app/media/[key]/route.ts index c1d9e93..c6529a1 100644 --- a/src/app/media/[key]/route.ts +++ b/src/app/media/[key]/route.ts @@ -6,7 +6,7 @@ import { media } from '@/server/schema'; export async function GET(_request: Request, { params }: { params: Promise<{ key: string }> }) { const { key } = await params; - if (!/^[0-9a-f-]{36}\.(webp|pdf)$/.test(key)) return new Response(null, { status: 404 }); + if (!/^(?:[0-9a-f-]{36}\.(?:webp|pdf)|[0-9a-f]{64}\.(?:png|jpg|webp))$/.test(key)) return new Response(null, { status: 404 }); const record = getDatabase().db.select().from(media).where(eq(media.key, key)).get(); if (!record) return new Response(null, { status: 404 }); try { diff --git a/src/server/legacy-import.ts b/src/server/legacy-import.ts index 764a706..25d6305 100644 --- a/src/server/legacy-import.ts +++ b/src/server/legacy-import.ts @@ -1,50 +1,104 @@ -import legacy from '../seed/legacy.json'; +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; import { eq } from 'drizzle-orm'; -import type { DatabaseConnection } from './db'; +import { z } from 'zod'; +import { dataDirectory, type DatabaseConnection } from './db'; import * as tables from './schema'; -import { entryKinds, entrySchemas, sectionKeys, sectionSchemas, settingsSchema, type EntryKind } from '../content/contracts'; -import { entrySamples } from '../content/samples'; -import { messageDefaults, flattenMessages } from '../content/messages'; -import { ContentError } from './content'; +import { entrySchema, entrySchemas, languageSchema, sectionKeys, sectionSchema, sectionSchemas, settingsSchema } from '../content/contracts'; +import { ContentError, readContent, type ContentSnapshot } from './content'; -export function importLegacy(connection: DatabaseConnection) { +const bundleSchema = z.object({ + version: z.literal(1), + content: z.object({ settings: settingsSchema, languages: z.array(languageSchema), sections: z.array(sectionSchema), entries: z.array(entrySchema) }), + files: z.array(z.object({ + source: z.string().regex(/^\/images\/[a-zA-Z0-9/_.()-]+$/), file: z.string(), + sha256: z.string().regex(/^[a-f0-9]{64}$/), mime: z.enum(['image/png', 'image/jpeg', 'image/webp']), + width: z.number().int().positive(), height: z.number().int().positive(), + })), +}); + +function readBundle(directory?: string) { + if (!directory) throw new ContentError('Supply an external bundle directory containing bundle.json'); + const root = realpathSync(directory); + const bundle = bundleSchema.parse(JSON.parse(readFileSync(path.join(root, 'bundle.json'), 'utf8'))); + const { content } = bundle; + const codes = new Set(content.languages.map(language => language.code)); + if (codes.size !== content.languages.length || !content.languages.some(language => language.code === content.settings.defaultLanguage && language.enabled)) throw new ContentError('Invalid bundle languages'); + if (new Set(content.entries.map(entry => entry.id)).size !== content.entries.length) throw new ContentError('Duplicate entry IDs'); + if (content.sections.length !== sectionKeys.length || sectionKeys.some(key => !content.sections.some(section => section.key === key))) throw new ContentError('Bundle must contain each section once'); + for (const section of content.sections) { + if (!section.translations[content.settings.defaultLanguage]) throw new ContentError('Missing default section translation'); + for (const [code, translation] of Object.entries(section.translations)) { + if (!codes.has(code)) throw new ContentError('Unknown translation language'); + sectionSchemas[section.key].parse(translation); + } + } + for (const entry of content.entries) { + entrySchemas[entry.kind].parse(entry.shared); + for (const [code, translation] of Object.entries(entry.translations)) { + if (!codes.has(code)) throw new ContentError('Unknown translation language'); + entrySchemas[entry.kind].parse({ ...entry.shared, ...translation }); + } + } + if (!content.settings.metadata[content.settings.defaultLanguage]) throw new ContentError('Missing default metadata'); + if (new Set(bundle.files.map(file => file.source)).size !== bundle.files.length) throw new ContentError('Duplicate media sources'); + // Validate all files before copying anything or changing the database. + const files = bundle.files.map(file => { + const absolute = realpathSync(path.resolve(root, file.file)); + if (!absolute.startsWith(root + path.sep)) throw new ContentError('Bundle file must stay inside its directory'); + const bytes = readFileSync(absolute); + if (createHash('sha256').update(bytes).digest('hex') !== file.sha256) throw new ContentError(`Bundle checksum mismatch: ${file.file}`); + const extension = file.mime === 'image/jpeg' ? 'jpg' : file.mime.slice(6); + return { ...file, bytes, key: `${file.sha256}.${extension}` }; + }); + return { content, files }; +} + +function rewriteMedia(value: T, urls: Map): T { + return JSON.parse(JSON.stringify(value), (_key, item) => { + if (typeof item !== 'string') return item; + if (urls.has(item)) return urls.get(item); + if ((item.startsWith('/images/') && item !== '/images/portfolio-placeholder.svg') || item.startsWith('/media/')) throw new ContentError(`Missing bundle media: ${item}`); + return item; + }) as T; +} + +function saveSnapshot(connection: DatabaseConnection, content: ContentSnapshot) { + const { db } = connection; + db.update(tables.siteSettings).set({ data: content.settings }).where(eq(tables.siteSettings.id, 1)).run(); + for (const data of content.sections) db.update(tables.sections).set({ data }).where(eq(tables.sections.key, data.key)).run(); + for (const entry of content.entries) db.update(tables.entries).set(entry).where(eq(tables.entries.id, entry.id)).run(); +} + +/** Imports external content, or migrates only media references in an edited portfolio. */ +export function importLegacy(connection: DatabaseConnection, directory?: string, mediaOnly = false, destination = dataDirectory()) { + const isSample = () => connection.db.select().from(tables.state).where(eq(tables.state.key, 'content-status')).get()?.value === 'sample'; + if (!mediaOnly && !isSample()) throw new ContentError('Import is only available before content has been edited'); + const bundle = readBundle(directory); + const urls = new Map(bundle.files.map(file => [file.source, `/media/${file.key}`])); + const imported = rewriteMedia(bundle.content, urls); + const current = readContent(connection); + // Existing uploads and unrelated edits survive a media-only migration. + const migrated = JSON.parse(JSON.stringify(current), (_key, item) => typeof item === 'string' ? urls.get(item) ?? item : item) as ContentSnapshot; + const uploads = path.join(destination, 'uploads'); + mkdirSync(uploads, { recursive: true, mode: 0o700 }); + for (const file of bundle.files) { + const target = path.join(uploads, file.key); + try { writeFileSync(target, file.bytes, { flag: 'wx', mode: 0o600 }); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST' || createHash('sha256').update(readFileSync(target)).digest('hex') !== file.sha256) throw error; + } + } connection.db.transaction(db => { - if (db.select().from(tables.state).where(eq(tables.state.key, 'content-status')).get()?.value !== 'sample') throw new ContentError('Import is only available before content has been edited'); - const settings = settingsSchema.parse({ - defaultLanguage: 'en', avatar: { src: '/images/avatar.png', width: 192, height: 192 }, - contactUrl: 'https://www.linkedin.com/messaging/compose/?recipient=ACoAAEJsAwMBCv3L9dYcEXWam-l3pLOJ3UspX7w', blogUrl: '', coursesUrl: '', - email: 'amirhosseinkhalili901@gmail.com', contacts: [ - { id: 'phone', label: '09388263077', href: 'tel:09388263077' }, - { id: 'linkedin', label: '@amiirkhl', href: 'https://linkedin.com/in/amiirkhl' }, - { id: 'github', label: 'git.amiirkhl.ir', href: 'https://git.amiirkhl.ir' }, - { id: 'telegram', label: '@amiirkhl', href: 'https://t.me/amiirkhl' }, - ], metadata: { en: legacy.messages.en.metadata, fa: legacy.messages.fa.metadata }, - }); - db.update(tables.siteSettings).set({ data: settings }).where(eq(tables.siteSettings.id, 1)).run(); - for (const locale of ['en', 'fa'] as const) { - const labels = flattenMessages(legacy.messages[locale]); - const data = { code: locale, name: locale === 'en' ? 'English' : 'فارسی', direction: locale === 'en' ? 'ltr' as const : 'rtl' as const, enabled: true, messages: Object.fromEntries(Object.entries(labels).filter(([key]) => key in messageDefaults)) }; - db.insert(tables.languages).values({ code: locale, data }).onConflictDoUpdate({ target: tables.languages.code, set: { data } }).run(); - } - for (const key of sectionKeys) { - const schema = sectionSchemas[key]; - const translations = Object.fromEntries(['en', 'fa'].map(locale => [locale, schema.parse(Object.fromEntries(Object.keys(schema.shape).map(field => [field, (legacy.messages[locale as 'en' | 'fa'][key] as Record)[field]])))])); - db.update(tables.sections).set({ data: { key, visible: true, translations } }).where(eq(tables.sections.key, key)).run(); - } + if (!mediaOnly && !isSample()) throw new ContentError('Import is only available before content has been edited'); + for (const file of bundle.files) db.insert(tables.media).values({ key: file.key, originalName: path.basename(file.file), mime: file.mime, width: file.width, height: file.height, size: file.bytes.length, createdAt: Date.now() }).onConflictDoNothing().run(); + if (mediaOnly) { saveSnapshot(connection, migrated); return; } db.delete(tables.entries).run(); - const normalize = (kind: EntryKind, item: Record) => { - const { id: _id, ...data } = item; - void _id; - return entrySchemas[kind].parse({ ...entrySamples[kind], ...(kind === 'experience' ? { isCurrent: false } : {}), ...data, - ...(kind === 'projects' ? { caseStudy: { ...entrySamples.projects.caseStudy, ...(data.caseStudy as object) } } : {}), - }); - }; - for (const kind of entryKinds) { - legacy.entries.en[kind].forEach((entry, position) => { - const translated = legacy.entries.fa[kind].find(item => item.id === entry.id)!; - db.insert(tables.entries).values({ id: entry.id, kind, position, visible: true, shared: normalize(kind, entry), translations: { fa: normalize(kind, translated) } }).run(); - }); - } + for (const entry of imported.entries) db.insert(tables.entries).values(entry).run(); + db.delete(tables.languages).run(); + for (const data of imported.languages) db.insert(tables.languages).values({ code: data.code, data }).run(); + saveSnapshot(connection, imported); db.update(tables.state).set({ value: 'legacy-imported' }).where(eq(tables.state.key, 'content-status')).run(); }); } diff --git a/tests/content.test.ts b/tests/content.test.ts index 8e03e15..3fbe0c3 100644 --- a/tests/content.test.ts +++ b/tests/content.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { openDatabase, migrateDatabase } from '../src/server/db'; @@ -43,13 +43,15 @@ test('runtime projects and arbitrary locales resolve, hidden and missing URLs do for (const parts of [['en'], ['unknown'], ['ar','bad'], ['admin']]) assert.throws(() => resolvePublicRoute(readContent(connection), parts)); assert.throws(() => saveContent(connection, 'languages', { code: 'en', name: 'English', direction: 'ltr', enabled: false, messages: {} })); }); -test('legacy import preserves English and Persian projects and only runs once', t => { - const connection = setup(t); importLegacy(connection); - const snapshot = readContent(connection); - assert.equal(resolvePublicRoute(snapshot, ['projects','qlockify']).project?.title, 'Qlockify'); - assert.equal(resolvePublicRoute(snapshot, ['fa']).portfolio.direction, 'rtl'); - assert.ok(snapshot.entries.length > 5); - assert.throws(() => importLegacy(connection)); +test('external import preserves content and only runs once', t => { + const connection = setup(t); + const directory = mkdtempSync(path.join(tmpdir(), 'portfolio-bundle-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const content = readContent(connection); + writeFileSync(path.join(directory, 'bundle.json'), JSON.stringify({ version: 1, content, files: [] })); + importLegacy(connection, directory, false, directory); + assert.deepEqual(readContent(connection), content); + assert.throws(() => importLegacy(connection, directory)); }); test('startup rejects a database migrated by an incompatible application image', t => { const connection = setup(t);