media: add attachment view, unify types

This commit is contained in:
Alex Gleason 2023-09-09 21:33:12 -05:00
parent cf9a754b02
commit 43499f2dfd
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
6 changed files with 82 additions and 65 deletions

View File

@ -5,6 +5,7 @@ import { z } from '@/deps.ts';
import { fileSchema } from '@/schema.ts'; import { fileSchema } from '@/schema.ts';
import { configUploader as uploader } from '@/uploaders/config.ts'; import { configUploader as uploader } from '@/uploaders/config.ts';
import { parseBody } from '@/utils/web.ts'; import { parseBody } from '@/utils/web.ts';
import { renderAttachment } from '@/views/attachment.ts';
const uploadSchema = fileSchema const uploadSchema = fileSchema
.refine((file) => !!file.type, 'File type is required.') .refine((file) => !!file.type, 'File type is required.')
@ -41,33 +42,11 @@ const mediaController: AppController = async (c) => {
}, },
}); });
return c.json({ return c.json(renderAttachment(media));
id: media.id,
type: getAttachmentType(file.type),
url,
preview_url: url,
remote_url: null,
description,
blurhash: null,
});
} catch (e) { } catch (e) {
console.error(e); console.error(e);
return c.json({ error: 'Failed to upload file.' }, 500); 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 }; export { mediaController };

View File

@ -1,18 +1,12 @@
import { db } from '@/db.ts'; import { db } from '@/db.ts';
import { uuid62 } from '@/deps.ts'; import { uuid62 } from '@/deps.ts';
import { type MediaData } from '@/schemas/nostr.ts';
interface UnattachedMedia { interface UnattachedMedia {
id: string; id: string;
pubkey: string; pubkey: string;
url: string; url: string;
data: { data: MediaData;
name?: string;
mime?: string;
width?: number;
height?: number;
size?: number;
description?: string;
};
uploaded_at: Date; uploaded_at: Date;
} }
@ -64,4 +58,10 @@ function getUnattachedMediaByIds(ids: string[]) {
.execute(); .execute();
} }
export { deleteUnattachedMediaByUrl, getUnattachedMedia, getUnattachedMediaByIds, insertUnattachedMedia }; export {
deleteUnattachedMediaByUrl,
getUnattachedMedia,
getUnattachedMediaByIds,
insertUnattachedMedia,
type UnattachedMedia,
};

View File

@ -1,5 +1,6 @@
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { linkify, linkifyStr, mime, nip19, nip21 } from '@/deps.ts'; import { linkify, linkifyStr, mime, nip19, nip21 } from '@/deps.ts';
import { type DittoAttachment } from '@/views/attachment.ts';
linkify.registerCustomProtocol('nostr', true); linkify.registerCustomProtocol('nostr', true);
linkify.registerCustomProtocol('wss'); linkify.registerCustomProtocol('wss');
@ -52,13 +53,8 @@ function parseNoteContent(content: string): ParsedNoteContent {
}; };
} }
interface MediaLink { function getMediaLinks(links: Link[]): DittoAttachment[] {
url: string; return links.reduce<DittoAttachment[]>((acc, link) => {
mimeType?: string;
}
function getMediaLinks(links: Link[]): MediaLink[] {
return links.reduce<MediaLink[]>((acc, link) => {
const mimeType = getUrlMimeType(link.href); const mimeType = getUrlMimeType(link.href);
if (!mimeType) return acc; if (!mimeType) return acc;
@ -67,7 +63,9 @@ function getMediaLinks(links: Link[]): MediaLink[] {
if (['audio', 'image', 'video'].includes(baseType)) { if (['audio', 'image', 'video'].includes(baseType)) {
acc.push({ acc.push({
url: link.href, 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 };

View File

@ -73,9 +73,27 @@ const metaContentSchema = z.object({
lud16: z.string().optional().catch(undefined), lud16: z.string().optional().catch(undefined),
}).partial().passthrough(); }).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<typeof mediaDataSchema>;
/** Parses kind 0 content from a JSON string. */ /** Parses kind 0 content from a JSON string. */
const jsonMetaContentSchema = jsonSchema.pipe(metaContentSchema).catch({}); const jsonMetaContentSchema = jsonSchema.pipe(metaContentSchema).catch({});
/** Parses media data from a JSON string. */
const jsonMediaDataSchema = jsonSchema.pipe(mediaDataSchema).catch({});
/** NIP-11 Relay Information Document. */ /** NIP-11 Relay Information Document. */
const relayInfoDocSchema = z.object({ const relayInfoDocSchema = z.object({
name: z.string().transform((val) => val.slice(0, 30)).optional().catch(undefined), name: z.string().transform((val) => val.slice(0, 30)).optional().catch(undefined),
@ -102,7 +120,10 @@ export {
type ClientREQ, type ClientREQ,
connectResponseSchema, connectResponseSchema,
filterSchema, filterSchema,
jsonMediaDataSchema,
jsonMetaContentSchema, jsonMetaContentSchema,
type MediaData,
mediaDataSchema,
metaContentSchema, metaContentSchema,
nostrIdSchema, nostrIdSchema,
relayInfoDocSchema, relayInfoDocSchema,

View File

@ -2,14 +2,15 @@ import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ad
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import * as eventsDB from '@/db/events.ts'; import * as eventsDB from '@/db/events.ts';
import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, z } from '@/deps.ts'; import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl } from '@/deps.ts';
import { getMediaLinks, type MediaLink, parseNoteContent } from '@/note.ts'; import { getMediaLinks, parseNoteContent } from '@/note.ts';
import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.ts'; import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.ts';
import { emojiTagSchema, filteredArray } from '@/schema.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 { isFollowing, type Nip05, nostrDate, parseNip05, Time } from '@/utils.ts';
import { verifyNip05Cached } from '@/utils/nip05.ts'; import { verifyNip05Cached } from '@/utils/nip05.ts';
import { findUser } from '@/db/users.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_AVATAR = 'https://gleasonator.com/images/avi.png';
const DEFAULT_BANNER = 'https://gleasonator.com/images/banner.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 mediaLinks = getMediaLinks(links);
const media = event.tags const mediaTags: DittoAttachment[] = event.tags
.filter((tag) => tag[0] === 'media') .filter((tag) => tag[0] === 'media')
.map(([_, url]) => ({ url })); .map(([_, url, json]) => ({ url, data: jsonMediaDataSchema.parse(json) }));
const media = [...mediaLinks, ...mediaTags];
return { return {
id: event.id, id: event.id,
@ -166,7 +169,7 @@ async function toStatus(event: Event<1>, viewerPubkey?: string) {
bookmarked: false, bookmarked: false,
reblog: null, reblog: null,
application: null, application: null,
media_attachments: mediaLinks.concat(media).map(renderAttachment), media_attachments: media.map(renderAttachment),
mentions, mentions,
tags: [], tags: [],
emojis: toEmojis(event), emojis: toEmojis(event),
@ -190,24 +193,6 @@ function buildInlineRecipients(mentions: Mention[]): string {
return `<span class="recipients-inline">${elements.join(' ')} </span>`; return `<span class="recipients-inline">${elements.join(' ')} </span>`;
} }
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 { interface PreviewCard {
url: string; url: string;
title: string; title: string;

34
src/views/attachment.ts Normal file
View File

@ -0,0 +1,34 @@
import { UnattachedMedia } from '@/db/unattached-media.ts';
import { type TypeFest } from '@/deps.ts';
type DittoAttachment = TypeFest.SetOptional<UnattachedMedia, 'id' | 'pubkey' | 'uploaded_at'>;
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 };