mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Merge branch 'fields' into 'main'
Add custom profile fields See merge request soapbox-pub/ditto!591
This commit is contained in:
commit
55d1297375
6 changed files with 107 additions and 7 deletions
|
|
@ -15,6 +15,7 @@ import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts'
|
||||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
import { renderRelationship } from '@/views/mastodon/relationships.ts';
|
import { renderRelationship } from '@/views/mastodon/relationships.ts';
|
||||||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
|
import { metadataSchema } from '@/schemas/nostr.ts';
|
||||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { bech32ToPubkey } from '@/utils.ts';
|
import { bech32ToPubkey } from '@/utils.ts';
|
||||||
import { addTag, deleteTag, findReplyTag, getTagSet } from '@/utils/tags.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(),
|
pleroma_settings_store: z.record(z.string(), z.unknown()).optional(),
|
||||||
lud16: z.string().email().or(z.literal('')).optional(),
|
lud16: z.string().email().or(z.literal('')).optional(),
|
||||||
website: z.string().url().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) => {
|
const updateCredentialsController: AppController = async (c) => {
|
||||||
|
|
@ -284,11 +286,12 @@ const updateCredentialsController: AppController = async (c) => {
|
||||||
const event = await updateEvent(
|
const event = await updateEvent(
|
||||||
{ kinds: [0], authors: [pubkey], limit: 1 },
|
{ kinds: [0], authors: [pubkey], limit: 1 },
|
||||||
async (prev) => {
|
async (prev) => {
|
||||||
const meta = n.json().pipe(n.metadata()).catch({}).parse(prev.content);
|
const meta = n.json().pipe(metadataSchema).catch({}).parse(prev.content);
|
||||||
const {
|
const {
|
||||||
avatar: avatarFile,
|
avatar: avatarFile,
|
||||||
header: headerFile,
|
header: headerFile,
|
||||||
display_name,
|
display_name,
|
||||||
|
fields_attributes,
|
||||||
note,
|
note,
|
||||||
nip05,
|
nip05,
|
||||||
lud16,
|
lud16,
|
||||||
|
|
@ -316,6 +319,10 @@ const updateCredentialsController: AppController = async (c) => {
|
||||||
if (lud16 === '') delete meta.lud16;
|
if (lud16 === '') delete meta.lud16;
|
||||||
if (website === '') delete meta.website;
|
if (website === '') delete meta.website;
|
||||||
|
|
||||||
|
if (fields_attributes) {
|
||||||
|
meta.fields = fields_attributes.map(({ name, value }) => [name, value]);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 0,
|
kind: 0,
|
||||||
content: JSON.stringify(meta),
|
content: JSON.stringify(meta),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ const signedEventSchema = n.event()
|
||||||
.refine((event) => event.id === getEventHash(event), 'Event ID does not match hash')
|
.refine((event) => event.id === getEventHash(event), 'Event ID does not match hash')
|
||||||
.refine(verifyEvent, 'Event signature is invalid');
|
.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.
|
* Stored in the kind 0 content.
|
||||||
* https://developer.mozilla.org/en-US/docs/Web/Manifest/screenshots
|
* 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. */
|
/** NIP-30 custom emoji tag. */
|
||||||
type EmojiTag = z.infer<typeof emojiTagSchema>;
|
type EmojiTag = z.infer<typeof emojiTagSchema>;
|
||||||
|
|
||||||
export { type EmojiTag, emojiTagSchema, relayInfoDocSchema, screenshotsSchema, serverMetaSchema, signedEventSchema };
|
export {
|
||||||
|
type EmojiTag,
|
||||||
|
emojiTagSchema,
|
||||||
|
metadataSchema,
|
||||||
|
relayInfoDocSchema,
|
||||||
|
screenshotsSchema,
|
||||||
|
serverMetaSchema,
|
||||||
|
signedEventSchema,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { type Context } from '@hono/hono';
|
||||||
import { HTTPException } from '@hono/hono/http-exception';
|
import { HTTPException } from '@hono/hono/http-exception';
|
||||||
import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||||
import Debug from '@soapbox/stickynotes/debug';
|
import Debug from '@soapbox/stickynotes/debug';
|
||||||
import { parseFormData } from 'formdata-helper';
|
|
||||||
import { EventTemplate } from 'nostr-tools';
|
import { EventTemplate } from 'nostr-tools';
|
||||||
import * as TypeFest from 'type-fest';
|
import * as TypeFest from 'type-fest';
|
||||||
|
|
||||||
|
|
@ -13,6 +12,7 @@ import { RelayError } from '@/RelayError.ts';
|
||||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { Storages } from '@/storages.ts';
|
||||||
import { nostrNow } from '@/utils.ts';
|
import { nostrNow } from '@/utils.ts';
|
||||||
|
import { parseFormData } from '@/utils/formdata.ts';
|
||||||
import { purifyEvent } from '@/utils/purify.ts';
|
import { purifyEvent } from '@/utils/purify.ts';
|
||||||
|
|
||||||
const debug = Debug('ditto:api');
|
const debug = Debug('ditto:api');
|
||||||
|
|
@ -182,7 +182,11 @@ async function parseBody(req: Request): Promise<unknown> {
|
||||||
switch (req.headers.get('content-type')?.split(';')[0]) {
|
switch (req.headers.get('content-type')?.split(';')[0]) {
|
||||||
case 'multipart/form-data':
|
case 'multipart/form-data':
|
||||||
case 'application/x-www-form-urlencoded':
|
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':
|
case 'application/json':
|
||||||
return req.json();
|
return req.json();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
src/utils/formdata.test.ts
Normal file
30
src/utils/formdata.test.ts
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
43
src/utils/formdata.ts
Normal file
43
src/utils/formdata.ts
Normal file
|
|
@ -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<string, unknown> = {};
|
||||||
|
|
||||||
|
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<string, any>,
|
||||||
|
/** 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];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { nip19, UnsignedEvent } from 'nostr-tools';
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
|
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
|
||||||
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
|
import { metadataSchema } from '@/schemas/nostr.ts';
|
||||||
import { getLnurl } from '@/utils/lnurl.ts';
|
import { getLnurl } from '@/utils/lnurl.ts';
|
||||||
import { parseAndVerifyNip05 } from '@/utils/nip05.ts';
|
import { parseAndVerifyNip05 } from '@/utils/nip05.ts';
|
||||||
import { parseNoteContent } from '@/utils/note.ts';
|
import { parseNoteContent } from '@/utils/note.ts';
|
||||||
|
|
@ -42,7 +43,8 @@ async function renderAccount(
|
||||||
lud06,
|
lud06,
|
||||||
lud16,
|
lud16,
|
||||||
website,
|
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 npub = nip19.npubEncode(pubkey);
|
||||||
const nprofile = nip19.nprofileEncode({ pubkey, relays: [Conf.relay] });
|
const nprofile = nip19.nprofileEncode({ pubkey, relays: [Conf.relay] });
|
||||||
|
|
@ -58,6 +60,7 @@ async function renderAccount(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const { html } = parseNoteContent(about || '', []);
|
const { html } = parseNoteContent(about || '', []);
|
||||||
|
const fields = _fields?.map(([name, value]) => ({ name, value, verified_at: null })) ?? [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: pubkey,
|
id: pubkey,
|
||||||
|
|
@ -69,7 +72,7 @@ async function renderAccount(
|
||||||
discoverable: true,
|
discoverable: true,
|
||||||
display_name: name ?? '',
|
display_name: name ?? '',
|
||||||
emojis: renderEmojis(event),
|
emojis: renderEmojis(event),
|
||||||
fields: [],
|
fields,
|
||||||
follow_requests_count: 0,
|
follow_requests_count: 0,
|
||||||
followers_count: event.author_stats?.followers_count ?? 0,
|
followers_count: event.author_stats?.followers_count ?? 0,
|
||||||
following_count: event.author_stats?.following_count ?? 0,
|
following_count: event.author_stats?.following_count ?? 0,
|
||||||
|
|
@ -82,7 +85,7 @@ async function renderAccount(
|
||||||
roles: [],
|
roles: [],
|
||||||
source: opts.withSource
|
source: opts.withSource
|
||||||
? {
|
? {
|
||||||
fields: [],
|
fields,
|
||||||
language: '',
|
language: '',
|
||||||
note: about || '',
|
note: about || '',
|
||||||
privacy: 'public',
|
privacy: 'public',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue