diff --git a/src/app.ts b/src/app.ts index cc508443..29886c89 100644 --- a/src/app.ts +++ b/src/app.ts @@ -49,6 +49,7 @@ import { nameRequestController, nameRequestsController, statusZapSplitsController, + updateInstanceController, updateZapSplitsController, } from '@/controllers/api/ditto.ts'; import { emptyArrayController, notImplementedController } from '@/controllers/api/fallback.ts'; @@ -308,6 +309,8 @@ app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAd app.get('/api/v1/admin/ditto/relays', requireRole('admin'), adminRelaysController); app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController); +app.put('/api/v1/admin/ditto/instance', requireRole('admin'), updateInstanceController); + app.post('/api/v1/ditto/names', requireSigner, nameRequestController); app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); diff --git a/src/controllers/api/ditto.ts b/src/controllers/api/ditto.ts index b9be027f..77bb32ba 100644 --- a/src/controllers/api/ditto.ts +++ b/src/controllers/api/ditto.ts @@ -1,19 +1,24 @@ import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; +import { HTTPException } from '@hono/hono/http-exception'; import { z } from 'zod'; -import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; import { AppController } from '@/app.ts'; -import { addTag } from '@/utils/tags.ts'; -import { AdminSigner } from '@/signers/AdminSigner.ts'; -import { booleanParamSchema, percentageSchema } from '@/schema.ts'; import { Conf } from '@/config.ts'; -import { createEvent, paginated, parseBody } from '@/utils/api.ts'; +import { dittoUploads } from '@/DittoUploads.ts'; +import { addTag } from '@/utils/tags.ts'; +import { getAuthor } from '@/queries.ts'; +import { createEvent, paginated, parseBody, updateEvent } from '@/utils/api.ts'; +import { getInstanceMetadata } from '@/utils/instance.ts'; import { deleteTag } from '@/utils/tags.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts'; -import { getAuthor } from '@/queries.ts'; +import { AdminSigner } from '@/signers/AdminSigner.ts'; +import { screenshotsSchema } from '@/schemas/nostr.ts'; +import { booleanParamSchema, percentageSchema } from '@/schema.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { renderNameRequest } from '@/views/ditto.ts'; +import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; +import { renderAttachment } from '@/views/mastodon/attachments.ts'; import { renderAccount } from '@/views/mastodon/accounts.ts'; import { Storages } from '@/storages.ts'; import { updateListAdminEvent } from '@/utils/api.ts'; @@ -287,3 +292,86 @@ export const statusZapSplitsController: AppController = async (c) => { return c.json(zapSplits, 200); }; + +const updateInstanceSchema = z.object({ + title: z.string().optional(), + description: z.string().optional(), + /** Mastodon doesn't have this field. */ + screenshot_ids: z.string().array().nullish(), + /** Mastodon doesn't have this field. */ + thumbnail_id: z.string().optional(), +}).strict(); + +export const updateInstanceController: AppController = async (c) => { + const body = await parseBody(c.req.raw); + const result = updateInstanceSchema.safeParse(body); + const pubkey = Conf.pubkey; + + if (!result.success) { + return c.json(result.error, 422); + } + + await updateEvent( + { kinds: [0], authors: [pubkey], limit: 1 }, + async (_) => { + const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); + const { + title, + description, + screenshot_ids, + thumbnail_id, + } = result.data; + + const thumbnailUrl: string | undefined = (() => { + if (!thumbnail_id) { + return undefined; + } + + const upload = dittoUploads.get(thumbnail_id); + + if (!upload) { + throw new HTTPException(422, { message: 'Uploaded attachment is no longer available.' }); + } + return upload.url; + })(); + + const screenshots: z.infer = (screenshot_ids ?? []).map((id) => { + const upload = dittoUploads.get(id); + + if (!upload) { + throw new HTTPException(422, { message: 'Uploaded attachment is no longer available.' }); + } + + const data = renderAttachment(upload); + + if (!data?.url || !data.meta?.original) { + throw new HTTPException(422, { message: 'Image must have an URL and size dimensions.' }); + } + + const screenshot = { + src: data.url, + label: data.description, + sizes: `${data?.meta?.original?.width}x${data?.meta?.original?.height}`, + type: data?.type, // FIX-ME, I BEG YOU: Returns just `image` instead of a valid MIME type + }; + + return screenshot; + }); + + meta.name = title ?? meta.name; + meta.about = description ?? meta.about; + meta.screenshots = screenshot_ids ? screenshots : meta.screenshots; + meta.picture = thumbnailUrl ?? meta.picture; + delete meta.event; + + return { + kind: 0, + content: JSON.stringify(meta), + tags: [], + }; + }, + c, + ); + + return c.json(204); +}; diff --git a/src/controllers/api/fallback.ts b/src/controllers/api/fallback.ts index 9e170093..0e98ac79 100644 --- a/src/controllers/api/fallback.ts +++ b/src/controllers/api/fallback.ts @@ -1,4 +1,4 @@ -import { Context } from '@hono/hono'; +import { type Context } from '@hono/hono'; const emptyArrayController = (c: Context) => c.json([]); const notImplementedController = (c: Context) => Promise.resolve(c.json({ error: 'Not implemented' }, 404)); diff --git a/src/controllers/api/instance.ts b/src/controllers/api/instance.ts index 78a72dd4..9f504cad 100644 --- a/src/controllers/api/instance.ts +++ b/src/controllers/api/instance.ts @@ -96,6 +96,7 @@ const instanceV2Controller: AppController = async (c) => { '@2x': meta.picture, }, }, + screenshots: meta.screenshots, languages: [ 'en', ], diff --git a/src/controllers/manifest.ts b/src/controllers/manifest.ts index 60e2a2ac..2e75de04 100644 --- a/src/controllers/manifest.ts +++ b/src/controllers/manifest.ts @@ -20,6 +20,7 @@ export const manifestController: AppController = async (c) => { scope: '/', short_name: meta.name, start_url: '/', + screenshots: meta.screenshots, }; return c.json(manifest, { diff --git a/src/schema.test.ts b/src/schema.test.ts index c6b577de..66809c53 100644 --- a/src/schema.test.ts +++ b/src/schema.test.ts @@ -1,6 +1,6 @@ import { assertEquals } from '@std/assert'; -import { percentageSchema } from '@/schema.ts'; +import { percentageSchema, sizesSchema } from '@/schema.ts'; Deno.test('Value is any percentage from 1 to 100', () => { assertEquals(percentageSchema.safeParse('latvia' as unknown).success, false); @@ -20,3 +20,12 @@ Deno.test('Value is any percentage from 1 to 100', () => { assertEquals(percentageSchema.safeParse('1e1').success, true); }); + +Deno.test('Size or sizes has correct format', () => { + assertEquals(sizesSchema.safeParse('orphan' as unknown).success, false); + assertEquals(sizesSchema.safeParse('0000x 20x20' as unknown).success, false); + assertEquals(sizesSchema.safeParse('0000x10 20X20 1x22' as unknown).success, false); + assertEquals(sizesSchema.safeParse('1000x10 20X20 1x22' as unknown).success, true); + assertEquals(sizesSchema.safeParse('3333X6666 1x22 f' as unknown).success, false); + assertEquals(sizesSchema.safeParse('11xxxxxxx0 20X20 1x22' as unknown).success, false); +}); diff --git a/src/schema.ts b/src/schema.ts index a9dd56e3..6147f562 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -65,6 +65,11 @@ const localeSchema = z.string().transform((val, ctx) => { } }); +/** White-space separated list of sizes, each in the format x or with "X" in upper case. */ +const sizesSchema = z.string().refine((value) => + value.split(' ').every((v) => /^[1-9]\d{0,3}[xX][1-9]\d{0,3}$/.test(v)) +); + export { booleanParamSchema, decode64Schema, @@ -75,4 +80,5 @@ export { localeSchema, percentageSchema, safeUrlSchema, + sizesSchema, }; diff --git a/src/schemas/mastodon.ts b/src/schemas/mastodon.ts new file mode 100644 index 00000000..bedd1aad --- /dev/null +++ b/src/schemas/mastodon.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +/** https://docs.joinmastodon.org/entities/Instance/#thumbnail */ +const thumbnailSchema = z.object({ + url: z.string().url(), + blurhash: z.string().optional(), + versions: z.object({ + '@1x': z.string().url().optional(), + '@2x': z.string().url().optional(), + }).optional(), +}); + +export { thumbnailSchema }; diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index d8aa29a4..46f68a34 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -2,17 +2,48 @@ import { NSchema as n } from '@nostrify/nostrify'; import { getEventHash, verifyEvent } from 'nostr-tools'; import { z } from 'zod'; -import { safeUrlSchema } from '@/schema.ts'; +import { safeUrlSchema, sizesSchema } from '@/schema.ts'; /** Nostr event schema that also verifies the event's signature. */ const signedEventSchema = n.event() .refine((event) => event.id === getEventHash(event), 'Event ID does not match hash') .refine(verifyEvent, 'Event signature is invalid'); +/** + * Stored in the kind 0 content. + * https://developer.mozilla.org/en-US/docs/Web/Manifest/screenshots + */ +const screenshotsSchema = z.array(z.object({ + form_factor: z.enum(['narrow', 'wide']).optional(), + label: z.string().optional(), + platform: z.enum([ + 'android', + 'chromeos', + 'ipados', + 'ios', + 'kaios', + 'macos', + 'windows', + 'xbox', + 'chrome_web_store', + 'itunes', + 'microsoft-inbox', + 'microsoft-store', + 'play', + ]).optional(), + /** https://developer.mozilla.org/en-US/docs/Web/Manifest/screenshots#sizes */ + sizes: sizesSchema, + /** Absolute URL. */ + src: z.string().url(), + /** MIME type of the image. */ + type: z.string().optional(), +})); + /** 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), + screenshots: screenshotsSchema.optional(), })); /** NIP-11 Relay Information Document. */ @@ -32,4 +63,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, relayInfoDocSchema, serverMetaSchema, signedEventSchema }; +export { type EmojiTag, emojiTagSchema, relayInfoDocSchema, screenshotsSchema, serverMetaSchema, signedEventSchema }; diff --git a/src/utils/instance.ts b/src/utils/instance.ts index f46541c2..c0b9c0d4 100644 --- a/src/utils/instance.ts +++ b/src/utils/instance.ts @@ -1,7 +1,8 @@ import { NostrEvent, NostrMetadata, NSchema as n, NStore } from '@nostrify/nostrify'; +import { z } from 'zod'; import { Conf } from '@/config.ts'; -import { serverMetaSchema } from '@/schemas/nostr.ts'; +import { screenshotsSchema, serverMetaSchema } from '@/schemas/nostr.ts'; /** Like NostrMetadata, but some fields are required and also contains some extra fields. */ export interface InstanceMetadata extends NostrMetadata { @@ -11,6 +12,7 @@ export interface InstanceMetadata extends NostrMetadata { picture: string; tagline: string; event?: NostrEvent; + screenshots: z.infer; } /** Get and parse instance metadata from the kind 0 of the admin user. */ @@ -34,5 +36,6 @@ export async function getInstanceMetadata(store: NStore, signal?: AbortSignal): email: meta.email ?? `postmaster@${Conf.url.host}`, picture: meta.picture ?? Conf.local('/images/thumbnail.png'), event, + screenshots: meta.screenshots ?? [], }; }