import { connect } from '@tursodatabase/database'; import { spawnSync } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { mkdir, readFile, link, rm, writeFile } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; import process from 'node:process'; const mode = process.argv[2]; const subject = process.argv[3]; const confirmation = '--confirm-article-import'; const databasePath = resolve( process.env.TDR_DATABASE_PATH || './data/shd-build-development.db', ); const imageDirectory = resolve( process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons', ); const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const STORAGE_KEY_PATTERN = /^library-[0-9a-f-]{36}\.webp$/i; const failUsage = () => { console.error( 'Usage: node scripts/article-transfer.mjs export [output.json]', ); console.error( ` or: ARTICLE_IMPORT_ENABLED=true node scripts/article-transfer.mjs import ${confirmation}`, ); process.exit(2); }; if (!['export', 'import'].includes(mode) || !subject) failUsage(); const hash = (value) => createHash('sha256').update(value).digest('hex'); const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`; const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value); async function insertRow(database, table, row) { const columns = Object.keys(row); if (!columns.length) throw new Error(`Cannot insert an empty ${table} row.`); await ( await database.prepare( `INSERT INTO ${quoteIdentifier(table)} (${columns.map(quoteIdentifier).join(',')}) VALUES (${columns.map(() => '?').join(',')})`, ) ).run(...columns.map((column) => row[column])); } async function exportArticle() { const database = await connect(databasePath); const article = await ( await database.prepare( `SELECT * FROM articles WHERE id=? OR slug=? ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END LIMIT 1`, ) ).get(subject, subject, subject); if (!article) throw new Error('Article not found.'); if (article.kind !== 'news') throw new Error('Only News articles can use this transfer command.'); if (article.deleted_at) throw new Error('Deleted News articles cannot be transferred.'); const external = await ( await database.prepare( 'SELECT article_id FROM external_news_sources WHERE article_id=?', ) ).get(article.id); if (external) throw new Error( 'Syndicated News articles must come from their source feed.', ); const embeds = await ( await database.prepare( `SELECT COUNT(*) AS count FROM article_revision_build_embeds WHERE revision_id IN (SELECT id FROM article_revisions WHERE article_id=?)`, ) ).get(article.id); if (Number(embeds?.count ?? 0) > 0) throw new Error('News article transfers cannot contain Build embeds.'); const owner = await ( await database.prepare('SELECT id,username FROM users WHERE id=?') ).get(article.owner_id); if (!owner?.username) throw new Error('The article owner does not have a transferable username.'); const draft = await ( await database.prepare('SELECT * FROM article_drafts WHERE article_id=?') ).get(article.id); const revisions = await ( await database.prepare( 'SELECT * FROM article_revisions WHERE article_id=? ORDER BY revision_number', ) ).all(article.id); if (!draft || !revisions.length) throw new Error('The article is missing its draft or revision history.'); const revisionIds = revisions.map((revision) => String(revision.id)); const placeholders = revisionIds.map(() => '?').join(','); const revisionTags = await ( await database.prepare( `SELECT * FROM article_revision_tags WHERE revision_id IN (${placeholders}) ORDER BY revision_id,ordinal`, ) ).all(...revisionIds); const media = await ( await database.prepare( 'SELECT * FROM article_media WHERE article_id=? ORDER BY image_id', ) ).all(article.id); const search = await ( await database.prepare('SELECT * FROM article_search WHERE article_id=?') ).get(article.id); const imageIds = [...new Set(media.map((row) => String(row.image_id)))]; const images = imageIds.length ? await ( await database.prepare( `SELECT * FROM managed_images WHERE id IN (${imageIds.map(() => '?').join(',')}) ORDER BY id`, ) ).all(...imageIds) : []; if (images.length !== imageIds.length) throw new Error('The article references missing managed-image metadata.'); const files = []; for (const image of images) { const storageKey = String(image.storage_key); if (!STORAGE_KEY_PATTERN.test(storageKey)) throw new Error('The article uses an unsupported managed-image key.'); const bytes = await readFile(join(imageDirectory, storageKey)); if (bytes.byteLength !== Number(image.byte_size)) throw new Error(`Managed image ${image.id} has an invalid byte size.`); files.push({ storageKey, byteSize: bytes.byteLength, sha256: hash(bytes), contentBase64: bytes.toString('base64'), }); } const bundle = { format: 'shd-build-article-transfer', version: 1, createdAt: new Date().toISOString(), owner: { sourceId: String(owner.id), username: String(owner.username) }, article, draft, revisions, revisionTags, media, search: search ?? null, images, files, }; const timestamp = new Date() .toISOString() .replaceAll(':', '-') .replace(/\.\d{3}Z$/, 'Z'); const outputPath = resolve( process.argv[4] || `./data/backups/article-${String(article.slug)}-${timestamp}.json`, ); await mkdir(dirname(outputPath), { recursive: true }); await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx', mode: 0o600, }); console.log( `Exported News article "${article.slug}" with ${revisions.length} revisions and ${files.length} images.`, ); console.log(`Article bundle: ${outputPath}`); } function validateBundle(bundle) { if ( !isRecord(bundle) || bundle.format !== 'shd-build-article-transfer' || bundle.version !== 1 || !isRecord(bundle.owner) || !isRecord(bundle.article) || !isRecord(bundle.draft) || !Array.isArray(bundle.revisions) || !Array.isArray(bundle.revisionTags) || !Array.isArray(bundle.media) || !Array.isArray(bundle.images) || !Array.isArray(bundle.files) || bundle.article.kind !== 'news' || !UUID_PATTERN.test(String(bundle.article.id)) || !bundle.owner.username || !bundle.revisions.length ) { throw new Error( 'The selected file is not a supported News article bundle.', ); } const articleId = String(bundle.article.id); if ( bundle.draft.article_id !== articleId || bundle.revisions.some( (revision) => !isRecord(revision) || revision.article_id !== articleId || !UUID_PATTERN.test(String(revision.id)), ) || bundle.media.some( (row) => !isRecord(row) || row.article_id !== articleId, ) || bundle.images.some( (image) => !isRecord(image) || !UUID_PATTERN.test(String(image.id)) || !STORAGE_KEY_PATTERN.test(String(image.storage_key)) || image.source_entity_type !== null || image.source_entity_id !== null, ) ) { throw new Error('The News article bundle contains inconsistent records.'); } const imageIds = new Set(bundle.images.map((image) => String(image.id))); const files = new Map(); for (const file of bundle.files) { if ( !isRecord(file) || !STORAGE_KEY_PATTERN.test(String(file.storageKey)) || !Number.isInteger(file.byteSize) || file.byteSize <= 0 || typeof file.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(file.sha256) || typeof file.contentBase64 !== 'string' ) { throw new Error( 'The News article bundle contains an invalid image file.', ); } const bytes = Buffer.from(file.contentBase64, 'base64'); if (bytes.byteLength !== file.byteSize || hash(bytes) !== file.sha256) throw new Error('The News article bundle contains a corrupt image file.'); files.set(String(file.storageKey), bytes); } if ( bundle.images.some((image) => !files.has(String(image.storage_key))) || bundle.media.some((row) => !imageIds.has(String(row.image_id))) ) { throw new Error('The News article bundle is missing image dependencies.'); } const revisionIds = new Set( bundle.revisions.map((revision) => String(revision.id)), ); if ( !revisionIds.has(String(bundle.article.published_revision_id)) || (bundle.article.pending_revision_id && !revisionIds.has(String(bundle.article.pending_revision_id))) || bundle.revisionTags.some((row) => !revisionIds.has(String(row.revision_id))) ) { throw new Error('The News article bundle has invalid revision pointers.'); } return files; } async function importArticle() { if ( process.env.ARTICLE_IMPORT_ENABLED !== 'true' || !process.argv.includes(confirmation) ) { failUsage(); } const bundlePath = resolve(subject); const bundle = JSON.parse(await readFile(bundlePath, 'utf8')); const files = validateBundle(bundle); const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], { cwd: process.cwd(), stdio: 'inherit', env: process.env, }); if (safetyBackup.error) throw safetyBackup.error; if (safetyBackup.status !== 0) throw new Error('Article import stopped because its safety backup failed.'); const database = await connect(databasePath); const existing = await ( await database.prepare( 'SELECT id,slug FROM articles WHERE id=? OR (kind=? AND slug=?)', ) ).get(bundle.article.id, bundle.article.kind, bundle.article.slug); if (existing) { if ( existing.id === bundle.article.id && existing.slug === bundle.article.slug ) { console.log(`News article "${existing.slug}" is already present.`); return; } throw new Error('The target already contains a conflicting News article.'); } const owner = await ( await database.prepare( 'SELECT id,username FROM users WHERE id=? OR lower(username)=lower(?) ORDER BY CASE WHEN id=? THEN 0 ELSE 1 END LIMIT 1', ) ).get(bundle.owner.sourceId, bundle.owner.username, bundle.owner.sourceId); if (!owner) throw new Error( `The target does not contain Agent "${bundle.owner.username}".`, ); const tagIds = [ ...new Set(bundle.revisionTags.map((row) => String(row.tag_id))), ]; if (tagIds.length) { const available = await ( await database.prepare( `SELECT COUNT(*) AS count FROM article_tags WHERE id IN (${tagIds.map(() => '?').join(',')})`, ) ).get(...tagIds); if (Number(available?.count ?? 0) !== tagIds.length) throw new Error('The target is missing one or more article tags.'); } const imageIds = bundle.images.map((image) => String(image.id)); if (imageIds.length) { const collision = await ( await database.prepare( `SELECT id FROM managed_images WHERE id IN (${imageIds.map(() => '?').join(',')}) OR storage_key IN (${imageIds.map(() => '?').join(',')}) LIMIT 1`, ) ).get(...imageIds, ...bundle.images.map((image) => image.storage_key)); if (collision) throw new Error('The target contains a conflicting managed image.'); } await mkdir(imageDirectory, { recursive: true }); const createdFiles = []; let transactionStarted = false; try { for (const image of bundle.images) { const storageKey = String(image.storage_key); const temporaryPath = join( imageDirectory, `.${storageKey}.${randomUUID()}.tmp`, ); await writeFile(temporaryPath, files.get(storageKey), { flag: 'wx', mode: 0o600, }); const finalPath = join(imageDirectory, storageKey); try { await link(temporaryPath, finalPath); createdFiles.push(finalPath); } finally { await rm(temporaryPath, { force: true }); } } await database.exec('BEGIN IMMEDIATE'); transactionStarted = true; for (const image of bundle.images) { await insertRow(database, 'managed_images', { ...image, created_by: owner.id, }); } await insertRow(database, 'articles', { ...bundle.article, owner_id: owner.id, visibility: 'private', published_revision_id: null, pending_revision_id: null, }); await insertRow(database, 'article_drafts', { ...bundle.draft, updated_by: owner.id, }); for (const revision of bundle.revisions) { await insertRow(database, 'article_revisions', { ...revision, created_by: owner.id, }); } for (const row of bundle.revisionTags) await insertRow(database, 'article_revision_tags', row); for (const row of bundle.media) { await insertRow(database, 'article_media', { ...row, uploaded_by: owner.id, }); } if (bundle.search) await insertRow(database, 'article_search', bundle.search); await ( await database.prepare( `UPDATE articles SET visibility=?,published_revision_id=?,pending_revision_id=? WHERE id=?`, ) ).run( bundle.article.visibility, bundle.article.published_revision_id, bundle.article.pending_revision_id, bundle.article.id, ); await insertRow(database, 'audit_log', { id: randomUUID(), actor_id: owner.id, entity_type: 'editorial_content', entity_id: bundle.article.id, action: 'news.promoted', before_json: null, after_json: JSON.stringify({ sourceBundle: basename(bundlePath), slug: bundle.article.slug, publishedRevisionId: bundle.article.published_revision_id, revisions: bundle.revisions.length, images: bundle.images.length, }), created_at: new Date().toISOString(), }); await database.exec('COMMIT'); transactionStarted = false; } catch (error) { if (transactionStarted) { try { await database.exec('ROLLBACK'); } catch { // Preserve the import error. } } for (const file of createdFiles) await rm(file, { force: true }); throw error; } console.log( `Imported News article "${bundle.article.slug}" with ${bundle.revisions.length} revisions and ${bundle.images.length} images for ${owner.username}.`, ); } if (mode === 'export') await exportArticle(); else await importArticle();