// Images, and the one thing a body says about one. // // A picture in a Radial body is not a URL to somebody's PDS. It is a `com.disnetdev.radial.image` // record — the blob plus its metadata — in the author's own repo, and the body refers to it with a // **locator**: `radial-image:`. Two reasons, both about what survives: // // - **The blob outlives the paste.** atproto reclaims a blob no record references, so an upload // with nothing pointing at it is an image that quietly stops existing. The record is what // anchors it, and the record is what the body names. // - **The address is not the host.** A `…/xrpc/com.atproto.sync.getBlob?did=…` URL pins whichever // PDS the author was on the day they pasted it. A record URI names the author, and the author's // current PDS is resolved at READ time — so moving a PDS does not rewrite anybody's prose. // // Resolution is deliberately one-way and closed: a reader takes the record URI out of the locator, // reads THAT record, validates it against the lexicon, resolves its author's DID to a PDS, and // fetches the blob CID the record itself carries. A DID/CID pair lifted straight out of markdown is // never fetched — that would be an arbitrary-fetch primitive with a Radial scheme painted on it. // // This module is the pure half of that: format, parse, and the blob URL. It is isomorphic and does // no I/O, because both fold implementations (the tab and the daemon) have to agree about what a // locator means, and only one of them has a network stack worth speaking of. import { COLLECTIONS, lexiconSchemas } from './generated/records.js' /** The scheme a Radial image locator is written in. Not a URL scheme anyone can navigate to. */ export const RADIAL_IMAGE_SCHEME = 'radial-image:' type BlobSchema = { accept?: readonly string[]; maxSize?: number } const imageBlobSchema = (): BlobSchema => { const lexicon = (lexiconSchemas as readonly { id: string; defs: Record }[]).find( (schema) => schema.id === COLLECTIONS.image, ) const record = (lexicon?.defs.main as { record?: { properties?: Record } })?.record return record?.properties?.blob ?? {} } /** * What an image record may carry, read off the lexicon rather than restated beside it. * * A writer that checked its own copy of these numbers would eventually check a different set from * the one the record is validated against, and the failure would arrive at somebody's PDS. The * upload form, the writer and the validator all read this. * * The allowlist is four raster formats and no SVG. An SVG displayed through `` is inert in most * respects but is still a document — it can carry script, reference external resources, and grow * arbitrarily on decode — and none of that is worth having in a paste target. */ export const IMAGE_LIMITS = { mimeTypes: (imageBlobSchema().accept ?? []) as readonly string[], maxBytes: imageBlobSchema().maxSize ?? 0, } as const /** Whether a MIME type is one an image record may carry. Compared case-insensitively, without params. */ export const isImageMimeType = (mimeType: string): boolean => IMAGE_LIMITS.mimeTypes.includes(mimeType.split(';')[0]?.trim().toLowerCase() ?? '') /** * An image record's URI, strictly. The shape is pinned rather than merely parsed: the authority is a * DID, the collection is the image collection and nothing else, and the record key is one segment of * key-safe characters. No query, no fragment, no userinfo, no second path segment — every one of * those is a way to make a resolver fetch something other than the record the URI appears to name. */ const IMAGE_URI = /^at:\/\/(did:[a-z0-9]+:[A-Za-z0-9._:%-]{1,256})\/com\.disnetdev\.radial\.image\/([A-Za-z0-9.~_-]{1,512})$/ export interface ImageLocator { /** The `at://` URI of the image record. */ uri: string /** Its author — whose PDS the blob is fetched from, after resolving the DID. */ did: string rkey: string } /** Whether a string is an image record URI this module will resolve. */ export const isImageUri = (uri: string): boolean => IMAGE_URI.test(uri) /** * The locator a body carries for one image record, or `undefined` for a URI this would not resolve. * * Percent-encoding is applied to the handful of characters a markdown link destination cannot hold * literally. An image record URI contains none of them, so in practice the locator is the URI with a * scheme on the front and stays readable to a person — and to an agent reading the brief it was * pasted into, which is the whole reason it is not an opaque blob of `%3A`. */ export function imageLocator(uri: string): string | undefined { if (!IMAGE_URI.test(uri)) return undefined return `${RADIAL_IMAGE_SCHEME}${uri.replace(/[()<>\s"'\\]/g, (char) => encodeURIComponent(char))}` } /** * The image record a locator names, or null for anything else — including an ordinary URL, another * scheme wearing this one's prefix, a URI naming some other collection, and a URI carrying the query * string or credentials a resolver would otherwise have to decide what to do with. */ export function parseImageLocator(href: string): ImageLocator | null { const text = href.trim() if (!text.toLowerCase().startsWith(RADIAL_IMAGE_SCHEME)) return null const rest = text.slice(RADIAL_IMAGE_SCHEME.length) let uri: string try { uri = decodeURIComponent(rest) } catch { // A malformed escape: not a locator, and emphatically not something to guess about. return null } const match = IMAGE_URI.exec(uri) if (!match) return null return { uri, did: match[1] as string, rkey: match[2] as string } } /** * Every distinct image a run of prose refers to, in the order it first mentions them. * * This exists for the readers that are not a browser. A turn's bundle carries request briefs, goal * bodies and messages verbatim, so a locator somebody pasted into one arrives in front of an agent * as `![alt](radial-image:at://…)` — text it can read but has no reason to recognise. Listing them * once, with what each is called, is what turns that into something an agent can go and fetch; * `bundle-writer.ts` does the listing. * * Scanning is deliberately over the whole string rather than over a token tree: this runs where * there is no markdown parser, the locator scheme is unambiguous, and a locator inside a fenced * block is still a picture somebody meant to show. */ export function imageLocatorsIn(text: string): ImageLocator[] { const found: ImageLocator[] = [] const seen = new Set() for (const match of text.matchAll(/radial-image:[^\s)\]<>"']+/gi)) { const locator = parseImageLocator(match[0]) if (!locator || seen.has(locator.uri)) continue seen.add(locator.uri) found.push(locator) } return found } export interface ImageMarkdownSpan { from: number to: number } /** * The spans of complete Markdown images whose destinations are valid Radial image locators. * * Editors use this to keep a later insertion out of an image whose alt text they selected for * correction. Keeping the recognition here means that presentation code cannot silently drift * from the locator syntax the protocol parser accepts. */ export function imageMarkdownSpansIn(text: string): ImageMarkdownSpan[] { const spans: ImageMarkdownSpan[] = [] for (const match of text.matchAll(/!\[[^\r\n]*?\]\((radial-image:[^\s)]+)\)/gi)) { if (!parseImageLocator(match[1] as string)) continue spans.push({ from: match.index, to: match.index + match[0].length }) } return spans } /** * Where a blob is actually fetched from: `com.atproto.sync.getBlob` on the PDS the author's DID * currently resolves to. Both parameters are query-encoded — a DID is not URL-safe by construction * (`did:web:example.com%3A8443`), and a CID pasted into a URL unescaped is the sort of thing that * works until one is not. */ export function imageBlobUrl(pds: string | URL, did: string, cid: string): string { const query = new URLSearchParams({ did, cid }) return new URL(`/xrpc/com.atproto.sync.getBlob?${query}`, pds).href }