mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
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:
commit
7f02657306
10 changed files with 166 additions and 11 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -96,6 +96,7 @@ const instanceV2Controller: AppController = async (c) => {
|
|||
'@2x': meta.picture,
|
||||
},
|
||||
},
|
||||
screenshots: meta.screenshots,
|
||||
languages: [
|
||||
'en',
|
||||
],
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export const manifestController: AppController = async (c) => {
|
|||
scope: '/',
|
||||
short_name: meta.name,
|
||||
start_url: '/',
|
||||
screenshots: meta.screenshots,
|
||||
};
|
||||
|
||||
return c.json(manifest, {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
13
src/schemas/mastodon.ts
Normal 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 };
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue