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