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

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