Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@
"@aws-sdk/client-cloudwatch": "^3.1157.0",
"@aws-sdk/client-dynamodb": "^3.700.0",
"@aws-sdk/client-kms": "^3.996.0",
"@aws-sdk/client-lambda": "^3.1162.0",
"@aws-sdk/client-rds": "3.996.0",
"@aws-sdk/client-s3": "^3.996.0",
"@aws-sdk/client-secrets-manager": "3.996.0",
"@aws-sdk/client-sqs": "^3.1162.0",
"@aws-sdk/lib-dynamodb": "^3.700.0",
"@aws-sdk/lib-storage": "^3.1085.0",
"@aws-sdk/s3-request-presigner": "^3.1085.0",
Expand Down
25 changes: 25 additions & 0 deletions backend/src/banners/jobs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { GetCommand, PutCommand, UpdateCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
import { TABLE_ARTIFACTS } from '../db/tableNames';
import type { BannerJob } from './model';
const key = (id: string) => ({ PK: `BANNERJOB#${id}`, SK: `BANNERJOB#${id}` });
export async function getBannerJob(client: DynamoDBDocumentClient, id: string): Promise<BannerJob | null> {
const result = await client.send(new GetCommand({ TableName: TABLE_ARTIFACTS, Key: key(id), ConsistentRead: true }));
if (!result.Item) return null;
const { PK: _pk, SK: _sk, ...job } = result.Item;
return job as BannerJob;
}
export async function putBannerJob(client: DynamoDBDocumentClient, job: BannerJob): Promise<BannerJob> {
try { await client.send(new PutCommand({ TableName: TABLE_ARTIFACTS, Item: { ...key(job.id), ...job }, ConditionExpression: 'attribute_not_exists(PK)' })); return job; }
catch (error) { if ((error as Error).name !== 'ConditionalCheckFailedException') throw error; return (await getBannerJob(client, job.id))!; }
}
export async function transitionBannerJob(client: DynamoDBDocumentClient, job: BannerJob, state: BannerJob['state'], extra: Record<string, unknown> = {}): Promise<boolean> {
const fields = { state, updatedAt: new Date().toISOString(), ...extra };
const names: Record<string, string> = { '#previous': 'state' };
const values: Record<string, unknown> = { ':previous': job.state };
const sets = Object.entries(fields).map(([name, value], i) => { names[`#n${i}`] = name; values[`:v${i}`] = value; return `#n${i} = :v${i}`; });
// A worker may recover an expired lease after Lambda/SQS delivery interruption.
const condition = job.state === 'rendering' ? '#previous = :previous AND leaseUntil = :lease' : '#previous = :previous';
if (job.state === 'rendering') values[':lease'] = job.leaseUntil;
try { await client.send(new UpdateCommand({ TableName: TABLE_ARTIFACTS, Key: key(job.id), UpdateExpression: `SET ${sets.join(', ')}`, ConditionExpression: condition, ExpressionAttributeNames: names, ExpressionAttributeValues: values })); return true; }
catch (error) { if ((error as Error).name === 'ConditionalCheckFailedException') return false; throw error; }
}
78 changes: 78 additions & 0 deletions backend/src/banners/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { createHash } from 'node:crypto';
import type { Card } from '../types';

export type BannerInput = {
eventType: 'workshop' | 'webinar' | 'podcast';
layout: 'landscape' | 'square';
speakers: { personId: string; portraitFileId: string }[];
revision: string;
sponsorLogoFileId?: string;
sponsorTreatment?: 'approved-logo';
};
export type BannerSpeaker = BannerInput['speakers'][number] & { name: string; personVersion: number; checksum: string };
export type BannerLogo = { fileId: string; checksum: string; treatment: 'approved-logo' };
export type BannerJob = {
bundleKey: string; id: string; cardId: string; title: string; input: BannerInput; speakers: BannerSpeaker[];
state: 'queued' | 'rendering' | 'succeeded' | 'failed';
createdBy: string; createdAt: string; updatedAt: string;
artifactId?: string; error?: string; leaseUntil?: number; attempt?: number;
sponsorLogo?: BannerLogo;
};
export type BannerTemplate = {
id: string; eventType: BannerInput['eventType']; layout: BannerInput['layout'];
speakerCount: number; width: number; height: number; html: string;
sponsorTreatment?: 'approved-logo';
};
export type BannerBundle = { schema: 'dataops.banner-templates.v1'; sourceRevision: string; assets?: Record<string, string>; templates: BannerTemplate[] };
export class BannerInputError extends Error {}
export function parseBannerInput(value: unknown): BannerInput {
const data = value as Partial<BannerInput> | null;
if (!data || !['workshop', 'webinar', 'podcast'].includes(data.eventType || '')
|| !['landscape', 'square'].includes(data.layout || '')
|| !Array.isArray(data.speakers) || data.speakers.length < 1 || data.speakers.length > 2
|| data.speakers.some(s => !s || !/^[a-zA-Z0-9-]{1,100}$/.test(s.personId) || !/^[a-zA-Z0-9-]{1,100}$/.test(s.portraitFileId))
|| new Set(data.speakers.map(s => s.personId)).size !== data.speakers.length
|| typeof data.revision !== 'string' || !/^[a-zA-Z0-9._-]{1,100}$/.test(data.revision)) {
throw new BannerInputError('Choose workshop|webinar|podcast, landscape|square, one or two distinct Card people with portrait file IDs, and a stable revision.');
}
if ((data.sponsorLogoFileId !== undefined || data.sponsorTreatment !== undefined)
&& (typeof data.sponsorLogoFileId !== 'string' || !/^[a-zA-Z0-9-]{1,100}$/.test(data.sponsorLogoFileId) || data.sponsorTreatment !== 'approved-logo')) {
throw new BannerInputError('A sponsored cover requires a verified Card logo file ID and explicit approved-logo treatment.');
}
return { eventType: data.eventType!, layout: data.layout!, speakers: data.speakers.map(s => ({ personId: s.personId, portraitFileId: s.portraitFileId })), revision: data.revision,
...(data.sponsorLogoFileId ? { sponsorLogoFileId: data.sponsorLogoFileId, sponsorTreatment: 'approved-logo' as const } : {}),
};
}
export function bannerId(card: Card, input: BannerInput, speakers: BannerSpeaker[], bundleKey: string, sponsorLogo?: BannerLogo): string {
return createHash('sha256').update(JSON.stringify({ cardId: card.id, title: card.title, input, speakers, bundleKey, sponsorLogo })).digest('hex');
}
export function checksum(bytes: Buffer): string { return `sha256:${createHash('sha256').update(bytes).digest('hex')}`; }
export function portraitContentType(bytes: Buffer): string {
if (bytes.subarray(0, 8).toString('hex') === '89504e470d0a1a0a') return 'image/png';
if (bytes.subarray(0, 3).toString('hex') === 'ffd8ff') return 'image/jpeg';
if (bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return 'image/webp';
throw new BannerInputError('Portrait bytes must be PNG, JPEG or WebP.');
}
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!);
}
export function composeBanner(template: BannerTemplate, job: BannerJob, portraits: string[], assets: Record<string, string> = {}, sponsorLogo?: string): string {
const titleArea = job.input.layout === 'landscape' ? 1260 * 810 : 1800 * 600;
const values: Record<string, string> = {
...Object.fromEntries(Object.entries(assets).filter(([key]) => /^asset[A-Za-z]+$/.test(key))),
title: escapeHtml(job.title),
titleFontSize: String(Math.min(150, Math.floor(Math.sqrt(titleArea / (Math.max(job.title.length, 20) * 0.72))))),
};
if (sponsorLogo) values.sponsorLogo = sponsorLogo;
job.speakers.forEach((s, i) => { values[`speaker${i + 1}Name`] = escapeHtml(s.name); values[`speaker${i + 1}Portrait`] = portraits[i]; });
const html = template.html.replace(/\{\{([a-zA-Z0-9]+)\}\}/g, (_, key: string) => {
if (!(key in values)) throw new Error('Unknown banner template field');
return values[key];
});
if (/\{\{|(?:src|href)\s*=\s*[\"'](?:https?:|file:)|url\(\s*[\"']?(?:https?:|file:)/i.test(html)) throw new Error('Banner template must be self-contained');
return html;
}
export function pngDimensions(bytes: Buffer): { width: number; height: number } {
if (bytes.length < 24 || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a' || bytes.toString('ascii', 12, 16) !== 'IHDR') throw new Error('Renderer did not return a PNG');
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}
151 changes: 151 additions & 0 deletions backend/src/banners/service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';
import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
import { getCardConsistent, updateCardAdditive } from '../db/cards';
import { getPerson } from '../db/people';
import { getFile } from '../db/files';
import { getTemplate } from '../db/templates';
import { createArtifactIfAbsent } from '../db/artifacts';
import { readFile } from '../storage';
import { getBannerJob, putBannerJob, transitionBannerJob } from './jobs';
import { BannerInputError, bannerId, checksum, composeBanner, pngDimensions, portraitContentType, type BannerInput, type BannerSpeaker, type BannerJob, type BannerBundle, type BannerLogo } from './model';

const s3 = new S3Client({});
const lambda = new LambdaClient({});
const sqs = new SQSClient({});
class BannerFailure extends Error { constructor(readonly code: string) { super(code); } }
export function bannerFailureCode(error: unknown, stage: string): string {
if (error instanceof BannerFailure) return error.code;
const name = (error as { name?: string })?.name;
if (name === 'CodeArtifactUserPendingException' || name === 'ResourceNotReadyException') return 'renderer-cold-activating';
if (name === 'AccessDeniedException' || name === 'AccessDenied') return `${stage}-access-denied`;
if (name === 'TooManyRequestsException' || name === 'TimeoutError' || name === 'ServiceException') return `${stage}-temporarily-unavailable`;
return `${stage}-failed`;
}
export function bannerConfig() {
const bucket = process.env.TASK_FILES_BUCKET;
const bundleBucket = process.env.KNOWLEDGE_BUCKET;
const bundleKey = process.env.BANNER_TEMPLATE_BUNDLE_KEY;
const renderer = process.env.BANNER_RENDERER_ARN;
const queueUrl = process.env.BANNER_QUEUE_URL;
if (!bucket || !bundleBucket || !bundleKey || !renderer || !queueUrl) throw new BannerInputError('Card banner generation is not configured on this deployment.');
return { bucket, bundleBucket, bundleKey, renderer, queueUrl };
}
export async function requestBanner(client: DynamoDBDocumentClient, cardId: string, input: BannerInput, actorId: string): Promise<BannerJob> {
const config = bannerConfig();
const card = await getCardConsistent(client, cardId);
if (!card?.title?.trim()) throw new BannerInputError('The Card must exist and have a title.');
if (card.title.length > 180) throw new BannerInputError('Card title exceeds the banner limit of 180 characters.');
const cardTemplate = await getTemplate(client, card.templateId);
const classifications = new Set([cardTemplate?.type, ...(card.tags || [])]
.map(value => value?.trim().toLowerCase()).filter(value => ['workshop', 'webinar', 'podcast'].includes(value || '')));
if (classifications.size === 1 && !classifications.has(input.eventType)) {
throw new BannerInputError(`Event type contradicts this Card's ${[...classifications][0]} classification. Confirm/correct Card facts before choosing the icon.`);
}
const speakers: BannerSpeaker[] = [];
for (const selection of input.speakers) {
if (!card.people?.includes(selection.personId)) throw new BannerInputError('Each speaker must be linked to this Card in the People register.');
const person = await getPerson(client, selection.personId);
const file = await getFile(client, selection.portraitFileId);
if (!person || person.name.length > 70 || !file || file.cardId !== cardId || file.category !== 'image'
|| !file.checksum) {
throw new BannerInputError('Each speaker needs a registered name and a verified PNG/JPEG/WebP portrait file belonging to this Card.');
}
speakers.push({ ...selection, name: person.name, personVersion: person.version, checksum: file.checksum });
}
let sponsorLogo: BannerLogo | undefined;
if (input.sponsorLogoFileId) {
const file = await getFile(client, input.sponsorLogoFileId);
if (!file || file.cardId !== cardId || file.category !== 'image' || !file.checksum) throw new BannerInputError('The approved sponsor logo must be an image file saved on this Card.');
sponsorLogo = { fileId: file.id, checksum: file.checksum, treatment: 'approved-logo' };
}
const id = bannerId(card, input, speakers, config.bundleKey, sponsorLogo);
const now = new Date().toISOString();
const job = await putBannerJob(client, { id, cardId, title: card.title, input, speakers, ...(sponsorLogo ? { sponsorLogo } : {}), state: 'queued', createdBy: actorId, createdAt: now, updatedAt: now, bundleKey: config.bundleKey });
if (job.state === 'succeeded' || job.state === 'rendering') return job;
// Failed delivery/render retries retain the same immutable renderer output key.
if (job.state === 'failed') {
if (!await transitionBannerJob(client, job, 'queued', { error: '' })) return (await getBannerJob(client, id))!;
}
try { await sqs.send(new SendMessageCommand({ QueueUrl: config.queueUrl, MessageBody: JSON.stringify({ schema: 'dataops.card-banner.v1', jobId: id }) })); }
catch { await transitionBannerJob(client, { ...job, state: 'queued' }, 'failed', { error: 'queue-delivery-failed' }); }
return (await getBannerJob(client, id))!;
}
async function objectBytes(bucket: string, key: string): Promise<Buffer> {
const result = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
if (!result.Body) throw new Error('Missing object body');
return Buffer.from(await result.Body.transformToByteArray());
}
export async function renderBannerJob(client: DynamoDBDocumentClient, id: string): Promise<void> {
const config = bannerConfig();
const job = await getBannerJob(client, id);
if (!job || job.state === 'succeeded' || job.state === 'failed') return;
if (job.state === 'rendering' && (job.leaseUntil || 0) > Date.now()) throw new Error('Banner job lease is active');
const leaseUntil = Date.now() + 150_000;
if (!await transitionBannerJob(client, job, 'rendering', { leaseUntil, attempt: (job.attempt || 0) + 1 })) throw new Error('Banner job was claimed concurrently');
const claimed = { ...job, state: 'rendering' as const, leaseUntil };
let stage = 'template';
try {
const bundleBytes = await objectBytes(config.bundleBucket, job.bundleKey);
const bundleHash = /^banner-templates\/([a-f0-9]{64})\/bundle.json$/.exec(job.bundleKey)?.[1];
if (!bundleHash || checksum(bundleBytes) !== `sha256:${bundleHash}`) throw new BannerFailure('template-checksum-mismatch');
const bundle = JSON.parse(bundleBytes.toString('utf8')) as BannerBundle;
if (bundle.schema !== 'dataops.banner-templates.v1' || !bundle.sourceRevision) throw new BannerFailure('invalid-template-bundle');
const template = bundle.templates.find(t => t.eventType === job.input.eventType && t.layout === job.input.layout && t.speakerCount === job.speakers.length && t.sponsorTreatment === job.input.sponsorTreatment);
if (!template || template.width < 1 || template.height < 1 || template.width > 4000 || template.height > 4000) throw new BannerFailure('unsupported-template');
stage = 'portrait';
const portraits: string[] = [];
for (const speaker of job.speakers) {
const file = await getFile(client, speaker.portraitFileId);
if (!file || file.cardId !== job.cardId || file.checksum !== speaker.checksum) throw new BannerFailure('portrait-changed');
const bytes = await readFile(file);
if (checksum(bytes) !== speaker.checksum) throw new BannerFailure('portrait-checksum-mismatch');
portraits.push(`data:${portraitContentType(bytes)};base64,${bytes.toString('base64')}`);
}
let logoData: string | undefined;
if (job.sponsorLogo) {
stage = 'sponsor-logo';
const file = await getFile(client, job.sponsorLogo.fileId);
if (!file || file.cardId !== job.cardId || file.category !== 'image' || file.checksum !== job.sponsorLogo.checksum) throw new BannerFailure('sponsor-logo-changed');
const bytes = await readFile(file);
if (checksum(bytes) !== job.sponsorLogo.checksum) throw new BannerFailure('sponsor-logo-checksum-mismatch');
logoData = `data:${portraitContentType(bytes)};base64,${bytes.toString('base64')}`;
}
const html = composeBanner(template, job, portraits, bundle.assets, logoData);
const prefix = `cards/${job.cardId}/banners/${id}`;
const inputKey = `${prefix}/input.html`;
const outputKey = `${prefix}/banner.png`;
stage = 'renderer';
await s3.send(new PutObjectCommand({ Bucket: config.bucket, Key: inputKey, Body: html, ContentType: 'text/html; charset=utf-8' }));
const result = await lambda.send(new InvokeCommand({ FunctionName: config.renderer, InvocationType: 'RequestResponse', Payload: Buffer.from(JSON.stringify({
schema: 'html-renderer.job.v1', job_id: id,
input: { bucket: config.bucket, key: inputKey }, output: { bucket: config.bucket, key: outputKey },
format: 'png', viewport: { width: template.width, height: template.height },
})) }));
if (result.FunctionError || !result.Payload) throw new BannerFailure('renderer-invocation-failed');
const completion = JSON.parse(Buffer.from(result.Payload).toString('utf8'));
if (completion.schema !== 'html-renderer.completed.v1' || completion.job_id !== id || completion.output?.bucket !== config.bucket || completion.output?.key !== outputKey) throw new BannerFailure('renderer-invalid-completion');
stage = 'integrity';
const bytes = await objectBytes(config.bucket, outputKey);
const dimensions = pngDimensions(bytes);
if (dimensions.width !== template.width || dimensions.height !== template.height) throw new BannerFailure('output-dimensions-mismatch');
stage = 'attachment';
const card = await getCardConsistent(client, job.cardId);
if (!card) throw new BannerFailure('card-deleted');
const artifactId = `banner-${id}`;
const { artifact } = await createArtifactIfAbsent(client, {
id: artifactId, cardId: job.cardId, type: 'other', title: `${job.title} — ${job.input.layout} ${job.input.eventType} cover`,
status: 'needs-review', storageProvider: 's3', storageUri: `s3://${config.bucket}/${outputKey}`,
filename: `${job.input.eventType}-${job.input.layout}-${id.slice(0, 12)}.png`, contentType: 'image/png',
checksum: checksum(bytes), sizeBytes: bytes.length, visibility: 'private', dataClass: 'internal',
sourceType: 'system', createdBy: job.createdBy,
metadata: { bannerJobId: id, ...dimensions, format: 'png', templateId: template.id, templateSourceRevision: bundle.sourceRevision, templateBundleChecksum: checksum(bundleBytes), inputChecksum: checksum(Buffer.from(html)), eventType: job.input.eventType, layout: job.input.layout, title: job.title, speakers: job.speakers, ...(job.sponsorLogo ? { sponsorLogo: job.sponsorLogo } : {}) },
});
await updateCardAdditive(client, card, current => ({ artifactRefs: [...(current.artifactRefs || []).filter(ref => ref.artifactId !== artifactId), { artifactId, type: artifact.type, title: artifact.title, status: artifact.status }] }));
await transitionBannerJob(client, claimed, 'succeeded', { artifactId, error: '' });
} catch (error) {
// Deliberately exclude provider errors/URLs/payloads from durable status and logs.
await transitionBannerJob(client, claimed, 'failed', { error: bannerFailureCode(error, stage) });
}
}
Loading