From a0cea1c22b071b881a7b2eea2cc9380acf1ef68f Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Thu, 9 Jul 2026 19:31:42 +0100 Subject: [PATCH] =?UTF-8?q?fix(cms):=20enforce=20blob=20size=20limit=20?= =?UTF-8?q?=F0=9F=A4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/components/admin/AmaAnswer.vue | 90 +++++- app/components/admin/AmaImageGenerator.vue | 41 ++- server/api/admin/ama/[rkey].patch.ts | 10 + .../admin/ama/[rkey]/publish/bluesky.post.ts | 5 +- .../admin/ama/[rkey]/publish/linkedin.post.ts | 4 +- .../admin/ama/[rkey]/publish/mastodon.post.ts | 4 +- server/utils/admin/ama-publish.ts | 27 +- server/utils/admin/ama-record.ts | 303 ++++++++++++------ shared/cms/blob.ts | 14 +- test/unit/blob.spec.ts | 38 +++ 10 files changed, 401 insertions(+), 135 deletions(-) create mode 100644 server/api/admin/ama/[rkey].patch.ts create mode 100644 test/unit/blob.spec.ts diff --git a/app/components/admin/AmaAnswer.vue b/app/components/admin/AmaAnswer.vue index 9cee83ab..44c60b2d 100644 --- a/app/components/admin/AmaAnswer.vue +++ b/app/components/admin/AmaAnswer.vue @@ -81,6 +81,17 @@ interface PendingVideo { } const pendingImage = ref(null) const pendingVideo = ref(null) +const imageRemoved = ref(false) + +function onImageGenerated (image: PendingImage) { + pendingImage.value = image + imageRemoved.value = false +} + +function onImageRemoved () { + pendingImage.value = null + imageRemoved.value = true +} // Plain-text answer for the video generator + YouTube description, with // entity mention placeholders swapped for bare names. @@ -99,6 +110,10 @@ const plainAnswerForVideo = computed(() => posts.value const error = ref(null) const submitting = computed(() => ALL_PLATFORMS.some(p => platformState[p].status === 'publishing')) +const saving = ref(false) +const savedAt = ref(null) +const saveError = ref(null) + function addPost () { posts.value.push({ text: '', mentions: [] }) } @@ -201,6 +216,46 @@ function requirePosts (): boolean { return false } +async function buildPayload (): Promise> { + const image = await ensureImageUploaded() + const payload: Record = { + question: props.question, + posts: posts.value + .filter(p => p.text.trim()) + .map(p => ({ text: p.text, mentions: p.mentions })), + platforms: { ...platforms }, + } + if (image && pendingImage.value) { + payload.image = image + payload.imageDimensions = { width: pendingImage.value.width, height: pendingImage.value.height } + payload.backgroundStyle = pendingImage.value.backgroundStyleId + } + else if (imageRemoved.value) { + payload.image = null + payload.imageDimensions = null + payload.backgroundStyle = null + } + return payload +} + +async function saveDraft () { + if (saving.value) return + saving.value = true + saveError.value = null + try { + await $fetch(`/api/admin/ama/${props.rkey}`, { method: 'PATCH', body: await buildPayload() }) + savedAt.value = new Date().toISOString() + imageRemoved.value = false + } + catch (err) { + const data = (err as { data?: { statusMessage?: string } }).data + saveError.value = data?.statusMessage || (err instanceof Error ? err.message : String(err)) + } + finally { + saving.value = false + } +} + async function publishToPlatform (platform: AmaPlatform, force = false) { if (platformState[platform].status === 'publishing') return if (!requirePosts()) return @@ -208,20 +263,8 @@ async function publishToPlatform (platform: AmaPlatform, force = false) { platformState[platform] = { status: 'publishing' } try { - const image = await ensureImageUploaded() const baseBody = { - question: props.question, - posts: posts.value - .filter(p => p.text.trim()) - .map(p => ({ text: p.text, mentions: p.mentions })), - platforms, - ...(image && pendingImage.value - ? { - image, - imageDimensions: { width: pendingImage.value.width, height: pendingImage.value.height }, - backgroundStyle: pendingImage.value.backgroundStyleId, - } - : {}), + ...(await buildPayload()), ...(force ? { force: true } : {}), } @@ -358,7 +401,8 @@ async function publishAll () { :created-at="createdAt" :initial-preview-url="initialImagePreviewUrl" :initial-background-style-id="initialBackgroundStyleId" - @generated="pendingImage = $event" + @generated="onImageGenerated" + @removed="onImageRemoved" @cleared="pendingImage = null" /> @@ -446,7 +490,7 @@ async function publishAll () { -
+
+ + {{ saveError }} + Draft saved () +const BLUESKY_EMBED_MAX_BYTES = 950_000 + +function canvasToBlob (canvas: HTMLCanvasElement, type: string, quality: number): Promise { + return new Promise(resolve => canvas.toBlob(resolve, type, quality)) +} + +async function compressUnderLimit (source: Blob, width: number, height: number, maxBytes: number): Promise { + const bitmap = await createImageBitmap(source) + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Could not get a canvas context to compress the image.') + ctx.drawImage(bitmap, 0, 0, width, height) + bitmap.close?.() + + let smallest: Blob | null = null + for (const type of ['image/webp', 'image/jpeg']) { + for (let quality = 0.92; quality >= 0.5; quality -= 0.07) { + const candidate = await canvasToBlob(canvas, type, quality) + if (!candidate) continue + if (!smallest || candidate.size < smallest.size) smallest = candidate + if (candidate.size <= maxBytes) return candidate + } + } + if (smallest && smallest.size <= maxBytes) return smallest + throw new Error(`Could not compress the image under ${maxBytes} bytes for Bluesky. Shorten the question or pick a simpler background.`) +} + const backgroundStyleId = ref(props.initialBackgroundStyleId ?? DEFAULT_BACKGROUND_STYLE_ID) const backgroundStyle = computed(() => getBackgroundStyle(backgroundStyleId.value)) @@ -144,12 +173,16 @@ async function generate () { const width = Math.round(naturalWidth * captureScale) const height = Math.round(naturalHeight * captureScale) + const finalBlob = blob.size > BLUESKY_EMBED_MAX_BYTES + ? await compressUnderLimit(blob, width, height, BLUESKY_EMBED_MAX_BYTES) + : blob + if (generatedUrl.value?.startsWith('blob:')) URL.revokeObjectURL(generatedUrl.value) - generatedUrl.value = URL.createObjectURL(blob) + generatedUrl.value = URL.createObjectURL(finalBlob) lastSignature.value = `${formattedText.value}::${backgroundStyleId.value}` emit('generated', { - blob, + blob: finalBlob, width, height, backgroundStyleId: backgroundStyleId.value, @@ -167,7 +200,7 @@ function clear () { if (generatedUrl.value?.startsWith('blob:')) URL.revokeObjectURL(generatedUrl.value) generatedUrl.value = null lastSignature.value = null - emit('cleared') + emit('removed') } diff --git a/server/api/admin/ama/[rkey].patch.ts b/server/api/admin/ama/[rkey].patch.ts new file mode 100644 index 00000000..bfa09d30 --- /dev/null +++ b/server/api/admin/ama/[rkey].patch.ts @@ -0,0 +1,10 @@ +import { saveAmaDraft } from '../../../utils/admin/ama-record' +import type { AmaUpdate } from '../../../utils/admin/ama-record' + +export default defineEventHandler(async event => { + const rkey = getRouterParam(event, 'rkey') + if (!rkey) throw createError({ statusCode: 400, statusMessage: 'Missing rkey.' }) + + const body = await readBody(event) + return saveAmaDraft(event, rkey, body) +}) diff --git a/server/api/admin/ama/[rkey]/publish/bluesky.post.ts b/server/api/admin/ama/[rkey]/publish/bluesky.post.ts index 0d511da9..9e74de83 100644 --- a/server/api/admin/ama/[rkey]/publish/bluesky.post.ts +++ b/server/api/admin/ama/[rkey]/publish/bluesky.post.ts @@ -1,4 +1,4 @@ -import { ensureNotAlreadyPublished, mergePublishedLink, publishImageFromBody } from '../../../../../utils/admin/ama-record' +import { ensureNotAlreadyPublished, mergePublishedLink, prepareAmaImage } from '../../../../../utils/admin/ama-record' import { blueskyThread, buildEntityLookup } from '../../../../../utils/admin/ama-resolve' import { publishBlueskyThread } from '../../../../../utils/admin/ama-publish' import type { AmaUpdate } from '../../../../../utils/admin/ama-record' @@ -21,7 +21,8 @@ export default defineEventHandler(async event => { await ensureNotAlreadyPublished(event, rkey, 'bluesky', Boolean(body.force)) const entities = await buildEntityLookup(event) - const { url } = await publishBlueskyThread(event, blueskyThread(body.posts, entities), publishImageFromBody(event, body), body.question) + const image = await prepareAmaImage(event, rkey, body) + const { url } = await publishBlueskyThread(event, blueskyThread(body.posts, entities), image, body.question) await mergePublishedLink(event, rkey, 'bluesky', url, body) return { success: true, url } diff --git a/server/api/admin/ama/[rkey]/publish/linkedin.post.ts b/server/api/admin/ama/[rkey]/publish/linkedin.post.ts index 1c48d190..c9c66744 100644 --- a/server/api/admin/ama/[rkey]/publish/linkedin.post.ts +++ b/server/api/admin/ama/[rkey]/publish/linkedin.post.ts @@ -1,4 +1,4 @@ -import { ensureNotAlreadyPublished, mergePublishedLink, publishImageFromBody } from '../../../../../utils/admin/ama-record' +import { ensureNotAlreadyPublished, mergePublishedLink, prepareAmaImage } from '../../../../../utils/admin/ama-record' import { buildEntityLookup, platformText } from '../../../../../utils/admin/ama-resolve' import { publishLinkedIn } from '../../../../../utils/admin/ama-publish' import type { AmaUpdate } from '../../../../../utils/admin/ama-record' @@ -23,7 +23,7 @@ export default defineEventHandler(async event => { const { url } = await publishLinkedIn( event, platformText(body.posts, 'linkedin', entities), - publishImageFromBody(event, body), + await prepareAmaImage(event, rkey, body), body.question, ) await mergePublishedLink(event, rkey, 'linkedin', url, body) diff --git a/server/api/admin/ama/[rkey]/publish/mastodon.post.ts b/server/api/admin/ama/[rkey]/publish/mastodon.post.ts index f5a697f1..0d7d9013 100644 --- a/server/api/admin/ama/[rkey]/publish/mastodon.post.ts +++ b/server/api/admin/ama/[rkey]/publish/mastodon.post.ts @@ -1,4 +1,4 @@ -import { ensureNotAlreadyPublished, mergePublishedLink, publishImageFromBody } from '../../../../../utils/admin/ama-record' +import { ensureNotAlreadyPublished, mergePublishedLink, prepareAmaImage } from '../../../../../utils/admin/ama-record' import { buildEntityLookup, platformText } from '../../../../../utils/admin/ama-resolve' import { publishMastodon } from '../../../../../utils/admin/ama-publish' import type { AmaUpdate } from '../../../../../utils/admin/ama-record' @@ -23,7 +23,7 @@ export default defineEventHandler(async event => { const { url } = await publishMastodon( event, platformText(body.posts, 'mastodon', entities), - publishImageFromBody(event, body), + await prepareAmaImage(event, rkey, body), body.question, ) await mergePublishedLink(event, rkey, 'mastodon', url, body) diff --git a/server/utils/admin/ama-publish.ts b/server/utils/admin/ama-publish.ts index 9633eeb4..603c3e2a 100644 --- a/server/utils/admin/ama-publish.ts +++ b/server/utils/admin/ama-publish.ts @@ -6,8 +6,10 @@ import type { H3Event } from 'h3' import type { AppBskyEmbedImages, AppBskyFeedPost, AppBskyRichtextFacet } from '@atproto/api' import { AtpAgent } from '@atproto/api' +import { BlobRef, jsonToLex } from '@atproto/lexicon' import { createRestAPIClient } from 'masto' import { parseURL, withProtocol } from 'ufo' +import { BLUESKY_IMAGE_MAX_BYTES } from '#shared/cms/blob' export interface PublishImage { /** Publicly-fetchable URL (typically a PDS blob URL). */ @@ -18,6 +20,13 @@ export interface PublishImage { mimeType?: string } +export interface BlueskyImage { + blob: unknown + width: number + height: number + size?: number | null +} + export interface ResolvedPost { text: string /** Bluesky-only: facets covering mentions, hashtags, URLs. */ @@ -42,7 +51,7 @@ async function resolveBlueskyPds (handle: string): Promise { export async function publishBlueskyThread ( event: H3Event, posts: ResolvedPost[], - image: PublishImage | undefined, + image: BlueskyImage | undefined, altText: string, ): Promise<{ url: string }> { const config = useRuntimeConfig(event) @@ -61,16 +70,22 @@ export async function publishBlueskyThread ( let embed: AppBskyEmbedImages.Main | undefined if (i === 0 && image) { - const imageResponse = await fetch(image.url) - const imageBuffer = await imageResponse.arrayBuffer() - const encoding = image.mimeType ?? imageResponse.headers.get('content-type') ?? 'image/png' - const uploadResult = await agent.uploadBlob(new Uint8Array(imageBuffer), { encoding }) + if (image.size != null && image.size > BLUESKY_IMAGE_MAX_BYTES) { + throw createError({ + statusCode: 413, + statusMessage: `AMA image is ${image.size} bytes; Bluesky embeds must be under ${BLUESKY_IMAGE_MAX_BYTES}. Regenerate the image to compress it.`, + }) + } + const blob = jsonToLex(image.blob as never) + if (!(blob instanceof BlobRef)) { + throw new Error('AMA image is not a valid blob ref; cannot embed.') + } embed = { $type: 'app.bsky.embed.images', images: [{ alt: altText, - image: uploadResult.data.blob, + image: blob, aspectRatio: { $type: 'app.bsky.embed.defs#aspectRatio', width: image.width, diff --git a/server/utils/admin/ama-record.ts b/server/utils/admin/ama-record.ts index bd8975c1..8adeb279 100644 --- a/server/utils/admin/ama-record.ts +++ b/server/utils/admin/ama-record.ts @@ -3,8 +3,8 @@ import type { H3Event } from 'h3' import { requireAdminAgent } from './agent' import type { DevRoeAma } from '#shared/lex' import { lexicons } from '#shared/lex' -import { jsonToLex, BlobRef } from '@atproto/lexicon' -import { blobUrlFor, cidFromBlob } from '#shared/cms/blob' +import { jsonToLex } from '@atproto/lexicon' +import { blobSize, blobUrlFor, cidFromBlob } from '#shared/cms/blob' import type { AdminRecord } from './crud' import { decrypt } from './encryption' @@ -12,11 +12,11 @@ export type AmaPlatform = 'bluesky' | 'mastodon' | 'linkedin' | 'youtubeShorts' export interface AmaUpdate { question?: string - posts?: DevRoeAma.Post[] - platforms?: DevRoeAma.Platforms - image?: unknown - imageDimensions?: { width: number, height: number } - backgroundStyle?: string + posts?: DevRoeAma.Post[] | null + platforms?: Partial | null + image?: unknown | null + imageDimensions?: { width: number, height: number } | null + backgroundStyle?: string | null } export interface AmaView { @@ -35,48 +35,126 @@ export interface AmaView { answeredAt?: string } -// Decrypts the question for unanswered AMAs; falls back to the public -// question field once answered. -export function viewAma (r: AdminRecord<'dev.roe.ama'>): AmaView { - const v = r.value - let question = v.question ?? '' - if (v.status === 'unanswered' && v.encryptedQuestion) { - try { - question = decrypt(v.encryptedQuestion) - } - catch (err) { - console.warn(`[admin/ama] Failed to decrypt ${r.uri}:`, err instanceof Error ? err.message : err) - } +const DEFAULT_PLATFORMS: DevRoeAma.Platforms = { + bluesky: true, + mastodon: true, + linkedin: true, + youtubeShorts: false, +} + +function hasOwn (value: object, key: K): value is Record { + return Object.prototype.hasOwnProperty.call(value, key) +} + +function isImageDimensions (value: unknown): value is { width: number, height: number } { + if (!value || typeof value !== 'object') return false + const v = value as { width?: unknown, height?: unknown } + return Number.isInteger(v.width) && Number.isInteger(v.height) && Number(v.width) > 0 && Number(v.height) > 0 +} + +function normalisePlatforms (platforms?: Partial | null): DevRoeAma.Platforms { + return { + ...DEFAULT_PLATFORMS, + ...(platforms ?? {}), } +} + +function cleanPosts (posts: DevRoeAma.Post[] | null | undefined): DevRoeAma.Post[] { + if (!Array.isArray(posts)) return [] + return posts + .filter(post => typeof post?.text === 'string' && post.text.trim()) + .map(post => { + const mentions = Array.isArray(post.mentions) + ? post.mentions + .filter(m => typeof m?.uri === 'string' && typeof m?.cid === 'string') + .map(m => ({ uri: m.uri, cid: m.cid })) + : [] + return { + text: post.text, + ...(mentions.length ? { mentions } : {}), + } + }) +} + +function imageFields (current: DevRoeAma.Record, update: AmaUpdate): Partial { + const image = hasOwn(update, 'image') ? update.image : current.image + if (!image) return {} + + const dimensions = hasOwn(update, 'imageDimensions') ? update.imageDimensions : current.imageDimensions + const backgroundStyle = hasOwn(update, 'backgroundStyle') ? update.backgroundStyle : current.backgroundStyle + return { - rkey: r.rkey, - uri: r.uri, - cid: r.cid, - status: v.status as 'unanswered' | 'answered', - question, - posts: v.posts ?? [], - platforms: v.platforms, - publishedLinks: v.publishedLinks, - image: v.image, - imageDimensions: v.imageDimensions, - backgroundStyle: v.backgroundStyle, - createdAt: v.createdAt, - answeredAt: v.answeredAt, + image: image as DevRoeAma.Record['image'], + ...(isImageDimensions(dimensions) ? { imageDimensions: dimensions } : {}), + ...(typeof backgroundStyle === 'string' && backgroundStyle ? { backgroundStyle } : {}), } } -/** - * Read-modify-write helper for publish endpoints. Platforms publish in - * parallel, so we CAS via `swapRecord` against the last-read CID and - * retry on conflict rather than stomping concurrent writers. - */ -export async function mergePublishedLink ( +function hasPublishedLinks (links: DevRoeAma.PublishedLinks | undefined): links is DevRoeAma.PublishedLinks { + return Boolean(links && Object.values(links).some(Boolean)) +} + +function buildRecord ( + current: DevRoeAma.Record, + update: AmaUpdate, + published?: { platform: AmaPlatform, url: string }, +): DevRoeAma.Record { + const status = published || current.status === 'answered' ? 'answered' : 'unanswered' + const posts = hasOwn(update, 'posts') ? cleanPosts(update.posts as DevRoeAma.Post[] | null | undefined) : cleanPosts(current.posts) + const platforms = hasOwn(update, 'platforms') + ? (update.platforms ? normalisePlatforms(update.platforms) : undefined) + : current.platforms + const publishedLinks = published + ? { ...(current.publishedLinks ?? {}), [published.platform]: published.url } + : current.publishedLinks + + const next: DevRoeAma.Record = { + $type: 'dev.roe.ama', + status, + ...(status === 'answered' + ? { question: update.question ?? current.question ?? '' } + : current.encryptedQuestion + ? { encryptedQuestion: current.encryptedQuestion } + : current.question + ? { question: current.question } + : {}), + ...(posts.length ? { posts } : {}), + ...(platforms ? { platforms } : {}), + ...(hasPublishedLinks(publishedLinks) ? { publishedLinks } : {}), + ...imageFields(current, update), + createdAt: current.createdAt, + ...(status === 'answered' ? { answeredAt: current.answeredAt ?? new Date().toISOString() } : {}), + } + + return next +} + +function toLexRecord (record: DevRoeAma.Record, action: string): DevRoeAma.Record { + const lexRecord = jsonToLex(record as never) as DevRoeAma.Record + const validation = lexicons.validate('dev.roe.ama', lexRecord) + if (!validation.success) { + throw createError({ + statusCode: 422, + statusMessage: `Invalid AMA ${action}: ${validation.error.message}`, + }) + } + return lexRecord +} + +function looksLikeSwapMiss (err: unknown): boolean { + const e = err as { error?: string, name?: string, status?: number, message?: string } | undefined + return e?.error === 'InvalidSwap' + || e?.name === 'InvalidSwapError' + || /invalidswap|swap|record was at/i.test(e?.message ?? '') +} + +async function mutateAmaRecord ( event: H3Event, rkey: string, - platform: AmaPlatform, - url: string, update: AmaUpdate, -): Promise { + action: string, + published?: { platform: AmaPlatform, url: string }, +): Promise> { const { agent, did } = await requireAdminAgent(event) const MAX_ATTEMPTS = 5 @@ -88,78 +166,81 @@ export async function mergePublishedLink ( }) const current = existing.data.value as DevRoeAma.Record const currentCid = existing.data.cid - - const next: DevRoeAma.Record = { - $type: 'dev.roe.ama', - status: 'answered', - question: update.question ?? current.question, - posts: update.posts ?? current.posts ?? [], - platforms: update.platforms ?? current.platforms ?? { - bluesky: false, - mastodon: false, - linkedin: false, - youtubeShorts: false, - }, - publishedLinks: { - ...(current.publishedLinks ?? {}), - [platform]: url, - }, - ...(update.image - ? { - image: update.image as never, - ...(update.imageDimensions ? { imageDimensions: update.imageDimensions } : {}), - ...(update.backgroundStyle ? { backgroundStyle: update.backgroundStyle } : {}), - } - : current.image - ? { - image: current.image, - ...(current.imageDimensions ? { imageDimensions: current.imageDimensions } : {}), - ...(current.backgroundStyle ? { backgroundStyle: current.backgroundStyle } : {}), - } - : {}), - createdAt: current.createdAt, - answeredAt: current.answeredAt ?? new Date().toISOString(), - } - - const lexRecord = jsonToLex(next as never) as DevRoeAma.Record - const validation = lexicons.validate('dev.roe.ama', lexRecord) - if (!validation.success) { - const lr = lexRecord as { image?: unknown } - console.warn('[ama-record] local validation failed for', platform, 'rkey=', rkey, { - error: validation.error.message, - rawImage: (next as { image?: unknown }).image, - lexImage: lr.image, - lexImageIsBlobRef: lr.image instanceof BlobRef, - updateImageSource: update.image ? 'body' : 'current', - }) - } + const lexRecord = toLexRecord(buildRecord(current, update, published), action) try { - await agent.com.atproto.repo.putRecord({ + const res = await agent.com.atproto.repo.putRecord({ repo: did, collection: 'dev.roe.ama', rkey, record: lexRecord as Record, swapRecord: currentCid, }) - return + return { + rkey, + uri: res.data.uri, + cid: res.data.cid, + value: lexRecord, + } } catch (err) { - const e = err as { error?: string, name?: string, status?: number, message?: string } | undefined - const looksLikeSwapMiss = e?.error === 'InvalidSwap' - || e?.name === 'InvalidSwapError' - || /invalidswap|swap|record was at/i.test(e?.message ?? '') - if (!looksLikeSwapMiss || attempt >= MAX_ATTEMPTS) throw err + if (!looksLikeSwapMiss(err) || attempt >= MAX_ATTEMPTS) throw err await new Promise(r => setTimeout(r, 50 * attempt)) } } throw createError({ statusCode: 500, - statusMessage: `mergePublishedLink exhausted ${MAX_ATTEMPTS} attempts for ${platform} on rkey=${rkey} without writing.`, + statusMessage: `AMA ${action} exhausted ${MAX_ATTEMPTS} attempts for rkey=${rkey} without writing.`, }) } +export function viewAma (r: AdminRecord<'dev.roe.ama'>): AmaView { + const v = r.value + let question = v.question ?? '' + if (v.status === 'unanswered' && v.encryptedQuestion) { + try { + question = decrypt(v.encryptedQuestion) + } + catch (err) { + console.warn(`[admin/ama] Failed to decrypt ${r.uri}:`, err instanceof Error ? err.message : err) + } + } + return { + rkey: r.rkey, + uri: r.uri, + cid: r.cid, + status: v.status as 'unanswered' | 'answered', + question, + posts: v.posts ?? [], + platforms: v.platforms, + publishedLinks: v.publishedLinks, + image: v.image, + imageDimensions: v.imageDimensions, + backgroundStyle: v.backgroundStyle, + createdAt: v.createdAt, + answeredAt: v.answeredAt, + } +} + +export async function saveAmaDraft ( + event: H3Event, + rkey: string, + update: AmaUpdate, +): Promise { + return viewAma(await mutateAmaRecord(event, rkey, update, 'draft save')) +} + +export async function mergePublishedLink ( + event: H3Event, + rkey: string, + platform: AmaPlatform, + url: string, + update: AmaUpdate, +): Promise { + await mutateAmaRecord(event, rkey, update, `publish link merge for ${platform}`, { platform, url }) +} + export async function ensureNotAlreadyPublished ( event: H3Event, rkey: string, @@ -182,20 +263,42 @@ export async function ensureNotAlreadyPublished ( } } -export function publishImageFromBody ( +export interface AmaImage { + blob: unknown + url: string + width: number + height: number + mimeType?: string + size: number | null +} + +export async function prepareAmaImage ( event: H3Event, + rkey: string, body: AmaUpdate, -): { url: string, width: number, height: number, mimeType?: string } | undefined { +): Promise { if (!body.image || !body.imageDimensions) return undefined + const cid = cidFromBlob(body.image) - const config = useRuntimeConfig(event) - const did = config.atproto.did - if (!cid || !did) return undefined + if (!cid) { + throw createError({ statusCode: 422, statusMessage: 'Invalid AMA image blob.' }) + } + + const service = useRuntimeConfig(event).public.atproto.service + if (!service) { + throw createError({ statusCode: 500, statusMessage: 'PDS service is not configured.' }) + } + + const { did } = await requireAdminAgent(event) + await saveAmaDraft(event, rkey, body) + const mimeType = (body.image as { mimeType?: string } | undefined)?.mimeType return { - url: blobUrlFor(config.public.atproto.service, did, cid), + blob: body.image, + url: blobUrlFor(service, did, cid), width: body.imageDimensions.width, height: body.imageDimensions.height, ...(mimeType ? { mimeType } : {}), + size: blobSize(body.image), } } diff --git a/shared/cms/blob.ts b/shared/cms/blob.ts index ee0f15f5..4733b4c5 100644 --- a/shared/cms/blob.ts +++ b/shared/cms/blob.ts @@ -1,14 +1,12 @@ /** * Extract the base32 CID string from a blob ref. Accepts the IPLD-JSON - * form (`{ ref: { $link: cid } }`), a `BlobRef` instance with a `CID` - * `ref`, and the legacy flat `{ cid }` shape. + * form (`{ ref: { $link: cid } }`) and a `BlobRef` instance with a `CID` + * `ref`. */ export function cidFromBlob (blob: unknown): string | null { if (!blob || typeof blob !== 'object') return null const b = blob as Record - if (typeof b.cid === 'string') return b.cid - const ref = b.ref if (!ref) return null if (typeof ref === 'string') return ref @@ -26,3 +24,11 @@ export function blobUrlFor (service: string, did: string, cid: string): string { const base = service.replace(/\/$/, '') return `${base}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}` } + +export const BLUESKY_IMAGE_MAX_BYTES = 1_000_000 + +export function blobSize (blob: unknown): number | null { + if (!blob || typeof blob !== 'object') return null + const size = (blob as { size?: unknown }).size + return typeof size === 'number' && Number.isFinite(size) ? size : null +} diff --git a/test/unit/blob.spec.ts b/test/unit/blob.spec.ts new file mode 100644 index 00000000..422ef0fa --- /dev/null +++ b/test/unit/blob.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { BLUESKY_IMAGE_MAX_BYTES, blobSize, blobUrlFor, cidFromBlob } from '../../shared/cms/blob' + +describe('cidFromBlob', () => { + it('reads the IPLD-JSON blob shape', () => { + expect(cidFromBlob({ $type: 'blob', ref: { $link: 'bafyabc' }, mimeType: 'image/png', size: 10 })).toBe('bafyabc') + }) + + it('returns null for non-blob input', () => { + expect(cidFromBlob(null)).toBeNull() + expect(cidFromBlob('nope')).toBeNull() + expect(cidFromBlob({})).toBeNull() + }) +}) + +describe('blobSize', () => { + it('reads the declared byte size', () => { + expect(blobSize({ $type: 'blob', size: 1234 })).toBe(1234) + }) + + it('returns null when size is missing or not finite', () => { + expect(blobSize({ $type: 'blob' })).toBeNull() + expect(blobSize({ size: Number.NaN })).toBeNull() + expect(blobSize(null)).toBeNull() + }) + + it('exposes the Bluesky embed limit as 1 MB', () => { + expect(BLUESKY_IMAGE_MAX_BYTES).toBe(1_000_000) + }) +}) + +describe('blobUrlFor', () => { + it('builds a getBlob URL and trims a trailing slash from the service', () => { + expect(blobUrlFor('https://pds.example/', 'did:plc:abc', 'bafyabc')).toBe( + 'https://pds.example/xrpc/com.atproto.sync.getBlob?did=did%3Aplc%3Aabc&cid=bafyabc', + ) + }) +}) -- 2.51.2