diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index 9988428..1e310e1 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -5,6 +5,7 @@ import { z } from '@/deps.ts'; import { fileSchema } from '@/schema.ts'; import { configUploader as uploader } from '@/uploaders/config.ts'; import { parseBody } from '@/utils/web.ts'; +import { renderAttachment } from '@/views/attachment.ts'; const uploadSchema = fileSchema .refine((file) => !!file.type, 'File type is required.') @@ -41,33 +42,11 @@ const mediaController: AppController = async (c) => { }, }); - return c.json({ - id: media.id, - type: getAttachmentType(file.type), - url, - preview_url: url, - remote_url: null, - description, - blurhash: null, - }); + return c.json(renderAttachment(media)); } catch (e) { console.error(e); return c.json({ error: 'Failed to upload file.' }, 500); } }; -/** MIME to Mastodon API `Attachment` type. */ -function getAttachmentType(mime: string): string { - const [type] = mime.split('/'); - - switch (type) { - case 'image': - case 'video': - case 'audio': - return type; - default: - return 'unknown'; - } -} - export { mediaController }; diff --git a/src/db/unattached-media.ts b/src/db/unattached-media.ts index f58931b..f3050a2 100644 --- a/src/db/unattached-media.ts +++ b/src/db/unattached-media.ts @@ -1,18 +1,12 @@ import { db } from '@/db.ts'; import { uuid62 } from '@/deps.ts'; +import { type MediaData } from '@/schemas/nostr.ts'; interface UnattachedMedia { id: string; pubkey: string; url: string; - data: { - name?: string; - mime?: string; - width?: number; - height?: number; - size?: number; - description?: string; - }; + data: MediaData; uploaded_at: Date; } @@ -64,4 +58,10 @@ function getUnattachedMediaByIds(ids: string[]) { .execute(); } -export { deleteUnattachedMediaByUrl, getUnattachedMedia, getUnattachedMediaByIds, insertUnattachedMedia }; +export { + deleteUnattachedMediaByUrl, + getUnattachedMedia, + getUnattachedMediaByIds, + insertUnattachedMedia, + type UnattachedMedia, +}; diff --git a/src/note.ts b/src/note.ts index 928c436..92baf52 100644 --- a/src/note.ts +++ b/src/note.ts @@ -1,5 +1,6 @@ import { Conf } from '@/config.ts'; import { linkify, linkifyStr, mime, nip19, nip21 } from '@/deps.ts'; +import { type DittoAttachment } from '@/views/attachment.ts'; linkify.registerCustomProtocol('nostr', true); linkify.registerCustomProtocol('wss'); @@ -52,13 +53,8 @@ function parseNoteContent(content: string): ParsedNoteContent { }; } -interface MediaLink { - url: string; - mimeType?: string; -} - -function getMediaLinks(links: Link[]): MediaLink[] { - return links.reduce((acc, link) => { +function getMediaLinks(links: Link[]): DittoAttachment[] { + return links.reduce((acc, link) => { const mimeType = getUrlMimeType(link.href); if (!mimeType) return acc; @@ -67,7 +63,9 @@ function getMediaLinks(links: Link[]): MediaLink[] { if (['audio', 'image', 'video'].includes(baseType)) { acc.push({ url: link.href, - mimeType, + data: { + mime: mimeType, + }, }); } @@ -110,4 +108,4 @@ function getDecodedPubkey(decoded: nip19.DecodeResult): string | undefined { } } -export { getMediaLinks, type MediaLink, parseNoteContent }; +export { getMediaLinks, parseNoteContent }; diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 1294804..c097935 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -73,9 +73,27 @@ const metaContentSchema = z.object({ lud16: z.string().optional().catch(undefined), }).partial().passthrough(); +/** Media data schema from `"media"` tags. */ +const mediaDataSchema = z.object({ + blurhash: z.string().optional().catch(undefined), + cid: z.string().optional().catch(undefined), + description: z.string().max(200).optional().catch(undefined), + height: z.number().int().positive().optional().catch(undefined), + mime: z.string().optional().catch(undefined), + name: z.string().optional().catch(undefined), + size: z.number().int().positive().optional().catch(undefined), + width: z.number().int().positive().optional().catch(undefined), +}); + +/** Media data from `"media"` tags. */ +type MediaData = z.infer; + /** Parses kind 0 content from a JSON string. */ const jsonMetaContentSchema = jsonSchema.pipe(metaContentSchema).catch({}); +/** Parses media data from a JSON string. */ +const jsonMediaDataSchema = jsonSchema.pipe(mediaDataSchema).catch({}); + /** NIP-11 Relay Information Document. */ const relayInfoDocSchema = z.object({ name: z.string().transform((val) => val.slice(0, 30)).optional().catch(undefined), @@ -102,7 +120,10 @@ export { type ClientREQ, connectResponseSchema, filterSchema, + jsonMediaDataSchema, jsonMetaContentSchema, + type MediaData, + mediaDataSchema, metaContentSchema, nostrIdSchema, relayInfoDocSchema, diff --git a/src/transformers/nostr-to-mastoapi.ts b/src/transformers/nostr-to-mastoapi.ts index 4dd7da5..5fc02a8 100644 --- a/src/transformers/nostr-to-mastoapi.ts +++ b/src/transformers/nostr-to-mastoapi.ts @@ -2,14 +2,15 @@ import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ad import { Conf } from '@/config.ts'; import * as eventsDB from '@/db/events.ts'; -import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, z } from '@/deps.ts'; -import { getMediaLinks, type MediaLink, parseNoteContent } from '@/note.ts'; +import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl } from '@/deps.ts'; +import { getMediaLinks, parseNoteContent } from '@/note.ts'; import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.ts'; import { emojiTagSchema, filteredArray } from '@/schema.ts'; -import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; +import { jsonMediaDataSchema, jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { isFollowing, type Nip05, nostrDate, parseNip05, Time } from '@/utils.ts'; import { verifyNip05Cached } from '@/utils/nip05.ts'; import { findUser } from '@/db/users.ts'; +import { DittoAttachment, renderAttachment } from '@/views/attachment.ts'; const DEFAULT_AVATAR = 'https://gleasonator.com/images/avi.png'; const DEFAULT_BANNER = 'https://gleasonator.com/images/banner.png'; @@ -141,9 +142,11 @@ async function toStatus(event: Event<1>, viewerPubkey?: string) { const mediaLinks = getMediaLinks(links); - const media = event.tags + const mediaTags: DittoAttachment[] = event.tags .filter((tag) => tag[0] === 'media') - .map(([_, url]) => ({ url })); + .map(([_, url, json]) => ({ url, data: jsonMediaDataSchema.parse(json) })); + + const media = [...mediaLinks, ...mediaTags]; return { id: event.id, @@ -166,7 +169,7 @@ async function toStatus(event: Event<1>, viewerPubkey?: string) { bookmarked: false, reblog: null, application: null, - media_attachments: mediaLinks.concat(media).map(renderAttachment), + media_attachments: media.map(renderAttachment), mentions, tags: [], emojis: toEmojis(event), @@ -190,24 +193,6 @@ function buildInlineRecipients(mentions: Mention[]): string { return `${elements.join(' ')} `; } -const attachmentTypeSchema = z.enum(['image', 'video', 'gifv', 'audio', 'unknown']).catch('unknown'); - -function renderAttachment({ url, mimeType = '' }: MediaLink) { - const [baseType, _subType] = mimeType.split('/'); - const type = attachmentTypeSchema.parse(baseType); - - return { - id: url, - type, - url, - preview_url: url, - remote_url: null, - meta: {}, - description: '', - blurhash: null, - }; -} - interface PreviewCard { url: string; title: string; diff --git a/src/views/attachment.ts b/src/views/attachment.ts new file mode 100644 index 0000000..38ddb37 --- /dev/null +++ b/src/views/attachment.ts @@ -0,0 +1,34 @@ +import { UnattachedMedia } from '@/db/unattached-media.ts'; +import { type TypeFest } from '@/deps.ts'; + +type DittoAttachment = TypeFest.SetOptional; + +function renderAttachment(media: DittoAttachment) { + const { id, data, url } = media; + return { + id: id ?? url ?? data.cid, + type: getAttachmentType(data.mime ?? ''), + url, + preview_url: url, + remote_url: null, + description: data.description ?? '', + blurhash: data.blurhash || null, + cid: data.cid, + }; +} + +/** MIME to Mastodon API `Attachment` type. */ +function getAttachmentType(mime: string): string { + const [type] = mime.split('/'); + + switch (type) { + case 'image': + case 'video': + case 'audio': + return type; + default: + return 'unknown'; + } +} + +export { type DittoAttachment, renderAttachment };