Merge branch 'feat-set-kind-0-api' into 'main'

Create updateInstanceController endpoint (allow for setting the kind 0, screenshots go into kind 0 content)

Closes #259 and #257

See merge request soapbox-pub/ditto!577
This commit is contained in:
Alex Gleason 2024-11-05 17:02:08 +00:00
commit 7f02657306
10 changed files with 166 additions and 11 deletions

View file

@ -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);

View file

@ -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<typeof screenshotsSchema> = (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);
};

View file

@ -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));

View file

@ -96,6 +96,7 @@ const instanceV2Controller: AppController = async (c) => {
'@2x': meta.picture,
},
},
screenshots: meta.screenshots,
languages: [
'en',
],

View file

@ -20,6 +20,7 @@ export const manifestController: AppController = async (c) => {
scope: '/',
short_name: meta.name,
start_url: '/',
screenshots: meta.screenshots,
};
return c.json(manifest, {

View file

@ -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);
});

View file

@ -65,6 +65,11 @@ const localeSchema = z.string().transform<Intl.Locale>((val, ctx) => {
}
});
/** White-space separated list of sizes, each in the format <number with up to 4 digits>x<number with up to 4 digits> 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,
};

13
src/schemas/mastodon.ts Normal file
View file

@ -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 };

View file

@ -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<typeof emojiTagSchema>;
export { type EmojiTag, emojiTagSchema, relayInfoDocSchema, serverMetaSchema, signedEventSchema };
export { type EmojiTag, emojiTagSchema, relayInfoDocSchema, screenshotsSchema, serverMetaSchema, signedEventSchema };

View file

@ -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<typeof screenshotsSchema>;
}
/** 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 ?? [],
};
}