From f97064afb4bc1a6c05e430f4d4315ca94ed4f179 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 11:35:29 -0500 Subject: [PATCH 01/11] Remove dependency on npm:mime, switch to @std/media-types --- deno.json | 2 +- src/deps.ts | 2 -- src/note.ts | 9 +++++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/deno.json b/deno.json index e40dd24c..54ba4680 100644 --- a/deno.json +++ b/deno.json @@ -32,7 +32,7 @@ "@std/dotenv": "jsr:@std/dotenv@^0.224.0", "@std/encoding": "jsr:@std/encoding@^0.224.0", "@std/json": "jsr:@std/json@^0.223.0", - "@std/media-types": "jsr:@std/media-types@^0.224.0", + "@std/media-types": "jsr:@std/media-types@^0.224.1", "@std/streams": "jsr:@std/streams@^0.223.0", "comlink": "npm:comlink@^4.4.1", "deno-safe-fetch": "https://gitlab.com/soapbox-pub/deno-safe-fetch/-/raw/v1.0.0/load.ts", diff --git a/src/deps.ts b/src/deps.ts index 46d8fecc..12be07f1 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -1,8 +1,6 @@ import 'deno-safe-fetch'; // @deno-types="npm:@types/lodash@4.14.194" export { default as lodash } from 'https://esm.sh/lodash@4.17.21'; -// @deno-types="npm:@types/mime@3.0.0" -export { default as mime } from 'npm:mime@^3.0.0'; // @deno-types="npm:@types/sanitize-html@2.9.0" export { default as sanitizeHtml } from 'npm:sanitize-html@^2.11.0'; export { diff --git a/src/note.ts b/src/note.ts index 1c5c70cd..7dc39f5a 100644 --- a/src/note.ts +++ b/src/note.ts @@ -1,10 +1,10 @@ +import { typeByExtension } from '@std/media-types'; import 'linkify-plugin-hashtag'; import linkifyStr from 'linkify-string'; import linkify from 'linkifyjs'; import { nip19, nip21 } from 'nostr-tools'; import { Conf } from '@/config.ts'; -import { mime } from '@/deps.ts'; import { type DittoAttachment } from '@/views/mastodon/attachments.ts'; linkify.registerCustomProtocol('nostr', true); @@ -87,12 +87,13 @@ function isLinkURL(link: Link): boolean { return link.type === 'url'; } -/** `npm:mime` treats `.com` as a file extension, so parse the full URL to get its path first. */ +/** Get the extension from the URL, then get its type. */ function getUrlMimeType(url: string): string | undefined { try { const { pathname } = new URL(url); - return mime.getType(pathname) || undefined; - } catch (_e) { + const ext = pathname.split('.').pop() ?? ''; + return typeByExtension(ext); + } catch { return undefined; } } From 5997ff0fff38bf79486818a7e0f9ceae2182e4b6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 11:52:33 -0500 Subject: [PATCH 02/11] Create utils/media.ts, move some code from note.ts there --- src/note.ts | 25 ++++++------------------- src/utils/media.test.ts | 17 +++++++++++++++++ src/utils/media.ts | 24 ++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 19 deletions(-) create mode 100644 src/utils/media.test.ts create mode 100644 src/utils/media.ts diff --git a/src/note.ts b/src/note.ts index 7dc39f5a..71dc0174 100644 --- a/src/note.ts +++ b/src/note.ts @@ -1,10 +1,10 @@ -import { typeByExtension } from '@std/media-types'; import 'linkify-plugin-hashtag'; import linkifyStr from 'linkify-string'; import linkify from 'linkifyjs'; import { nip19, nip21 } from 'nostr-tools'; import { Conf } from '@/config.ts'; +import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts'; import { type DittoAttachment } from '@/views/mastodon/attachments.ts'; linkify.registerCustomProtocol('nostr', true); @@ -60,16 +60,14 @@ function parseNoteContent(content: string): ParsedNoteContent { function getMediaLinks(links: Link[]): DittoAttachment[] { return links.reduce((acc, link) => { - const mimeType = getUrlMimeType(link.href); - if (!mimeType) return acc; + const mediaType = getUrlMediaType(link.href); + if (!mediaType) return acc; - const [baseType, _subType] = mimeType.split('/'); - - if (['audio', 'image', 'video'].includes(baseType)) { + if (isPermittedMediaType(mediaType, ['audio', 'image', 'video'])) { acc.push({ url: link.href, data: { - mime: mimeType, + mime: mediaType, }, }); } @@ -79,7 +77,7 @@ function getMediaLinks(links: Link[]): DittoAttachment[] { } function isNonMediaLink({ href }: Link): boolean { - return /^https?:\/\//.test(href) && !getUrlMimeType(href); + return /^https?:\/\//.test(href) && !getUrlMediaType(href); } /** Ensures the Link is a URL so it can be parsed. */ @@ -87,17 +85,6 @@ function isLinkURL(link: Link): boolean { return link.type === 'url'; } -/** Get the extension from the URL, then get its type. */ -function getUrlMimeType(url: string): string | undefined { - try { - const { pathname } = new URL(url); - const ext = pathname.split('.').pop() ?? ''; - return typeByExtension(ext); - } catch { - return undefined; - } -} - /** Get pubkey from decoded bech32 entity, or undefined if not applicable. */ function getDecodedPubkey(decoded: nip19.DecodeResult): string | undefined { switch (decoded.type) { diff --git a/src/utils/media.test.ts b/src/utils/media.test.ts new file mode 100644 index 00000000..e88e97da --- /dev/null +++ b/src/utils/media.test.ts @@ -0,0 +1,17 @@ +import { assertEquals } from '@std/assert'; + +import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts'; + +Deno.test('getUrlMediaType', () => { + assertEquals(getUrlMediaType('https://example.com/image.png'), 'image/png'); + assertEquals(getUrlMediaType('https://example.com/index.html'), 'text/html'); + assertEquals(getUrlMediaType('https://example.com/yolo'), undefined); + assertEquals(getUrlMediaType('https://example.com/'), undefined); +}); + +Deno.test('isPermittedMediaType', () => { + assertEquals(isPermittedMediaType('image/png', ['image', 'video']), true); + assertEquals(isPermittedMediaType('video/webm', ['image', 'video']), true); + assertEquals(isPermittedMediaType('audio/ogg', ['image', 'video']), false); + assertEquals(isPermittedMediaType('application/json', ['image', 'video']), false); +}); diff --git a/src/utils/media.ts b/src/utils/media.ts new file mode 100644 index 00000000..9c0ea9e3 --- /dev/null +++ b/src/utils/media.ts @@ -0,0 +1,24 @@ +import { typeByExtension } from '@std/media-types'; + +/** Get media type of the filename in the URL by its extension, if any. */ +export function getUrlMediaType(url: string): string | undefined { + try { + const { pathname } = new URL(url); + const ext = pathname.split('.').pop() ?? ''; + return typeByExtension(ext); + } catch { + return undefined; + } +} + +/** + * Check if the base type matches any of the permitted types. + * + * ```ts + * isPermittedMediaType('image/png', ['image', 'video']); // true + * ``` + */ +export function isPermittedMediaType(mediaType: string, permitted: string[]): boolean { + const [baseType, _subType] = mediaType.split('/'); + return permitted.includes(baseType); +} From 942260aa54f61a96a43492f886328df4cfa29dc4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 11:53:33 -0500 Subject: [PATCH 03/11] note.ts -> utils/note.ts --- src/{ => utils}/note.ts | 0 src/views/mastodon/statuses.ts | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{ => utils}/note.ts (100%) diff --git a/src/note.ts b/src/utils/note.ts similarity index 100% rename from src/note.ts rename to src/utils/note.ts diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index aefe258b..1b0ebe85 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -4,10 +4,10 @@ import { nip19 } from 'nostr-tools'; import { Conf } from '@/config.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { getMediaLinks, parseNoteContent } from '@/note.ts'; import { Storages } from '@/storages.ts'; import { findReplyTag } from '@/tags.ts'; import { nostrDate } from '@/utils.ts'; +import { getMediaLinks, parseNoteContent } from '@/utils/note.ts'; import { unfurlCardCached } from '@/utils/unfurl.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts'; From c8f9483795f12d8e79470c6142be88f8879f398d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 11:56:22 -0500 Subject: [PATCH 04/11] Add note.test.ts --- src/utils/note.test.ts | 28 ++++++++++++++++++++++++++++ src/utils/note.ts | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 src/utils/note.test.ts diff --git a/src/utils/note.test.ts b/src/utils/note.test.ts new file mode 100644 index 00000000..d123050f --- /dev/null +++ b/src/utils/note.test.ts @@ -0,0 +1,28 @@ +import { assertEquals } from '@std/assert'; + +import { getMediaLinks, parseNoteContent } from '@/utils/note.ts'; + +Deno.test('parseNoteContent', () => { + const { html, links, firstUrl } = parseNoteContent('Hello, world!'); + assertEquals(html, 'Hello, world!'); + assertEquals(links, []); + assertEquals(firstUrl, undefined); +}); + +Deno.test('getMediaLinks', () => { + const links = [ + { href: 'https://example.com/image.png' }, + { href: 'https://example.com/index.html' }, + { href: 'https://example.com/yolo' }, + { href: 'https://example.com/' }, + ]; + const mediaLinks = getMediaLinks(links); + assertEquals(mediaLinks, [ + { + url: 'https://example.com/image.png', + data: { + mime: 'image/png', + }, + }, + ]); +}); diff --git a/src/utils/note.ts b/src/utils/note.ts index 71dc0174..580c2072 100644 --- a/src/utils/note.ts +++ b/src/utils/note.ts @@ -58,7 +58,7 @@ function parseNoteContent(content: string): ParsedNoteContent { }; } -function getMediaLinks(links: Link[]): DittoAttachment[] { +function getMediaLinks(links: Pick[]): DittoAttachment[] { return links.reduce((acc, link) => { const mediaType = getUrlMediaType(link.href); if (!mediaType) return acc; From 7d34b9401e8b191e91801d205c85c3685a876769 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 13:22:20 -0500 Subject: [PATCH 05/11] Support imeta tags --- src/controllers/api/statuses.ts | 2 +- src/db/unattached-media.ts | 3 +-- src/schemas/nostr.ts | 25 +-------------------- src/upload.ts | 37 ++++++++++++++++++++----------- src/views/mastodon/attachments.ts | 16 ++++++++----- src/views/mastodon/statuses.ts | 12 ++++++---- 6 files changed, 46 insertions(+), 49 deletions(-) diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 00f9a98e..8904b2b8 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -96,7 +96,7 @@ const createStatusController: AppController = async (c) => { if (data.media_ids?.length) { const media = await getUnattachedMediaByIds(kysely, data.media_ids) .then((media) => media.filter(({ pubkey }) => pubkey === viewerPubkey)) - .then((media) => media.map(({ url, data }) => ['media', url, data])); + .then((media) => media.map(({ data }) => ['imeta', ...data])); tags.push(...media); } diff --git a/src/db/unattached-media.ts b/src/db/unattached-media.ts index cee1e3a3..0628278d 100644 --- a/src/db/unattached-media.ts +++ b/src/db/unattached-media.ts @@ -3,13 +3,12 @@ import uuid62 from 'uuid62'; import { DittoDB } from '@/db/DittoDB.ts'; import { DittoTables } from '@/db/DittoTables.ts'; -import { type MediaData } from '@/schemas/nostr.ts'; interface UnattachedMedia { id: string; pubkey: string; url: string; - data: MediaData; + data: string[][]; // NIP-94 tags uploaded_at: number; } diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index a42b9f07..d8aa29a4 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -9,27 +9,12 @@ const signedEventSchema = n.event() .refine((event) => event.id === getEventHash(event), 'Event ID does not match hash') .refine(verifyEvent, 'Event signature is invalid'); -/** 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), -}); - /** Kind 0 content schema for the Ditto server admin user. */ const serverMetaSchema = n.metadata().and(z.object({ tagline: z.string().optional().catch(undefined), email: z.string().optional().catch(undefined), })); -/** Media data from `"media"` tags. */ -type MediaData = z.infer; - /** NIP-11 Relay Information Document. */ const relayInfoDocSchema = z.object({ name: z.string().transform((val) => val.slice(0, 30)).optional().catch(undefined), @@ -47,12 +32,4 @@ const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url() /** NIP-30 custom emoji tag. */ type EmojiTag = z.infer; -export { - type EmojiTag, - emojiTagSchema, - type MediaData, - mediaDataSchema, - relayInfoDocSchema, - serverMetaSchema, - signedEventSchema, -}; +export { type EmojiTag, emojiTagSchema, relayInfoDocSchema, serverMetaSchema, signedEventSchema }; diff --git a/src/upload.ts b/src/upload.ts index 632dbabf..4f5fd14f 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -8,26 +8,37 @@ interface FileMeta { } /** Upload a file, track it in the database, and return the resulting media object. */ -async function uploadFile(file: File, meta: FileMeta, signal?: AbortSignal) { - const { name, type, size } = file; +async function uploadFile(file: File, meta: FileMeta, signal?: AbortSignal): Promise { + const { type, size } = file; const { pubkey, description } = meta; if (file.size > Conf.maxUploadSize) { throw new Error('File size is too large.'); } - const { url } = await uploader.upload(file, { signal }); + const { url, sha256, cid } = await uploader.upload(file, { signal }); - return insertUnattachedMedia({ - pubkey, - url, - data: { - name, - size, - description, - mime: type, - }, - }); + const data: string[][] = [ + ['url', url], + ['m', type], + ['size', size.toString()], + ]; + + if (sha256) { + data.push(['x', sha256]); + } + + if (cid) { + data.push(['cid', cid]); + } + + if (description) { + data.push(['alt', description]); + } + + await insertUnattachedMedia({ pubkey, url, data }); + + return data; } export { uploadFile }; diff --git a/src/views/mastodon/attachments.ts b/src/views/mastodon/attachments.ts index 3ea989e6..18fe0319 100644 --- a/src/views/mastodon/attachments.ts +++ b/src/views/mastodon/attachments.ts @@ -6,15 +6,21 @@ type DittoAttachment = TypeFest.SetOptional name === 'm')?.[1]; + const alt = data.find(([name]) => name === 'alt')?.[1]; + const cid = data.find(([name]) => name === 'cid')?.[1]; + const blurhash = data.find(([name]) => name === 'blurhash')?.[1]; + return { - id: id ?? url ?? data.cid, - type: getAttachmentType(data.mime ?? ''), + id: id ?? url, + type: getAttachmentType(m ?? ''), url, preview_url: url, remote_url: null, - description: data.description ?? '', - blurhash: data.blurhash || null, - cid: data.cid, + description: alt ?? '', + blurhash: blurhash || null, + cid: cid, }; } diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 1b0ebe85..8428e9a1 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -1,4 +1,4 @@ -import { NostrEvent, NSchema as n } from '@nostrify/nostrify'; +import { NostrEvent } from '@nostrify/nostrify'; import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ade5e01597c6d23e22e53c628ef0e2/src/nostr/tags.ts'; import { nip19 } from 'nostr-tools'; @@ -12,7 +12,6 @@ import { unfurlCardCached } from '@/utils/unfurl.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts'; -import { mediaDataSchema } from '@/schemas/nostr.ts'; interface RenderStatusOpts { viewerPubkey?: string; @@ -80,8 +79,13 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< const mediaLinks = getMediaLinks(links); const mediaTags: DittoAttachment[] = event.tags - .filter((tag) => tag[0] === 'media') - .map(([_, url, json]) => ({ url, data: n.json().pipe(mediaDataSchema).parse(json) })); + .filter(([name]) => name === 'imeta') + .map(([_, ...entries]) => { + const data = entries.map((entry) => entry.split(' ')); + const url = data.find(([name]) => name === 'url')?.[1]; + return { url, data }; + }) + .filter((media): media is DittoAttachment => !!media.url); const media = [...mediaLinks, ...mediaTags]; From 611a94bdcf640efcd22615ff12d1f911fbf61cc4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 14:32:50 -0500 Subject: [PATCH 06/11] Fix uploading (almost) --- src/controllers/api/statuses.ts | 12 +++++++++--- src/db/unattached-media.ts | 9 +++++++++ src/utils/api.ts | 2 ++ src/utils/note.ts | 16 +++++++--------- src/views/mastodon/attachments.ts | 14 +++++++------- src/views/mastodon/statuses.ts | 13 ++++--------- 6 files changed, 38 insertions(+), 28 deletions(-) diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 8904b2b8..d8186f59 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { DittoDB } from '@/db/DittoDB.ts'; -import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; +import { getUnattachedMediaByUrls } from '@/db/unattached-media.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; import { addTag, deleteTag } from '@/tags.ts'; import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; @@ -94,9 +94,15 @@ const createStatusController: AppController = async (c) => { const viewerPubkey = await c.get('signer')?.getPublicKey(); if (data.media_ids?.length) { - const media = await getUnattachedMediaByIds(kysely, data.media_ids) + const media = await getUnattachedMediaByUrls(kysely, data.media_ids) .then((media) => media.filter(({ pubkey }) => pubkey === viewerPubkey)) - .then((media) => media.map(({ data }) => ['imeta', ...data])); + .then((media) => + media.map(({ data }) => { + const tags: string[][] = JSON.parse(data); + const values: string[] = tags.map((tag) => tag.join(' ')); + return ['imeta', ...values]; + }) + ); tags.push(...media); } diff --git a/src/db/unattached-media.ts b/src/db/unattached-media.ts index 0628278d..397a1f77 100644 --- a/src/db/unattached-media.ts +++ b/src/db/unattached-media.ts @@ -64,6 +64,14 @@ async function getUnattachedMediaByIds(kysely: Kysely, ids: string[ .execute(); } +/** Get unattached media by URLs. */ +async function getUnattachedMediaByUrls(kysely: Kysely, urls: string[]) { + if (!urls.length) return []; + return await selectUnattachedMediaQuery(kysely) + .where('url', 'in', urls) + .execute(); +} + /** Delete rows as an event with media is being created. */ async function deleteAttachedMedia(pubkey: string, urls: string[]): Promise { if (!urls.length) return; @@ -79,6 +87,7 @@ export { deleteUnattachedMediaByUrl, getUnattachedMedia, getUnattachedMediaByIds, + getUnattachedMediaByUrls, insertUnattachedMedia, type UnattachedMedia, }; diff --git a/src/utils/api.ts b/src/utils/api.ts index dceede7a..c54f5aad 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -29,6 +29,8 @@ async function createEvent(t: EventStub, c: AppContext): Promise { }); } + console.log(t); + const event = await signer.signEvent({ content: '', created_at: nostrNow(), diff --git a/src/utils/note.ts b/src/utils/note.ts index 580c2072..20cb83aa 100644 --- a/src/utils/note.ts +++ b/src/utils/note.ts @@ -5,7 +5,6 @@ import { nip19, nip21 } from 'nostr-tools'; import { Conf } from '@/config.ts'; import { getUrlMediaType, isPermittedMediaType } from '@/utils/media.ts'; -import { type DittoAttachment } from '@/views/mastodon/attachments.ts'; linkify.registerCustomProtocol('nostr', true); linkify.registerCustomProtocol('wss'); @@ -58,18 +57,17 @@ function parseNoteContent(content: string): ParsedNoteContent { }; } -function getMediaLinks(links: Pick[]): DittoAttachment[] { - return links.reduce((acc, link) => { +/** Returns a matrix of tags. Each item is a list of NIP-94 tags representing a file. */ +function getMediaLinks(links: Pick[]): string[][][] { + return links.reduce((acc, link) => { const mediaType = getUrlMediaType(link.href); if (!mediaType) return acc; if (isPermittedMediaType(mediaType, ['audio', 'image', 'video'])) { - acc.push({ - url: link.href, - data: { - mime: mediaType, - }, - }); + acc.push([ + ['url', link.href], + ['m', mediaType], + ]); } return acc; diff --git a/src/views/mastodon/attachments.ts b/src/views/mastodon/attachments.ts index 18fe0319..8922985f 100644 --- a/src/views/mastodon/attachments.ts +++ b/src/views/mastodon/attachments.ts @@ -4,16 +4,16 @@ import { UnattachedMedia } from '@/db/unattached-media.ts'; type DittoAttachment = TypeFest.SetOptional; -function renderAttachment(media: DittoAttachment) { - const { id, data, url } = media; +function renderAttachment(tags: string[][]) { + const url = tags.find(([name]) => name === 'url')?.[1]; - const m = data.find(([name]) => name === 'm')?.[1]; - const alt = data.find(([name]) => name === 'alt')?.[1]; - const cid = data.find(([name]) => name === 'cid')?.[1]; - const blurhash = data.find(([name]) => name === 'blurhash')?.[1]; + const m = tags.find(([name]) => name === 'm')?.[1]; + const alt = tags.find(([name]) => name === 'alt')?.[1]; + const cid = tags.find(([name]) => name === 'cid')?.[1]; + const blurhash = tags.find(([name]) => name === 'blurhash')?.[1]; return { - id: id ?? url, + id: url, type: getAttachmentType(m ?? ''), url, preview_url: url, diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 8428e9a1..889c23dd 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -10,7 +10,7 @@ import { nostrDate } from '@/utils.ts'; import { getMediaLinks, parseNoteContent } from '@/utils/note.ts'; import { unfurlCardCached } from '@/utils/unfurl.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; -import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts'; +import { renderAttachment } from '@/views/mastodon/attachments.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts'; interface RenderStatusOpts { @@ -78,16 +78,11 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< const mediaLinks = getMediaLinks(links); - const mediaTags: DittoAttachment[] = event.tags + const imeta: string[][][] = event.tags .filter(([name]) => name === 'imeta') - .map(([_, ...entries]) => { - const data = entries.map((entry) => entry.split(' ')); - const url = data.find(([name]) => name === 'url')?.[1]; - return { url, data }; - }) - .filter((media): media is DittoAttachment => !!media.url); + .map(([_, ...entries]) => entries.map((entry) => entry.split(' '))); - const media = [...mediaLinks, ...mediaTags]; + const media = [...mediaLinks, ...imeta]; return { id: event.id, From e7d350a0e305a58191331b6664866419d215802f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 14:54:10 -0500 Subject: [PATCH 07/11] Fix uploading by URL --- deno.json | 1 - src/controllers/api/statuses.ts | 4 ++-- src/db/unattached-media.ts | 25 +++++-------------------- src/upload.ts | 11 ++++++++++- src/utils/api.ts | 2 -- src/views/mastodon/attachments.ts | 6 +++--- 6 files changed, 20 insertions(+), 29 deletions(-) diff --git a/deno.json b/deno.json index 54ba4680..946b4e02 100644 --- a/deno.json +++ b/deno.json @@ -54,7 +54,6 @@ "tseep": "npm:tseep@^1.2.1", "type-fest": "npm:type-fest@^4.3.0", "unfurl.js": "npm:unfurl.js@^6.4.0", - "uuid62": "npm:uuid62@^1.0.2", "zod": "npm:zod@^3.23.5", "~/fixtures/": "./fixtures/" }, diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index d8186f59..f7a603fe 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -5,7 +5,7 @@ import { z } from 'zod'; import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { DittoDB } from '@/db/DittoDB.ts'; -import { getUnattachedMediaByUrls } from '@/db/unattached-media.ts'; +import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; import { addTag, deleteTag } from '@/tags.ts'; import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; @@ -94,7 +94,7 @@ const createStatusController: AppController = async (c) => { const viewerPubkey = await c.get('signer')?.getPublicKey(); if (data.media_ids?.length) { - const media = await getUnattachedMediaByUrls(kysely, data.media_ids) + const media = await getUnattachedMediaByIds(kysely, data.media_ids) .then((media) => media.filter(({ pubkey }) => pubkey === viewerPubkey)) .then((media) => media.map(({ data }) => { diff --git a/src/db/unattached-media.ts b/src/db/unattached-media.ts index 397a1f77..0ab46b61 100644 --- a/src/db/unattached-media.ts +++ b/src/db/unattached-media.ts @@ -1,5 +1,4 @@ import { Kysely } from 'kysely'; -import uuid62 from 'uuid62'; import { DittoDB } from '@/db/DittoDB.ts'; import { DittoTables } from '@/db/DittoTables.ts'; @@ -8,24 +7,19 @@ interface UnattachedMedia { id: string; pubkey: string; url: string; - data: string[][]; // NIP-94 tags + /** NIP-94 tags. */ + data: string[][]; uploaded_at: number; } /** Add unattached media into the database. */ -async function insertUnattachedMedia(media: Omit) { - const result = { - id: uuid62.v4(), - uploaded_at: Date.now(), - ...media, - }; - +async function insertUnattachedMedia(media: UnattachedMedia) { const kysely = await DittoDB.getInstance(); await kysely.insertInto('unattached_media') - .values({ ...result, data: JSON.stringify(media.data) }) + .values({ ...media, data: JSON.stringify(media.data) }) .execute(); - return result; + return media; } /** Select query for unattached media. */ @@ -64,14 +58,6 @@ async function getUnattachedMediaByIds(kysely: Kysely, ids: string[ .execute(); } -/** Get unattached media by URLs. */ -async function getUnattachedMediaByUrls(kysely: Kysely, urls: string[]) { - if (!urls.length) return []; - return await selectUnattachedMediaQuery(kysely) - .where('url', 'in', urls) - .execute(); -} - /** Delete rows as an event with media is being created. */ async function deleteAttachedMedia(pubkey: string, urls: string[]): Promise { if (!urls.length) return; @@ -87,7 +73,6 @@ export { deleteUnattachedMediaByUrl, getUnattachedMedia, getUnattachedMediaByIds, - getUnattachedMediaByUrls, insertUnattachedMedia, type UnattachedMedia, }; diff --git a/src/upload.ts b/src/upload.ts index 4f5fd14f..d815bd83 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -36,7 +36,16 @@ async function uploadFile(file: File, meta: FileMeta, signal?: AbortSignal): Pro data.push(['alt', description]); } - await insertUnattachedMedia({ pubkey, url, data }); + const uuid = crypto.randomUUID(); + data.push(['uuid', uuid]); + + await insertUnattachedMedia({ + id: uuid, + pubkey, + url, + data, + uploaded_at: Date.now(), + }); return data; } diff --git a/src/utils/api.ts b/src/utils/api.ts index c54f5aad..dceede7a 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -29,8 +29,6 @@ async function createEvent(t: EventStub, c: AppContext): Promise { }); } - console.log(t); - const event = await signer.signEvent({ content: '', created_at: nostrNow(), diff --git a/src/views/mastodon/attachments.ts b/src/views/mastodon/attachments.ts index 8922985f..1dbcda95 100644 --- a/src/views/mastodon/attachments.ts +++ b/src/views/mastodon/attachments.ts @@ -5,15 +5,15 @@ import { UnattachedMedia } from '@/db/unattached-media.ts'; type DittoAttachment = TypeFest.SetOptional; function renderAttachment(tags: string[][]) { - const url = tags.find(([name]) => name === 'url')?.[1]; - const m = tags.find(([name]) => name === 'm')?.[1]; + const url = tags.find(([name]) => name === 'url')?.[1]; const alt = tags.find(([name]) => name === 'alt')?.[1]; const cid = tags.find(([name]) => name === 'cid')?.[1]; + const uuid = tags.find(([name]) => name === 'uuid')?.[1]; const blurhash = tags.find(([name]) => name === 'blurhash')?.[1]; return { - id: url, + id: uuid, type: getAttachmentType(m ?? ''), url, preview_url: url, From 91ea4577f1549be74b8f036293ca3fa0a56d7bd5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 14:58:48 -0500 Subject: [PATCH 08/11] Filter out attachments with no url --- src/views/mastodon/attachments.ts | 4 +++- src/views/mastodon/statuses.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/views/mastodon/attachments.ts b/src/views/mastodon/attachments.ts index 1dbcda95..9f8e5c35 100644 --- a/src/views/mastodon/attachments.ts +++ b/src/views/mastodon/attachments.ts @@ -12,8 +12,10 @@ function renderAttachment(tags: string[][]) { const uuid = tags.find(([name]) => name === 'uuid')?.[1]; const blurhash = tags.find(([name]) => name === 'blurhash')?.[1]; + if (!url) return; + return { - id: uuid, + id: uuid ?? url, type: getAttachmentType(m ?? ''), url, preview_url: url, diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 889c23dd..16ba4822 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -106,7 +106,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< pinned: Boolean(pinEvent), reblog: null, application: null, - media_attachments: media.map(renderAttachment), + media_attachments: media.map(renderAttachment).filter(Boolean), mentions, tags: [], emojis: renderEmojis(event), From b1b341d3b8a947b2a07325bfa07f2060c01cadcd Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 15:29:12 -0500 Subject: [PATCH 09/11] Insert media URL into text --- src/controllers/api/statuses.ts | 27 +++++++++++++-------------- src/db/unattached-media.ts | 10 ++++++++-- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index f7a603fe..e620b930 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -91,21 +91,14 @@ const createStatusController: AppController = async (c) => { tags.push(['subject', data.spoiler_text]); } - const viewerPubkey = await c.get('signer')?.getPublicKey(); + const media = data.media_ids?.length ? await getUnattachedMediaByIds(kysely, data.media_ids) : []; - if (data.media_ids?.length) { - const media = await getUnattachedMediaByIds(kysely, data.media_ids) - .then((media) => media.filter(({ pubkey }) => pubkey === viewerPubkey)) - .then((media) => - media.map(({ data }) => { - const tags: string[][] = JSON.parse(data); - const values: string[] = tags.map((tag) => tag.join(' ')); - return ['imeta', ...values]; - }) - ); + const imeta: string[][] = media.map(({ data }) => { + const values: string[] = data.map((tag) => tag.join(' ')); + return ['imeta', ...values]; + }); - tags.push(...media); - } + tags.push(...imeta); const pubkeys = new Set(); @@ -137,9 +130,15 @@ const createStatusController: AppController = async (c) => { tags.push(['t', match[1]]); } + const mediaUrls: string[] = media + .map(({ data }) => data.find(([name]) => name === 'url')?.[1]) + .filter((url): url is string => Boolean(url)); + + const mediaCompat: string = mediaUrls.length ? ['', '', ...mediaUrls].join('\n') : ''; + const event = await createEvent({ kind: 1, - content, + content: content + mediaCompat, tags, }, c); diff --git a/src/db/unattached-media.ts b/src/db/unattached-media.ts index 0ab46b61..0e0aeea6 100644 --- a/src/db/unattached-media.ts +++ b/src/db/unattached-media.ts @@ -51,11 +51,17 @@ async function deleteUnattachedMediaByUrl(url: string) { } /** Get unattached media by IDs. */ -async function getUnattachedMediaByIds(kysely: Kysely, ids: string[]) { +async function getUnattachedMediaByIds(kysely: Kysely, ids: string[]): Promise { if (!ids.length) return []; - return await selectUnattachedMediaQuery(kysely) + + const results = await selectUnattachedMediaQuery(kysely) .where('id', 'in', ids) .execute(); + + return results.map((row) => ({ + ...row, + data: JSON.parse(row.data), + })); } /** Delete rows as an event with media is being created. */ From c8b999a1f7a808e89aab968c24ae2bf467fb4a8f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 15:36:17 -0500 Subject: [PATCH 10/11] imeta: don't get attachment ID from a tag --- src/upload.ts | 13 ++++--------- src/views/mastodon/attachments.ts | 14 +++++--------- src/views/mastodon/statuses.ts | 2 +- 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/upload.ts b/src/upload.ts index d815bd83..40184f09 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -1,5 +1,5 @@ import { Conf } from '@/config.ts'; -import { insertUnattachedMedia } from '@/db/unattached-media.ts'; +import { insertUnattachedMedia, UnattachedMedia } from '@/db/unattached-media.ts'; import { configUploader as uploader } from '@/uploaders/config.ts'; interface FileMeta { @@ -8,7 +8,7 @@ interface FileMeta { } /** Upload a file, track it in the database, and return the resulting media object. */ -async function uploadFile(file: File, meta: FileMeta, signal?: AbortSignal): Promise { +async function uploadFile(file: File, meta: FileMeta, signal?: AbortSignal): Promise { const { type, size } = file; const { pubkey, description } = meta; @@ -36,18 +36,13 @@ async function uploadFile(file: File, meta: FileMeta, signal?: AbortSignal): Pro data.push(['alt', description]); } - const uuid = crypto.randomUUID(); - data.push(['uuid', uuid]); - - await insertUnattachedMedia({ - id: uuid, + return insertUnattachedMedia({ + id: crypto.randomUUID(), pubkey, url, data, uploaded_at: Date.now(), }); - - return data; } export { uploadFile }; diff --git a/src/views/mastodon/attachments.ts b/src/views/mastodon/attachments.ts index 9f8e5c35..273b460d 100644 --- a/src/views/mastodon/attachments.ts +++ b/src/views/mastodon/attachments.ts @@ -1,21 +1,17 @@ -import * as TypeFest from 'type-fest'; +/** Render Mastodon media attachment. */ +function renderAttachment(media: { id?: string; data: string[][] }) { + const { id, data: tags } = media; -import { UnattachedMedia } from '@/db/unattached-media.ts'; - -type DittoAttachment = TypeFest.SetOptional; - -function renderAttachment(tags: string[][]) { const m = tags.find(([name]) => name === 'm')?.[1]; const url = tags.find(([name]) => name === 'url')?.[1]; const alt = tags.find(([name]) => name === 'alt')?.[1]; const cid = tags.find(([name]) => name === 'cid')?.[1]; - const uuid = tags.find(([name]) => name === 'uuid')?.[1]; const blurhash = tags.find(([name]) => name === 'blurhash')?.[1]; if (!url) return; return { - id: uuid ?? url, + id: id ?? url, type: getAttachmentType(m ?? ''), url, preview_url: url, @@ -40,4 +36,4 @@ function getAttachmentType(mime: string): string { } } -export { type DittoAttachment, renderAttachment }; +export { renderAttachment }; diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 16ba4822..c674e16d 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -106,7 +106,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< pinned: Boolean(pinEvent), reblog: null, application: null, - media_attachments: media.map(renderAttachment).filter(Boolean), + media_attachments: media.map((m) => renderAttachment({ data: m })).filter(Boolean), mentions, tags: [], emojis: renderEmojis(event), From cbf0bc35940b87c84d30feb1a91d0c900ac47cd5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 15:46:28 -0500 Subject: [PATCH 11/11] Fix note test --- src/utils/note.test.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/utils/note.test.ts b/src/utils/note.test.ts index d123050f..9c8fad7e 100644 --- a/src/utils/note.test.ts +++ b/src/utils/note.test.ts @@ -17,12 +17,8 @@ Deno.test('getMediaLinks', () => { { href: 'https://example.com/' }, ]; const mediaLinks = getMediaLinks(links); - assertEquals(mediaLinks, [ - { - url: 'https://example.com/image.png', - data: { - mime: 'image/png', - }, - }, - ]); + assertEquals(mediaLinks, [[ + ['url', 'https://example.com/image.png'], + ['m', 'image/png'], + ]]); });