feat: import private content from external bundles
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(); }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user