Merge branch 'instance' into 'main'

Configurable Instance (partially), implement Pleroma's ConfigDB

See merge request soapbox-pub/ditto!98
This commit is contained in:
Alex Gleason 2024-01-09 04:56:39 +00:00
commit 0914959ee6
7 changed files with 96 additions and 44 deletions

View File

@ -37,10 +37,6 @@ Example:
The sections below describe the `content` field. Some are encrypted and some are not, depending on whether the data should be public. Also, some events are user events, and some are admin events. The sections below describe the `content` field. Some are encrypted and some are not, depending on whether the data should be public. Also, some events are user events, and some are admin events.
### `pub.ditto.blocks` ### `pub.ditto.pleroma.config`
An encrypted array of blocked pubkeys, JSON stringified in `content` and encrypted with `nip04.encrypt`. NIP-04 encrypted JSON array of Pleroma ConfigDB objects. Pleroma admin API endpoints set this config, and Ditto reads from it.
### `pub.ditto.frontendConfig`
JSON data for Pleroma frontends served on `/api/pleroma/frontend_configurations`. Each key contains arbitrary data used by a different frontend.

View File

@ -44,7 +44,7 @@ import { instanceController } from './controllers/api/instance.ts';
import { mediaController } from './controllers/api/media.ts'; import { mediaController } from './controllers/api/media.ts';
import { notificationsController } from './controllers/api/notifications.ts'; import { notificationsController } from './controllers/api/notifications.ts';
import { createTokenController, oauthAuthorizeController, oauthController } from './controllers/api/oauth.ts'; import { createTokenController, oauthAuthorizeController, oauthController } from './controllers/api/oauth.ts';
import { frontendConfigController, updateConfigController } from './controllers/api/pleroma.ts'; import { configController, frontendConfigController, updateConfigController } from './controllers/api/pleroma.ts';
import { preferencesController } from './controllers/api/preferences.ts'; import { preferencesController } from './controllers/api/preferences.ts';
import { relayController } from './controllers/nostr/relay.ts'; import { relayController } from './controllers/nostr/relay.ts';
import { searchController } from './controllers/api/search.ts'; import { searchController } from './controllers/api/search.ts';
@ -187,6 +187,7 @@ app.get('/api/v1/bookmarks', requirePubkey, bookmarksController);
app.get('/api/v1/blocks', requirePubkey, blocksController); app.get('/api/v1/blocks', requirePubkey, blocksController);
app.get('/api/v1/admin/accounts', adminAccountsController); app.get('/api/v1/admin/accounts', adminAccountsController);
app.get('/api/v1/pleroma/admin/config', requireRole('admin'), configController);
app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController); app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController);
// Not (yet) implemented. // Not (yet) implemented.

View File

