feat: add community publishing and news syndication
Validate and deploy production / validate (push) Successful in 23s
Validate and deploy staging / validate (push) Successful in 50s
Validate and deploy production / deploy (push) Failing after 40s
Validate and deploy staging / deploy (push) Failing after 7s

This commit is contained in:
jb
2026-08-01 21:42:09 -04:00
parent b47e2588e9
commit dd84dff055
288 changed files with 83254 additions and 38351 deletions
+425
View File
@@ -0,0 +1,425 @@
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 <article-id-or-slug> [output.json]',
);
console.error(
` or: ARTICLE_IMPORT_ENABLED=true node scripts/article-transfer.mjs import <bundle.json> ${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();