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
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

View File

@@ -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(); }

View File

@@ -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 {

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 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<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 => {
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<string, unknown>)[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<string, unknown>) => {
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();
});
}

View File

@@ -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);