@ -58,10 +58,6 @@ const Conf = {
get postCharLimit() { get postCharLimit() {
return Number(Deno.env.get('POST_CHAR_LIMIT') || 5000); return Number(Deno.env.get('POST_CHAR_LIMIT') || 5000);
}, },
/** Admin contact to expose through various endpoints. This information is public. */
get adminEmail() {
return Deno.env.get('ADMIN_EMAIL') || 'webmaster@localhost';
},
/** S3 media storage configuration. */ /** S3 media storage configuration. */
s3: { s3: {
get endPoint() { get endPoint() {

View File

@ -1,17 +1,22 @@
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { jsonServerMetaSchema } from '@/schemas/nostr.ts';
import { eventsDB } from '@/storages.ts';
const instanceController: AppController = (c) => { const instanceController: AppController = async (c) => {
const { host, protocol } = Conf.url; const { host, protocol } = Conf.url;
const [event] = await eventsDB.filter([{ kinds: [0], authors: [Conf.pubkey], limit: 1 }]);
const meta = jsonServerMetaSchema.parse(event?.content);
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */ /** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
return c.json({ return c.json({
uri: host, uri: host,
title: 'Ditto', title: meta.name ?? 'Ditto',
description: 'Nostr and the Fediverse', description: meta.about ?? 'Nostr and the Fediverse',
short_description: 'Nostr and the Fediverse', short_description: meta.tagline ?? 'Nostr and the Fediverse',
registrations: Conf.registrations, registrations: Conf.registrations,
max_toot_chars: Conf.postCharLimit, max_toot_chars: Conf.postCharLimit,
configuration: { configuration: {
@ -49,7 +54,7 @@ const instanceController: AppController = (c) => {
streaming_api: `${wsProtocol}//${host}`, streaming_api: `${wsProtocol}//${host}`,
}, },
version: '0.0.0 (compatible; Ditto 0.0.1)', version: '0.0.0 (compatible; Ditto 0.0.1)',
email: Conf.adminEmail, email: meta.email ?? `postmaster@${host}`,
nostr: { nostr: {
pubkey: Conf.pubkey, pubkey: Conf.pubkey,
relay: `${wsProtocol}//${host}/relay`, relay: `${wsProtocol}//${host}/relay`,

View File

@ -1,49 +1,88 @@
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { decryptAdmin, encryptAdmin } from '@/crypto.ts';
import { z } from '@/deps.ts'; import { z } from '@/deps.ts';
import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts'; import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts';
import { eventsDB } from '@/storages.ts'; import { eventsDB } from '@/storages.ts';
import { createAdminEvent } from '@/utils/api.ts'; import { createAdminEvent } from '@/utils/api.ts';
import { Conf } from '@/config.ts'; import { jsonSchema } from '@/schema.ts';
const frontendConfigController: AppController = async (c) => { const frontendConfigController: AppController = async (c) => {
const [event] = await eventsDB.filter([{ const [event] = await eventsDB.filter([{
kinds: [30078], kinds: [30078],
authors: [Conf.pubkey], authors: [Conf.pubkey],
'#d': ['pub.ditto.frontendConfig'], '#d': ['pub.ditto.pleroma.config'],
limit: 1, limit: 1,
}]); }]);
if (event) { const configs = jsonSchema.pipe(z.array(configSchema)).catch([]).parse(
const data = JSON.parse(event.content); event?.content ? await decryptAdmin(Conf.pubkey, event.content) : '',
return c.json(data); );
}
const frontendConfig = configs.find(({ group, key }) => group === ':pleroma' && key === ':frontend_configurations');
if (frontendConfig) {
const schema = elixirTupleSchema.transform(({ tuple }) => tuple).array();
const data = schema.parse(frontendConfig.value).reduce<Record<string, unknown>>((result, [name, data]) => {
result[name.replace(/^:/, '')] = data;
return result;
}, {});
return c.json(data);
} else {
return c.json({}); return c.json({});
}
};
const configController: AppController = async (c) => {
const { pubkey } = Conf;
const [event] = await eventsDB.filter([{
kinds: [30078],
authors: [pubkey],
'#d': ['pub.ditto.pleroma.config'],
limit: 1,
}]);
const configs = jsonSchema.pipe(z.array(configSchema)).catch([]).parse(
event?.content ? await decryptAdmin(pubkey, event.content) : '',
);
return c.json({ configs, need_reboot: false });
}; };
/** Pleroma admin config controller. */ /** Pleroma admin config controller. */
const updateConfigController: AppController = async (c) => { const updateConfigController: AppController = async (c) => {
const json = await c.req.json(); const { pubkey } = Conf;
const { configs } = z.object({ configs: z.array(configSchema) }).parse(json);
for (const { group, key, value } of configs) { const [event] = await eventsDB.filter([{
if (group === ':pleroma' && key === ':frontend_configurations') { kinds: [30078],
const schema = elixirTupleSchema.transform(({ tuple }) => tuple).array(); authors: [pubkey],
'#d': ['pub.ditto.pleroma.config'],
limit: 1,
}]);
const data = schema.parse(value).reduce<Record<string, unknown>>((result, [name, data]) => { const configs = jsonSchema.pipe(z.array(configSchema)).catch([]).parse(
result[name.replace(/^:/, '')] = data; event?.content ? await decryptAdmin(pubkey, event.content) : '',
return result; );
}, {});
const { configs: newConfigs } = z.object({ configs: z.array(configSchema) }).parse(await c.req.json());
for (const { group, key, value } of newConfigs) {
const index = configs.findIndex((c) => c.group === group && c.key === key);
if (index === -1) {
configs.push({ group, key, value });
} else {
configs[index].value = value;
}
}
await createAdminEvent({ await createAdminEvent({
kind: 30078, kind: 30078,
content: JSON.stringify(data), content: await encryptAdmin(pubkey, JSON.stringify(configs)),
tags: [['d', 'pub.ditto.frontendConfig']], tags: [['d', 'pub.ditto.pleroma.config']],
}, c); }, c);
}
}
return c.json([]); return c.json({ configs: newConfigs, need_reboot: false });
}; };
export { frontendConfigController, updateConfigController }; export { configController, frontendConfigController, updateConfigController };

View File

@ -1,13 +1,18 @@
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts'; import { Conf } from '@/config.ts';
import { jsonServerMetaSchema } from '@/schemas/nostr.ts';
import { eventsDB } from '@/storages.ts';
const relayInfoController: AppController = async (c) => {
const [event] = await eventsDB.filter([{ kinds: [0], authors: [Conf.pubkey], limit: 1 }]);
const meta = jsonServerMetaSchema.parse(event?.content);
const relayInfoController: AppController = (c) => {
return c.json({ return c.json({
name: 'Ditto', name: meta.name ?? 'Ditto',
description: 'Nostr and the Fediverse.', description: meta.about ?? 'Nostr and the Fediverse.',
pubkey: Conf.pubkey, pubkey: Conf.pubkey,
contact: `mailto:${Conf.adminEmail}`, contact: `mailto:${meta.email ?? `postmaster@${Conf.url.host}`}`,
supported_nips: [1, 5, 9, 11, 16, 45, 46, 98], supported_nips: [1, 5, 9, 11, 16, 45, 50, 46, 98],
software: 'Ditto', software: 'Ditto',
version: '0.0.0', version: '0.0.0',
limitation: { limitation: {

View File

@ -85,6 +85,12 @@ const mediaDataSchema = z.object({
width: z.number().int().positive().optional().catch(undefined), width: z.number().int().positive().optional().catch(undefined),
}); });
/** Kind 0 content schema for the Ditto server admin user. */
const serverMetaSchema = metaContentSchema.extend({
tagline: z.string().optional().catch(undefined),
email: z.string().optional().catch(undefined),
});
/** Media data from `"media"` tags. */ /** Media data from `"media"` tags. */
type MediaData = z.infer<typeof mediaDataSchema>; type MediaData = z.infer<typeof mediaDataSchema>;
@ -94,6 +100,9 @@ const jsonMetaContentSchema = jsonSchema.pipe(metaContentSchema).catch({});
/** Parses media data from a JSON string. */ /** Parses media data from a JSON string. */
const jsonMediaDataSchema = jsonSchema.pipe(mediaDataSchema).catch({}); const jsonMediaDataSchema = jsonSchema.pipe(mediaDataSchema).catch({});
/** Parses server admin meta from a JSON string. */
const jsonServerMetaSchema = jsonSchema.pipe(serverMetaSchema).catch({});
/** NIP-11 Relay Information Document. */ /** NIP-11 Relay Information Document. */
const relayInfoDocSchema = z.object({ const relayInfoDocSchema = z.object({
name: z.string().transform((val) => val.slice(0, 30)).optional().catch(undefined), name: z.string().transform((val) => val.slice(0, 30)).optional().catch(undefined),
@ -130,6 +139,7 @@ export {
filterSchema, filterSchema,
jsonMediaDataSchema, jsonMediaDataSchema,
jsonMetaContentSchema, jsonMetaContentSchema,
jsonServerMetaSchema,
type MediaData, type MediaData,
mediaDataSchema, mediaDataSchema,
metaContentSchema, metaContentSchema,