feat: import private content from external bundles

This commit is contained in:
2026-09-16 10:13:17 +03:30
parent 9f749e7d81
commit db8f257a98
5 changed files with 137 additions and 65 deletions

View File

@@ -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 upload a replacement rather than overwrite an existing URL. Deleting content
does not recall files already cached by a visitor. 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 Personal content is supplied separately, never embedded in the application image.
English/Persian content and project URLs, run the explicit import **before editing A version-1 bundle contains `bundle.json` with `content` (settings, languages,
any content**: sections, entries) and `files` (original URL, relative file path, SHA-256, MIME,
width, height). Keep the bundle outside the Git checkout.
```sh ```sh
npm run maintenance -- import-legacy npm run maintenance -- import-legacy /absolute/path/to/private-bundle
# Or in Docker:
docker compose -f compose.selfhost.yaml exec portfolio-app node maintenance.mjs import-legacy
``` ```
The import is transactional and only replaces untouched samples. It refuses to The import validates the bundle before writing, preserves project IDs, copies
run twice or overwrite edits. Creating the owner account does not prevent import. images into persistent uploads, and replaces only untouched sample content.
The source fixture is `src/seed/legacy.json`; startup never imports it implicitly. 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 ## Backups and restore

View File

@@ -29,14 +29,17 @@ async function main() {
if (!argument || !target) throw new Error('Usage: restore BACKUP_DIRECTORY NEW_DATA_DIRECTORY'); 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; 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(); const connection = initialize();
try { try {
if (command === 'create-owner' || command === 'reset-password') { if (command === 'create-owner' || command === 'create-user' || command === 'reset-password') {
const { email, password } = await credentials(); const { email, password } = await credentials();
await withContentLock(() => provisionOwner(connection, email, password, command === 'reset-password')); await withContentLock(() => provisionOwner(connection, email, password, command === 'reset-password'));
console.log(command === 'create-owner' ? 'Owner created.' : 'Password updated. All sessions revoked.'); console.log(command === 'reset-password' ? 'Password updated. All sessions revoked.' : 'Owner created.');
} else if (command === 'import-legacy') { await withContentLock(() => importLegacy(connection)); console.log('Existing portfolio imported.'); } } 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 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.'); else console.log('Database initialized.');
} finally { connection.sqlite.close(); } } finally { connection.sqlite.close(); }

View File

@@ -6,7 +6,7 @@ import { media } from '@/server/schema';
export async function GET(_request: Request, { params }: { params: Promise<{ key: string }> }) { export async function GET(_request: Request, { params }: { params: Promise<{ key: string }> }) {
const { key } = await params; 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(); const record = getDatabase().db.select().from(media).where(eq(media.key, key)).get();
if (!record) return new Response(null, { status: 404 }); if (!record) return new Response(null, { status: 404 });
try { try {

View File

@@ -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 { 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 * as tables from './schema';
import { entryKinds, entrySchemas, sectionKeys, sectionSchemas, settingsSchema, type EntryKind } from '../content/contracts'; import { entrySchema, entrySchemas, languageSchema, sectionKeys, sectionSchema, sectionSchemas, settingsSchema } from '../content/contracts';
import { entrySamples } from '../content/samples'; import { ContentError, readContent, type ContentSnapshot } from './content';
import { messageDefaults, flattenMessages } from '../content/messages';
import { ContentError } 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<T>(value: T, urls: Map<string, string>): 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 => { 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'); if (!mediaOnly && !isSample()) throw new ContentError('Import is only available before content has been edited');
const settings = settingsSchema.parse({ 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();
defaultLanguage: 'en', avatar: { src: '/images/avatar.png', width: 192, height: 192 }, if (mediaOnly) { saveSnapshot(connection, migrated); return; }
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<string, unknown>)[field]])))]));
db.update(tables.sections).set({ data: { key, visible: true, translations } }).where(eq(tables.sections.key, key)).run();
}
db.delete(tables.entries).run(); db.delete(tables.entries).run();
const normalize = (kind: EntryKind, item: Record<string, unknown>) => { for (const entry of imported.entries) db.insert(tables.entries).values(entry).run();
const { id: _id, ...data } = item; db.delete(tables.languages).run();
void _id; for (const data of imported.languages) db.insert(tables.languages).values({ code: data.code, data }).run();
return entrySchemas[kind].parse({ ...entrySamples[kind], ...(kind === 'experience' ? { isCurrent: false } : {}), ...data, saveSnapshot(connection, imported);
...(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();
});
}
db.update(tables.state).set({ value: 'legacy-imported' }).where(eq(tables.state.key, 'content-status')).run(); db.update(tables.state).set({ value: 'legacy-imported' }).where(eq(tables.state.key, 'content-status')).run();
}); });
} }

View File

@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; 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 { tmpdir } from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { openDatabase, migrateDatabase } from '../src/server/db'; 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)); 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: {} })); 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 => { test('external import preserves content and only runs once', t => {
const connection = setup(t); importLegacy(connection); const connection = setup(t);
const snapshot = readContent(connection); const directory = mkdtempSync(path.join(tmpdir(), 'portfolio-bundle-'));
assert.equal(resolvePublicRoute(snapshot, ['projects','qlockify']).project?.title, 'Qlockify'); t.after(() => rmSync(directory, { recursive: true, force: true }));
assert.equal(resolvePublicRoute(snapshot, ['fa']).portfolio.direction, 'rtl'); const content = readContent(connection);
assert.ok(snapshot.entries.length > 5); writeFileSync(path.join(directory, 'bundle.json'), JSON.stringify({ version: 1, content, files: [] }));
assert.throws(() => importLegacy(connection)); 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 => { test('startup rejects a database migrated by an incompatible application image', t => {
const connection = setup(t); const connection = setup(t);