diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 27cf7590..1ae6a4f6 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -15,6 +15,7 @@ import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts' import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderRelationship } from '@/views/mastodon/relationships.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; +import { metadataSchema } from '@/schemas/nostr.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { bech32ToPubkey } from '@/utils.ts'; import { addTag, deleteTag, findReplyTag, getTagSet } from '@/utils/tags.ts'; @@ -269,6 +270,7 @@ const updateCredentialsSchema = z.object({ pleroma_settings_store: z.record(z.string(), z.unknown()).optional(), lud16: z.string().email().or(z.literal('')).optional(), website: z.string().url().or(z.literal('')).optional(), + fields_attributes: z.object({ name: z.string(), value: z.string() }).array().optional(), }); const updateCredentialsController: AppController = async (c) => { @@ -284,11 +286,12 @@ const updateCredentialsController: AppController = async (c) => { const event = await updateEvent( { kinds: [0], authors: [pubkey], limit: 1 }, async (prev) => { - const meta = n.json().pipe(n.metadata()).catch({}).parse(prev.content); + const meta = n.json().pipe(metadataSchema).catch({}).parse(prev.content); const { avatar: avatarFile, header: headerFile, display_name, + fields_attributes, note, nip05, lud16, @@ -316,6 +319,10 @@ const updateCredentialsController: AppController = async (c) => { if (lud16 === '') delete meta.lud16; if (website === '') delete meta.website; + if (fields_attributes) { + meta.fields = fields_attributes.map(({ name, value }) => [name, value]); + } + return { kind: 0, content: JSON.stringify(meta), diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 4e8f917d..05cd0f31 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -9,6 +9,11 @@ const signedEventSchema = n.event() .refine((event) => event.id === getEventHash(event), 'Event ID does not match hash') .refine(verifyEvent, 'Event signature is invalid'); +/** Kind 0 standardized fields extended with Ditto custom fields. */ +const metadataSchema = n.metadata().and(z.object({ + fields: z.tuple([z.string(), z.string()]).array().optional().catch(undefined), +})); + /** * Stored in the kind 0 content. * https://developer.mozilla.org/en-US/docs/Web/Manifest/screenshots @@ -63,4 +68,12 @@ 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, screenshotsSchema, serverMetaSchema, signedEventSchema }; +export { + type EmojiTag, + emojiTagSchema, + metadataSchema, + relayInfoDocSchema, + screenshotsSchema, + serverMetaSchema, + signedEventSchema, +}; diff --git a/src/utils/api.ts b/src/utils/api.ts index e7766979..829a2cce 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -2,7 +2,6 @@ import { type Context } from '@hono/hono'; import { HTTPException } from '@hono/hono/http-exception'; import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import Debug from '@soapbox/stickynotes/debug'; -import { parseFormData } from 'formdata-helper'; import { EventTemplate } from 'nostr-tools'; import * as TypeFest from 'type-fest'; @@ -13,6 +12,7 @@ import { RelayError } from '@/RelayError.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts'; import { Storages } from '@/storages.ts'; import { nostrNow } from '@/utils.ts'; +import { parseFormData } from '@/utils/formdata.ts'; import { purifyEvent } from '@/utils/purify.ts'; const debug = Debug('ditto:api'); @@ -182,7 +182,11 @@ async function parseBody(req: Request): Promise { switch (req.headers.get('content-type')?.split(';')[0]) { case 'multipart/form-data': case 'application/x-www-form-urlencoded': - return parseFormData(await req.formData()); + try { + return parseFormData(await req.formData()); + } catch { + throw new HTTPException(400, { message: 'Invalid form data' }); + } case 'application/json': return req.json(); } diff --git a/src/utils/formdata.test.ts b/src/utils/formdata.test.ts new file mode 100644 index 00000000..3eaf02aa --- /dev/null +++ b/src/utils/formdata.test.ts @@ -0,0 +1,30 @@ +import { assertEquals, assertThrows } from '@std/assert'; + +import { parseFormData } from '@/utils/formdata.ts'; + +Deno.test('parseFormData', () => { + const formData = new FormData(); + + formData.append('foo', 'bar'); + formData.append('fields_attributes[0][name]', 'baz'); + formData.append('fields_attributes[0][value]', 'qux'); + formData.append('fields_attributes[1][name]', 'quux'); + formData.append('fields_attributes[1][value]', 'corge'); + + const result = parseFormData(formData); + + assertEquals(result, { + foo: 'bar', + fields_attributes: [ + { name: 'baz', value: 'qux' }, + { name: 'quux', value: 'corge' }, + ], + }); + + assertThrows(() => { + const formData = new FormData(); + formData.append('fields_attributes[1]', 'unexpected'); + formData.append('fields_attributes[1][extra]', 'extra_value'); + parseFormData(formData); + }); +}); diff --git a/src/utils/formdata.ts b/src/utils/formdata.ts new file mode 100644 index 00000000..6d5d997b --- /dev/null +++ b/src/utils/formdata.ts @@ -0,0 +1,43 @@ +import { parseFormData as _parseFormData } from 'formdata-helper'; + +/** Parse formData into JSON, simulating the way Mastodon does it. */ +export function parseFormData(formData: FormData): unknown { + const json = _parseFormData(formData); + + const parsed: Record = {}; + + for (const [key, value] of Object.entries(json)) { + deepSet(parsed, key, value); + } + + return parsed; +} + +/** Deeply sets a value in an object based on a Rails-style nested key. */ +function deepSet( + /** The target object to modify. */ + target: Record, + /** The Rails-style key (e.g., "fields_attributes[0][name]"). */ + key: string, + /** The value to set. */ + value: any, +): void { + const keys = key.match(/[^[\]]+/g); // Extract keys like ["fields_attributes", "0", "name"] + if (!keys) return; + + let current = target; + + keys.forEach((k, index) => { + const isLast = index === keys.length - 1; + + if (isLast) { + current[k] = value; // Set the value at the final key + } else { + if (!current[k]) { + // Determine if the next key is numeric, then create an array; otherwise, an object + current[k] = /^\d+$/.test(keys[index + 1]) ? [] : {}; + } + current = current[k]; + } + }); +} diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 6d0e0446..927b00d5 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -4,6 +4,7 @@ import { nip19, UnsignedEvent } from 'nostr-tools'; import { Conf } from '@/config.ts'; import { MastodonAccount } from '@/entities/MastodonAccount.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { metadataSchema } from '@/schemas/nostr.ts'; import { getLnurl } from '@/utils/lnurl.ts'; import { parseAndVerifyNip05 } from '@/utils/nip05.ts'; import { parseNoteContent } from '@/utils/note.ts'; @@ -42,7 +43,8 @@ async function renderAccount( lud06, lud16, website, - } = n.json().pipe(n.metadata()).catch({}).parse(event.content); + fields: _fields, + } = n.json().pipe(metadataSchema).catch({}).parse(event.content); const npub = nip19.npubEncode(pubkey); const nprofile = nip19.nprofileEncode({ pubkey, relays: [Conf.relay] }); @@ -58,6 +60,7 @@ async function renderAccount( } } const { html } = parseNoteContent(about || '', []); + const fields = _fields?.map(([name, value]) => ({ name, value, verified_at: null })) ?? []; return { id: pubkey, @@ -69,7 +72,7 @@ async function renderAccount( discoverable: true, display_name: name ?? '', emojis: renderEmojis(event), - fields: [], + fields, follow_requests_count: 0, followers_count: event.author_stats?.followers_count ?? 0, following_count: event.author_stats?.following_count ?? 0, @@ -82,7 +85,7 @@ async function renderAccount( roles: [], source: opts.withSource ? { - fields: [], + fields, language: '', note: about || '', privacy: 'public',