Merge branch 'main' into mint-cashu

Conflicts:
	packages/ditto/controllers/api/cashu.ts
This commit is contained in:
P. Reis 2025-02-15 21:59:12 -03:00
commit 8cba937962
47 changed files with 896 additions and 584 deletions

View file

@ -1,5 +1,7 @@
{ {
"workspace": [ "workspace": [
"./packages/api",
"./packages/conf",
"./packages/ditto" "./packages/ditto"
], ],
"tasks": { "tasks": {

7
packages/api/deno.json Normal file
View file

@ -0,0 +1,7 @@
{
"name": "@ditto/api",
"version": "1.1.0",
"exports": {
"./middleware": "./middleware/mod.ts"
}
}

View file

@ -0,0 +1,19 @@
import { Hono } from '@hono/hono';
import { assertEquals } from '@std/assert';
import { confMw } from './confMw.ts';
Deno.test('confMw', async () => {
const env = new Map([
['DITTO_NSEC', 'nsec19shyxpuzd0cq2p5078fwnws7tyykypud6z205fzhlmlrs2vpz6hs83zwkw'],
]);
const app = new Hono();
app.get('/', confMw(env), (c) => c.text(c.var.conf.pubkey));
const response = await app.request('/');
const body = await response.text();
assertEquals(body, '1ba0c5ed1bbbf3b7eb0d7843ba16836a0201ea68a76bafcba507358c45911ff6');
});

View file

@ -0,0 +1,15 @@
import { DittoConf } from '@ditto/conf';
import type { MiddlewareHandler } from '@hono/hono';
/** Set Ditto config. */
export function confMw(
env: { get(key: string): string | undefined },
): MiddlewareHandler<{ Variables: { conf: DittoConf } }> {
const conf = new DittoConf(env);
return async (c, next) => {
c.set('conf', conf);
await next();
};
}

View file

@ -0,0 +1,22 @@
import { Hono } from '@hono/hono';
import { assertEquals } from '@std/assert';
import { confMw } from './confMw.ts';
import { confRequiredMw } from './confRequiredMw.ts';
Deno.test('confRequiredMw', async (t) => {
const app = new Hono();
app.get('/without', confRequiredMw, (c) => c.text('ok'));
app.get('/with', confMw(new Map()), confRequiredMw, (c) => c.text('ok'));
await t.step('without conf returns 500', async () => {
const response = await app.request('/without');
assertEquals(response.status, 500);
});
await t.step('with conf returns 200', async () => {
const response = await app.request('/with');
assertEquals(response.status, 200);
});
});

View file

@ -0,0 +1,15 @@
import { HTTPException } from '@hono/hono/http-exception';
import type { DittoConf } from '@ditto/conf';
import type { MiddlewareHandler } from '@hono/hono';
/** Throws an error if conf isn't set. */
export const confRequiredMw: MiddlewareHandler<{ Variables: { conf: DittoConf } }> = async (c, next) => {
const { conf } = c.var;
if (!conf) {
throw new HTTPException(500, { message: 'Ditto config not set in request.' });
}
await next();
};

View file

@ -0,0 +1,2 @@
export { confMw } from './confMw.ts';
export { confRequiredMw } from './confRequiredMw.ts';

View file

@ -0,0 +1,32 @@
import { assertEquals, assertThrows } from '@std/assert';
import { DittoConf } from './DittoConf.ts';
Deno.test('DittoConfig', async (t) => {
const env = new Map<string, string>([
['DITTO_NSEC', 'nsec19shyxpuzd0cq2p5078fwnws7tyykypud6z205fzhlmlrs2vpz6hs83zwkw'],
]);
const config = new DittoConf(env);
await t.step('nsec', () => {
assertEquals(config.nsec, 'nsec19shyxpuzd0cq2p5078fwnws7tyykypud6z205fzhlmlrs2vpz6hs83zwkw');
});
await t.step('pubkey', () => {
assertEquals(config.pubkey, '1ba0c5ed1bbbf3b7eb0d7843ba16836a0201ea68a76bafcba507358c45911ff6');
});
});
Deno.test('DittoConfig defaults', async (t) => {
const env = new Map<string, string>();
const config = new DittoConf(env);
await t.step('nsec throws', () => {
assertThrows(() => config.nsec);
});
await t.step('port', () => {
assertEquals(config.port, 4036);
});
});

464
packages/conf/DittoConf.ts Normal file
View file

@ -0,0 +1,464 @@
import os from 'node:os';
import ISO6391, { type LanguageCode } from 'iso-639-1';
import { getPublicKey, nip19 } from 'nostr-tools';
import { decodeBase64 } from '@std/encoding/base64';
import { encodeBase64Url } from '@std/encoding/base64url';
import { getEcdsaPublicKey } from './utils/crypto.ts';
import { optionalBooleanSchema, optionalNumberSchema } from './utils/schema.ts';
import { mergeURLPath } from './utils/url.ts';
/** Ditto application-wide configuration. */
export class DittoConf {
constructor(private env: { get(key: string): string | undefined }) {}
/** Cached parsed admin pubkey value. */
private _pubkey: string | undefined;
/** Cached parsed VAPID public key value. */
private _vapidPublicKey: Promise<string | undefined> | undefined;
/** Ditto admin secret key in nip19 format. This is the way it's configured by an admin. */
get nsec(): `nsec1${string}` {
const value = this.env.get('DITTO_NSEC');
if (!value) {
throw new Error('Missing DITTO_NSEC');
}
if (!value.startsWith('nsec1')) {
throw new Error('Invalid DITTO_NSEC');
}
return value as `nsec1${string}`;
}
/** Ditto admin secret key in hex format. */
get seckey(): Uint8Array {
return nip19.decode(this.nsec).data;
}
/** Ditto admin public key in hex format. */
get pubkey(): string {
if (!this._pubkey) {
this._pubkey = getPublicKey(this.seckey);
}
return this._pubkey;
}
/** Port to use when serving the HTTP server. */
get port(): number {
return parseInt(this.env.get('PORT') || '4036');
}
/** IP addresses not affected by rate limiting. */
get ipWhitelist(): string[] {
return this.env.get('IP_WHITELIST')?.split(',') || [];
}
/** Relay URL to the Ditto server's relay. */
get relay(): `wss://${string}` | `ws://${string}` {
const { protocol, host } = this.url;
return `${protocol === 'https:' ? 'wss:' : 'ws:'}//${host}/relay`;
}
/** Relay to use for NIP-50 `search` queries. */
get searchRelay(): string | undefined {
return this.env.get('SEARCH_RELAY');
}
/** Origin of the Ditto server, including the protocol and port. */
get localDomain(): string {
return this.env.get('LOCAL_DOMAIN') || `http://localhost:${this.port}`;
}
/** Link to an external nostr viewer. */
get externalDomain(): string {
return this.env.get('NOSTR_EXTERNAL') || 'https://njump.me';
}
/** Get a link to a nip19-encoded entity in the configured external viewer. */
external(path: string): string {
return new URL(path, this.externalDomain).toString();
}
/**
* Heroku-style database URL. This is used in production to connect to the
* database.
*
* Follows the format:
*
* ```txt
* protocol://username:password@host:port/database_name
* ```
*/
get databaseUrl(): string {
return this.env.get('DATABASE_URL') ?? 'file://data/pgdata';
}
/** PGlite debug level. 0 disables logging. */
get pgliteDebug(): 0 | 1 | 2 | 3 | 4 | 5 {
return Number(this.env.get('PGLITE_DEBUG') || 0) as 0 | 1 | 2 | 3 | 4 | 5;
}
get vapidPublicKey(): Promise<string | undefined> {
if (!this._vapidPublicKey) {
this._vapidPublicKey = (async () => {
const keys = await this.vapidKeys;
if (keys) {
const { publicKey } = keys;
const bytes = await crypto.subtle.exportKey('raw', publicKey);
return encodeBase64Url(bytes);
}
})();
}
return this._vapidPublicKey;
}
get vapidKeys(): Promise<CryptoKeyPair | undefined> {
return (async () => {
const encoded = this.env.get('VAPID_PRIVATE_KEY');
if (!encoded) {
return;
}
const keyData = decodeBase64(encoded);
const privateKey = await crypto.subtle.importKey(
'pkcs8',
keyData,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign'],
);
const publicKey = await getEcdsaPublicKey(privateKey, true);
return { privateKey, publicKey };
})();
}
get db(): { timeouts: { default: number; relay: number; timelines: number } } {
const env = this.env;
return {
/** Database query timeout configurations. */
timeouts: {
/** Default query timeout when another setting isn't more specific. */
get default(): number {
return Number(env.get('DB_TIMEOUT_DEFAULT') || 5_000);
},
/** Timeout used for queries made through the Nostr relay. */
get relay(): number {
return Number(env.get('DB_TIMEOUT_RELAY') || 1_000);
},
/** Timeout used for timelines such as home, notifications, hashtag, etc. */
get timelines(): number {
return Number(env.get('DB_TIMEOUT_TIMELINES') || 15_000);
},
},
};
}
/** Time-to-live for captchas in milliseconds. */
get captchaTTL(): number {
return Number(this.env.get('CAPTCHA_TTL') || 5 * 60 * 1000);
}
/** Character limit to enforce for posts made through Mastodon API. */
get postCharLimit(): number {
return Number(this.env.get('POST_CHAR_LIMIT') || 5000);
}
/** S3 media storage configuration. */
get s3(): {
endPoint?: string;
region?: string;
accessKey?: string;
secretKey?: string;
bucket?: string;
pathStyle?: boolean;
port?: number;
sessionToken?: string;
useSSL?: boolean;
} {
const env = this.env;
return {
get endPoint(): string | undefined {
return env.get('S3_ENDPOINT');
},
get region(): string | undefined {
return env.get('S3_REGION');
},
get accessKey(): string | undefined {
return env.get('S3_ACCESS_KEY');
},
get secretKey(): string | undefined {
return env.get('S3_SECRET_KEY');
},
get bucket(): string | undefined {
return env.get('S3_BUCKET');
},
get pathStyle(): boolean | undefined {
return optionalBooleanSchema.parse(env.get('S3_PATH_STYLE'));
},
get port(): number | undefined {
return optionalNumberSchema.parse(env.get('S3_PORT'));
},
get sessionToken(): string | undefined {
return env.get('S3_SESSION_TOKEN');
},
get useSSL(): boolean | undefined {
return optionalBooleanSchema.parse(env.get('S3_USE_SSL'));
},
};
}
/** IPFS uploader configuration. */
get ipfs(): { apiUrl: string } {
const env = this.env;
return {
/** Base URL for private IPFS API calls. */
get apiUrl(): string {
return env.get('IPFS_API_URL') || 'http://localhost:5001';
},
};
}
/** nostr.build API endpoint when the `nostrbuild` uploader is used. */
get nostrbuildEndpoint(): string {
return this.env.get('NOSTRBUILD_ENDPOINT') || 'https://nostr.build/api/v2/upload/files';
}
/** Default Blossom servers to use when the `blossom` uploader is set. */
get blossomServers(): string[] {
return this.env.get('BLOSSOM_SERVERS')?.split(',') || ['https://blossom.primal.net/'];
}
/** Module to upload files with. */
get uploader(): string | undefined {
return this.env.get('DITTO_UPLOADER');
}
/** Location to use for local uploads. */
get uploadsDir(): string {
return this.env.get('UPLOADS_DIR') || 'data/uploads';
}
/** Media base URL for uploads. */
get mediaDomain(): string {
const value = this.env.get('MEDIA_DOMAIN');
if (!value) {
const url = this.url;
url.host = `media.${url.host}`;
return url.toString();
}
return value;
}
/**
* Whether to analyze media metadata with [blurhash](https://www.npmjs.com/package/blurhash) and [sharp](https://www.npmjs.com/package/sharp).
* This is prone to security vulnerabilities, which is why it's not enabled by default.
*/
get mediaAnalyze(): boolean {
return optionalBooleanSchema.parse(this.env.get('MEDIA_ANALYZE')) ?? false;
}
/** Max upload size for files in number of bytes. Default 100MiB. */
get maxUploadSize(): number {
return Number(this.env.get('MAX_UPLOAD_SIZE') || 100 * 1024 * 1024);
}
/** Usernames that regular users cannot sign up with. */
get forbiddenUsernames(): string[] {
return this.env.get('FORBIDDEN_USERNAMES')?.split(',') || [
'_',
'admin',
'administrator',
'root',
'sysadmin',
'system',
];
}
/** Domain of the Ditto server as a `URL` object, for easily grabbing the `hostname`, etc. */
get url(): URL {
return new URL(this.localDomain);
}
/** Merges the path with the localDomain. */
local(path: string): string {
return mergeURLPath(this.localDomain, path);
}
/** URL to send Sentry errors to. */
get sentryDsn(): string | undefined {
return this.env.get('SENTRY_DSN');
}
/** Postgres settings. */
get pg(): { poolSize: number } {
const env = this.env;
return {
/** Number of connections to use in the pool. */
get poolSize(): number {
return Number(env.get('PG_POOL_SIZE') ?? 20);
},
};
}
/** Whether to enable requesting events from known relays. */
get firehoseEnabled(): boolean {
return optionalBooleanSchema.parse(this.env.get('FIREHOSE_ENABLED')) ?? true;
}
/** Number of events the firehose is allowed to process at one time before they have to wait in a queue. */
get firehoseConcurrency(): number {
return Math.ceil(Number(this.env.get('FIREHOSE_CONCURRENCY') ?? 1));
}
/** Nostr event kinds of events to listen for on the firehose. */
get firehoseKinds(): number[] {
return (this.env.get('FIREHOSE_KINDS') ?? '0, 1, 3, 5, 6, 7, 20, 9735, 10002')
.split(/[, ]+/g)
.map(Number);
}
/**
* Whether Ditto should subscribe to Nostr events from the Postgres database itself.
* This would make Nostr events inserted directly into Postgres available to the streaming API and relay.
*/
get notifyEnabled(): boolean {
return optionalBooleanSchema.parse(this.env.get('NOTIFY_ENABLED')) ?? true;
}
/** Whether to enable Ditto cron jobs. */
get cronEnabled(): boolean {
return optionalBooleanSchema.parse(this.env.get('CRON_ENABLED')) ?? true;
}
/** User-Agent to use when fetching link previews. Pretend to be Facebook by default. */
get fetchUserAgent(): string {
return this.env.get('DITTO_FETCH_USER_AGENT') ?? 'facebookexternalhit';
}
/** Path to the custom policy module. Must be an absolute path, https:, npm:, or jsr: URI. */
get policy(): string {
return this.env.get('DITTO_POLICY') || new URL('../data/policy.ts', import.meta.url).pathname;
}
/** Absolute path to the data directory used by Ditto. */
get dataDir(): string {
return this.env.get('DITTO_DATA_DIR') || new URL('../data', import.meta.url).pathname;
}
/** Absolute path of the Deno directory. */
get denoDir(): string {
return this.env.get('DENO_DIR') || `${os.userInfo().homedir}/.cache/deno`;
}
/** Whether zap splits should be enabled. */
get zapSplitsEnabled(): boolean {
return optionalBooleanSchema.parse(this.env.get('ZAP_SPLITS_ENABLED')) ?? false;
}
/** Languages this server wishes to highlight. Used when querying trends.*/
get preferredLanguages(): LanguageCode[] | undefined {
return this.env.get('DITTO_LANGUAGES')?.split(',')?.filter(ISO6391.validate);
}
/** Mints to be displayed in the UI when the user decides to create a wallet.*/
get cashuMints(): string[] {
return this.env.get('CASHU_MINTS')?.split(',') ?? [];
}
/** Translation provider used to translate posts. */
get translationProvider(): string | undefined {
return this.env.get('TRANSLATION_PROVIDER');
}
/** DeepL URL endpoint. */
get deeplBaseUrl(): string | undefined {
return this.env.get('DEEPL_BASE_URL');
}
/** DeepL API KEY. */
get deeplApiKey(): string | undefined {
return this.env.get('DEEPL_API_KEY');
}
/** LibreTranslate URL endpoint. */
get libretranslateBaseUrl(): string | undefined {
return this.env.get('LIBRETRANSLATE_BASE_URL');
}
/** LibreTranslate API KEY. */
get libretranslateApiKey(): string | undefined {
return this.env.get('LIBRETRANSLATE_API_KEY');
}
/** Cache settings. */
get caches(): {
nip05: { max: number; ttl: number };
favicon: { max: number; ttl: number };
linkPreview: { max: number; ttl: number };
translation: { max: number; ttl: number };
} {
const env = this.env;
return {
/** NIP-05 cache settings. */
get nip05(): { max: number; ttl: number } {
return {
max: Number(env.get('DITTO_CACHE_NIP05_MAX') || 3000),
ttl: Number(env.get('DITTO_CACHE_NIP05_TTL') || 1 * 60 * 60 * 1000),
};
},
/** Favicon cache settings. */
get favicon(): { max: number; ttl: number } {
return {
max: Number(env.get('DITTO_CACHE_FAVICON_MAX') || 500),
ttl: Number(env.get('DITTO_CACHE_FAVICON_TTL') || 1 * 60 * 60 * 1000),
};
},
/** Link preview cache settings. */
get linkPreview(): { max: number; ttl: number } {
return {
max: Number(env.get('DITTO_CACHE_LINK_PREVIEW_MAX') || 3000),
ttl: Number(env.get('DITTO_CACHE_LINK_PREVIEW_TTL') || 12 * 60 * 60 * 1000),
};
},
/** Translation cache settings. */
get translation(): { max: number; ttl: number } {
return {
max: Number(env.get('DITTO_CACHE_TRANSLATION_MAX') || 1000),
ttl: Number(env.get('DITTO_CACHE_TRANSLATION_TTL') || 6 * 60 * 60 * 1000),
};
},
};
}
/** Custom profile fields configuration. */
get profileFields(): { maxFields: number; nameLength: number; valueLength: number } {
const env = this.env;
return {
get maxFields(): number {
return Number(env.get('PROFILE_FIELDS_MAX_FIELDS') || 10);
},
get nameLength(): number {
return Number(env.get('PROFILE_FIELDS_NAME_LENGTH') || 255);
},
get valueLength(): number {
return Number(env.get('PROFILE_FIELDS_VALUE_LENGTH') || 2047);
},
};
}
/** Maximum time between events before a streak is broken, *in seconds*. */
get streakWindow(): number {
return Number(this.env.get('STREAK_WINDOW') || 129600);
}
}

7
packages/conf/deno.json Normal file
View file

@ -0,0 +1,7 @@
{
"name": "@ditto/conf",
"version": "1.1.0",
"exports": {
".": "./mod.ts"
}
}

1
packages/conf/mod.ts Normal file
View file

@ -0,0 +1 @@
export { DittoConf } from './DittoConf.ts';

View file

@ -1,6 +1,6 @@
import { assertEquals } from '@std/assert'; import { assertEquals } from '@std/assert';
import { getEcdsaPublicKey } from '@/utils/crypto.ts'; import { getEcdsaPublicKey } from './crypto.ts';
Deno.test('getEcdsaPublicKey', async () => { Deno.test('getEcdsaPublicKey', async () => {
const { publicKey, privateKey } = await crypto.subtle.generateKey( const { publicKey, privateKey } = await crypto.subtle.generateKey(

View file

@ -0,0 +1,17 @@
import { assertEquals, assertThrows } from '@std/assert';
import { optionalBooleanSchema, optionalNumberSchema } from './schema.ts';
Deno.test('optionalBooleanSchema', () => {
assertEquals(optionalBooleanSchema.parse('true'), true);
assertEquals(optionalBooleanSchema.parse('false'), false);
assertEquals(optionalBooleanSchema.parse(undefined), undefined);
assertThrows(() => optionalBooleanSchema.parse('invalid'));
});
Deno.test('optionalNumberSchema', () => {
assertEquals(optionalNumberSchema.parse('123'), 123);
assertEquals(optionalNumberSchema.parse('invalid'), NaN); // maybe this should throw?
assertEquals(optionalNumberSchema.parse(undefined), undefined);
});

View file

@ -0,0 +1,11 @@
import { z } from 'zod';
export const optionalBooleanSchema = z
.enum(['true', 'false'])
.optional()
.transform((value) => value !== undefined ? value === 'true' : undefined);
export const optionalNumberSchema = z
.string()
.optional()
.transform((value) => value !== undefined ? Number(value) : undefined);

View file

@ -0,0 +1,9 @@
import { assertEquals } from '@std/assert';
import { mergeURLPath } from './url.ts';
Deno.test('mergeURLPath', () => {
assertEquals(mergeURLPath('https://mario.com', '/path'), 'https://mario.com/path');
assertEquals(mergeURLPath('https://mario.com', 'https://luigi.com/path'), 'https://mario.com/path');
assertEquals(mergeURLPath('https://mario.com', 'https://luigi.com/path?q=1'), 'https://mario.com/path?q=1');
});

View file

@ -0,0 +1,23 @@
/**
* Produce a URL whose origin is guaranteed to be the same as the base URL.
* The path is either an absolute path (starting with `/`), or a full URL. In either case, only its path is used.
*/
export function mergeURLPath(
/** Base URL. Result is guaranteed to use this URL's origin. */
base: string,
/** Either an absolute path (starting with `/`), or a full URL. If a full URL, its path */
path: string,
): string {
const url = new URL(
path.startsWith('/') ? path : new URL(path).pathname,
base,
);
if (!path.startsWith('/')) {
// Copy query parameters from the original URL to the new URL
const originalUrl = new URL(path);
url.search = originalUrl.search;
}
return url.toString();
}

View file

@ -1,3 +1,5 @@
import { confMw } from '@ditto/api/middleware';
import { type DittoConf } from '@ditto/conf';
import { type Context, Env as HonoEnv, Handler, Hono, Input as HonoInput, MiddlewareHandler } from '@hono/hono'; import { type Context, Env as HonoEnv, Handler, Hono, Input as HonoInput, MiddlewareHandler } from '@hono/hono';
import { every } from '@hono/hono/combine'; import { every } from '@hono/hono/combine';
import { cors } from '@hono/hono/cors'; import { cors } from '@hono/hono/cors';
@ -149,6 +151,7 @@ import { logiMiddleware } from '@/middleware/logiMiddleware.ts';
export interface AppEnv extends HonoEnv { export interface AppEnv extends HonoEnv {
Variables: { Variables: {
conf: DittoConf;
/** Signer to get the logged-in user's pubkey, relays, and to sign events, or `undefined` if the user isn't logged in. */ /** Signer to get the logged-in user's pubkey, relays, and to sign events, or `undefined` if the user isn't logged in. */
signer?: NostrSigner; signer?: NostrSigner;
/** Uploader for the user to upload files. */ /** Uploader for the user to upload files. */
@ -180,7 +183,7 @@ const publicFiles = serveStatic({ root: './public/' });
/** Static files provided by the Ditto repo, checked into git. */ /** Static files provided by the Ditto repo, checked into git. */
const staticFiles = serveStatic({ root: new URL('./static/', import.meta.url).pathname }); const staticFiles = serveStatic({ root: new URL('./static/', import.meta.url).pathname });
app.use('*', cacheControlMiddleware({ noStore: true })); app.use(confMw(Deno.env), cacheControlMiddleware({ noStore: true }));
const ratelimit = every( const ratelimit = every(
rateLimitMiddleware(30, Time.seconds(5), false), rateLimitMiddleware(30, Time.seconds(5), false),
@ -196,7 +199,6 @@ app.get('/api/v1/streaming', metricsMiddleware, ratelimit, streamingController);
app.get('/relay', metricsMiddleware, ratelimit, relayController); app.get('/relay', metricsMiddleware, ratelimit, relayController);
app.use( app.use(
'*',
cspMiddleware(), cspMiddleware(),
cors({ origin: '*', exposeHeaders: ['link'] }), cors({ origin: '*', exposeHeaders: ['link'] }),
signerMiddleware, signerMiddleware,

View file

@ -1,395 +1,4 @@
import os from 'node:os'; import { DittoConf } from '@ditto/conf';
import ISO6391, { LanguageCode } from 'iso-639-1';
import { getPublicKey, nip19 } from 'nostr-tools';
import { z } from 'zod';
import { decodeBase64 } from '@std/encoding/base64';
import { encodeBase64Url } from '@std/encoding/base64url';
import { getEcdsaPublicKey } from '@/utils/crypto.ts'; /** @deprecated Use middleware to set/get the config instead. */
export const Conf = new DittoConf(Deno.env);
/** Application-wide configuration. */
class Conf {
private static _pubkey: string | undefined;
/** Ditto admin secret key in nip19 format. This is the way it's configured by an admin. */
static get nsec(): `nsec1${string}` {
const value = Deno.env.get('DITTO_NSEC');
if (!value) {
throw new Error('Missing DITTO_NSEC');
}
if (!value.startsWith('nsec1')) {
throw new Error('Invalid DITTO_NSEC');
}
return value as `nsec1${string}`;
}
/** Ditto admin secret key in hex format. */
static get seckey(): Uint8Array {
return nip19.decode(Conf.nsec).data;
}
/** Ditto admin public key in hex format. */
static get pubkey(): string {
if (!this._pubkey) {
this._pubkey = getPublicKey(Conf.seckey);
}
return this._pubkey;
}
/** Port to use when serving the HTTP server. */
static get port(): number {
return parseInt(Deno.env.get('PORT') || '4036');
}
/** IP addresses not affected by rate limiting. */
static get ipWhitelist(): string[] {
return Deno.env.get('IP_WHITELIST')?.split(',') || [];
}
/** Relay URL to the Ditto server's relay. */
static get relay(): `wss://${string}` | `ws://${string}` {
const { protocol, host } = Conf.url;
return `${protocol === 'https:' ? 'wss:' : 'ws:'}//${host}/relay`;
}
/** Relay to use for NIP-50 `search` queries. */
static get searchRelay(): string | undefined {
return Deno.env.get('SEARCH_RELAY');
}
/** Origin of the Ditto server, including the protocol and port. */
static get localDomain(): string {
return Deno.env.get('LOCAL_DOMAIN') || `http://localhost:${Conf.port}`;
}
/** Link to an external nostr viewer. */
static get externalDomain(): string {
return Deno.env.get('NOSTR_EXTERNAL') || 'https://njump.me';
}
/** Get a link to a nip19-encoded entity in the configured external viewer. */
static external(path: string) {
return new URL(path, Conf.externalDomain).toString();
}
/**
* Heroku-style database URL. This is used in production to connect to the
* database.
*
* Follows the format:
*
* ```txt
* protocol://username:password@host:port/database_name
* ```
*/
static get databaseUrl(): string {
return Deno.env.get('DATABASE_URL') ?? 'file://data/pgdata';
}
/** PGlite debug level. 0 disables logging. */
static get pgliteDebug(): 0 | 1 | 2 | 3 | 4 | 5 {
return Number(Deno.env.get('PGLITE_DEBUG') || 0) as 0 | 1 | 2 | 3 | 4 | 5;
}
private static _vapidPublicKey: Promise<string | undefined> | undefined;
static get vapidPublicKey(): Promise<string | undefined> {
if (!this._vapidPublicKey) {
this._vapidPublicKey = (async () => {
const keys = await Conf.vapidKeys;
if (keys) {
const { publicKey } = keys;
const bytes = await crypto.subtle.exportKey('raw', publicKey);
return encodeBase64Url(bytes);
}
})();
}
return this._vapidPublicKey;
}
static get vapidKeys(): Promise<CryptoKeyPair | undefined> {
return (async () => {
const encoded = Deno.env.get('VAPID_PRIVATE_KEY');
if (!encoded) {
return;
}
const keyData = decodeBase64(encoded);
const privateKey = await crypto.subtle.importKey(
'pkcs8',
keyData,
{ name: 'ECDSA', namedCurve: 'P-256' },
true,
['sign'],
);
const publicKey = await getEcdsaPublicKey(privateKey, true);
return { privateKey, publicKey };
})();
}
static db = {
/** Database query timeout configurations. */
timeouts: {
/** Default query timeout when another setting isn't more specific. */
get default(): number {
return Number(Deno.env.get('DB_TIMEOUT_DEFAULT') || 5_000);
},
/** Timeout used for queries made through the Nostr relay. */
get relay(): number {
return Number(Deno.env.get('DB_TIMEOUT_RELAY') || 1_000);
},
/** Timeout used for timelines such as home, notifications, hashtag, etc. */
get timelines(): number {
return Number(Deno.env.get('DB_TIMEOUT_TIMELINES') || 15_000);
},
},
};
/** Time-to-live for captchas in milliseconds. */
static get captchaTTL(): number {
return Number(Deno.env.get('CAPTCHA_TTL') || 5 * 60 * 1000);
}
/** Character limit to enforce for posts made through Mastodon API. */
static get postCharLimit(): number {
return Number(Deno.env.get('POST_CHAR_LIMIT') || 5000);
}
/** S3 media storage configuration. */
static s3 = {
get endPoint(): string | undefined {
return Deno.env.get('S3_ENDPOINT');
},
get region(): string | undefined {
return Deno.env.get('S3_REGION');
},
get accessKey(): string | undefined {
return Deno.env.get('S3_ACCESS_KEY');
},
get secretKey(): string | undefined {
return Deno.env.get('S3_SECRET_KEY');
},
get bucket(): string | undefined {
return Deno.env.get('S3_BUCKET');
},
get pathStyle(): boolean | undefined {
return optionalBooleanSchema.parse(Deno.env.get('S3_PATH_STYLE'));
},
get port(): number | undefined {
return optionalNumberSchema.parse(Deno.env.get('S3_PORT'));
},
get sessionToken(): string | undefined {
return Deno.env.get('S3_SESSION_TOKEN');
},
get useSSL(): boolean | undefined {
return optionalBooleanSchema.parse(Deno.env.get('S3_USE_SSL'));
},
};
/** IPFS uploader configuration. */
static ipfs = {
/** Base URL for private IPFS API calls. */
get apiUrl(): string {
return Deno.env.get('IPFS_API_URL') || 'http://localhost:5001';
},
};
/** nostr.build API endpoint when the `nostrbuild` uploader is used. */
static get nostrbuildEndpoint(): string {
return Deno.env.get('NOSTRBUILD_ENDPOINT') || 'https://nostr.build/api/v2/upload/files';
}
/** Default Blossom servers to use when the `blossom` uploader is set. */
static get blossomServers(): string[] {
return Deno.env.get('BLOSSOM_SERVERS')?.split(',') || ['https://blossom.primal.net/'];
}
/** Module to upload files with. */
static get uploader(): string | undefined {
return Deno.env.get('DITTO_UPLOADER');
}
/** Location to use for local uploads. */
static get uploadsDir(): string {
return Deno.env.get('UPLOADS_DIR') || 'data/uploads';
}
/** Media base URL for uploads. */
static get mediaDomain(): string {
const value = Deno.env.get('MEDIA_DOMAIN');
if (!value) {
const url = Conf.url;
url.host = `media.${url.host}`;
return url.toString();
}
return value;
}
/**
* Whether to analyze media metadata with [blurhash](https://www.npmjs.com/package/blurhash) and [sharp](https://www.npmjs.com/package/sharp).
* This is prone to security vulnerabilities, which is why it's not enabled by default.
*/
static get mediaAnalyze(): boolean {
return optionalBooleanSchema.parse(Deno.env.get('MEDIA_ANALYZE')) ?? false;
}
/** Max upload size for files in number of bytes. Default 100MiB. */
static get maxUploadSize(): number {
return Number(Deno.env.get('MAX_UPLOAD_SIZE') || 100 * 1024 * 1024);
}
/** Usernames that regular users cannot sign up with. */
static get forbiddenUsernames(): string[] {
return Deno.env.get('FORBIDDEN_USERNAMES')?.split(',') || [
'_',
'admin',
'administrator',
'root',
'sysadmin',
'system',
];
}
/** Domain of the Ditto server as a `URL` object, for easily grabbing the `hostname`, etc. */
static get url(): URL {
return new URL(Conf.localDomain);
}
/** Merges the path with the localDomain. */
static local(path: string): string {
return mergePaths(Conf.localDomain, path);
}
/** URL to send Sentry errors to. */
static get sentryDsn(): string | undefined {
return Deno.env.get('SENTRY_DSN');
}
/** Postgres settings. */
static pg = {
/** Number of connections to use in the pool. */
get poolSize(): number {
return Number(Deno.env.get('PG_POOL_SIZE') ?? 20);
},
};
/** Whether to enable requesting events from known relays. */
static get firehoseEnabled(): boolean {
return optionalBooleanSchema.parse(Deno.env.get('FIREHOSE_ENABLED')) ?? true;
}
/** Number of events the firehose is allowed to process at one time before they have to wait in a queue. */
static get firehoseConcurrency(): number {
return Math.ceil(Number(Deno.env.get('FIREHOSE_CONCURRENCY') ?? 1));
}
/** Nostr event kinds of events to listen for on the firehose. */
static get firehoseKinds(): number[] {
return (Deno.env.get('FIREHOSE_KINDS') ?? '0, 1, 3, 5, 6, 7, 20, 9735, 10002')
.split(/[, ]+/g)
.map(Number);
}
/**
* Whether Ditto should subscribe to Nostr events from the Postgres database itself.
* This would make Nostr events inserted directly into Postgres available to the streaming API and relay.
*/
static get notifyEnabled(): boolean {
return optionalBooleanSchema.parse(Deno.env.get('NOTIFY_ENABLED')) ?? true;
}
/** Whether to enable Ditto cron jobs. */
static get cronEnabled(): boolean {
return optionalBooleanSchema.parse(Deno.env.get('CRON_ENABLED')) ?? true;
}
/** User-Agent to use when fetching link previews. Pretend to be Facebook by default. */
static get fetchUserAgent(): string {
return Deno.env.get('DITTO_FETCH_USER_AGENT') ?? 'facebookexternalhit';
}
/** Path to the custom policy module. Must be an absolute path, https:, npm:, or jsr: URI. */
static get policy(): string {
return Deno.env.get('DITTO_POLICY') || new URL('../data/policy.ts', import.meta.url).pathname;
}
/** Absolute path to the data directory used by Ditto. */
static get dataDir(): string {
return Deno.env.get('DITTO_DATA_DIR') || new URL('../data', import.meta.url).pathname;
}
/** Absolute path of the Deno directory. */
static get denoDir(): string {
return Deno.env.get('DENO_DIR') || `${os.userInfo().homedir}/.cache/deno`;
}
/** Whether zap splits should be enabled. */
static get zapSplitsEnabled(): boolean {
return optionalBooleanSchema.parse(Deno.env.get('ZAP_SPLITS_ENABLED')) ?? false;
}
/** Languages this server wishes to highlight. Used when querying trends.*/
static get preferredLanguages(): LanguageCode[] | undefined {
return Deno.env.get('DITTO_LANGUAGES')?.split(',')?.filter(ISO6391.validate);
}
/** Mints to be displayed in the UI when the user decides to create a wallet.*/
static get cashuMints(): string[] {
return Deno.env.get('CASHU_MINTS')?.split(',') ?? [];
}
/** Translation provider used to translate posts. */
static get translationProvider(): string | undefined {
return Deno.env.get('TRANSLATION_PROVIDER');
}
/** DeepL URL endpoint. */
static get deeplBaseUrl(): string | undefined {
return Deno.env.get('DEEPL_BASE_URL');
}
/** DeepL API KEY. */
static get deeplApiKey(): string | undefined {
return Deno.env.get('DEEPL_API_KEY');
}
/** LibreTranslate URL endpoint. */
static get libretranslateBaseUrl(): string | undefined {
return Deno.env.get('LIBRETRANSLATE_BASE_URL');
}
/** LibreTranslate API KEY. */
static get libretranslateApiKey(): string | undefined {
return Deno.env.get('LIBRETRANSLATE_API_KEY');
}
/** Cache settings. */
static caches = {
/** NIP-05 cache settings. */
get nip05(): { max: number; ttl: number } {
return {
max: Number(Deno.env.get('DITTO_CACHE_NIP05_MAX') || 3000),
ttl: Number(Deno.env.get('DITTO_CACHE_NIP05_TTL') || 1 * 60 * 60 * 1000),
};
},
/** Favicon cache settings. */
get favicon(): { max: number; ttl: number } {
return {
max: Number(Deno.env.get('DITTO_CACHE_FAVICON_MAX') || 500),
ttl: Number(Deno.env.get('DITTO_CACHE_FAVICON_TTL') || 1 * 60 * 60 * 1000),
};
},
/** Link preview cache settings. */
get linkPreview(): { max: number; ttl: number } {
return {
max: Number(Deno.env.get('DITTO_CACHE_LINK_PREVIEW_MAX') || 3000),
ttl: Number(Deno.env.get('DITTO_CACHE_LINK_PREVIEW_TTL') || 12 * 60 * 60 * 1000),
};
},
/** Translation cache settings. */
get translation(): { max: number; ttl: number } {
return {
max: Number(Deno.env.get('DITTO_CACHE_TRANSLATION_MAX') || 1000),
ttl: Number(Deno.env.get('DITTO_CACHE_TRANSLATION_TTL') || 6 * 60 * 60 * 1000),
};
},
};
static profileFields = {
get maxFields(): number {
return Number(Deno.env.get('PROFILE_FIELDS_MAX_FIELDS') || 10);
},
get nameLength(): number {
return Number(Deno.env.get('PROFILE_FIELDS_NAME_LENGTH') || 255);
},
get valueLength(): number {
return Number(Deno.env.get('PROFILE_FIELDS_VALUE_LENGTH') || 2047);
},
};
/** Maximum time between events before a streak is broken, *in seconds*. */
static get streakWindow(): number {
return Number(Deno.env.get('STREAK_WINDOW') || 129600);
}
}
const optionalBooleanSchema = z
.enum(['true', 'false'])
.optional()
.transform((value) => value !== undefined ? value === 'true' : undefined);
const optionalNumberSchema = z
.string()
.optional()
.transform((value) => value !== undefined ? Number(value) : undefined);
function mergePaths(base: string, path: string) {
const url = new URL(
path.startsWith('/') ? path : new URL(path).pathname,
base,
);
if (!path.startsWith('/')) {
// Copy query parameters from the original URL to the new URL
const originalUrl = new URL(path);
url.search = originalUrl.search;
}
return url.toString();
}
export { Conf };

View file

@ -3,7 +3,6 @@ import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { getAuthor, getFollowedPubkeys } from '@/queries.ts';
import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
@ -22,13 +21,8 @@ import { addTag, deleteTag, findReplyTag, getTagSet } from '@/utils/tags.ts';
import { getPubkeysBySearch } from '@/utils/search.ts'; import { getPubkeysBySearch } from '@/utils/search.ts';
import { MastodonAccount } from '@/entities/MastodonAccount.ts'; import { MastodonAccount } from '@/entities/MastodonAccount.ts';
const usernameSchema = z
.string().min(1).max(30)
.regex(/^[a-z0-9_]+$/i)
.refine((username) => !Conf.forbiddenUsernames.includes(username), 'Username is reserved.');
const createAccountSchema = z.object({ const createAccountSchema = z.object({
username: usernameSchema, username: z.string().min(1).max(30).regex(/^[a-z0-9_]+$/i),
}); });
const createAccountController: AppController = async (c) => { const createAccountController: AppController = async (c) => {
@ -39,6 +33,10 @@ const createAccountController: AppController = async (c) => {
return c.json({ error: 'Bad request', schema: result.error }, 400); return c.json({ error: 'Bad request', schema: result.error }, 400);
} }
if (c.var.conf.forbiddenUsernames.includes(result.data.username)) {
return c.json({ error: 'Username is reserved.' }, 422);
}
return c.json({ return c.json({
access_token: nip19.npubEncode(pubkey), access_token: nip19.npubEncode(pubkey),
token_type: 'Bearer', token_type: 'Bearer',
@ -204,7 +202,8 @@ const accountStatusesQuerySchema = z.object({
const accountStatusesController: AppController = async (c) => { const accountStatusesController: AppController = async (c) => {
const pubkey = c.req.param('pubkey'); const pubkey = c.req.param('pubkey');
const { since, until } = c.get('pagination'); const { conf } = c.var;
const { since, until } = c.var.pagination;
const { pinned, limit, exclude_replies, tagged, only_media } = accountStatusesQuerySchema.parse(c.req.query()); const { pinned, limit, exclude_replies, tagged, only_media } = accountStatusesQuerySchema.parse(c.req.query());
const { signal } = c.req.raw; const { signal } = c.req.raw;
@ -212,7 +211,7 @@ const accountStatusesController: AppController = async (c) => {
const [[author], [user]] = await Promise.all([ const [[author], [user]] = await Promise.all([
store.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal }), store.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal }),
store.query([{ kinds: [30382], authors: [Conf.pubkey], '#d': [pubkey], limit: 1 }], { signal }), store.query([{ kinds: [30382], authors: [conf.pubkey], '#d': [pubkey], limit: 1 }], { signal }),
]); ]);
if (author) { if (author) {
@ -261,7 +260,7 @@ const accountStatusesController: AppController = async (c) => {
filter.search = search.join(' '); filter.search = search.join(' ');
} }
const opts = { signal, limit, timeout: Conf.db.timeouts.timelines }; const opts = { signal, limit, timeout: conf.db.timeouts.timelines };
const events = await store.query([filter], opts) const events = await store.query([filter], opts)
.then((events) => hydrateEvents({ events, store, signal })) .then((events) => hydrateEvents({ events, store, signal }))

View file

@ -3,7 +3,6 @@ import { logi } from '@soapbox/logi';
import { z } from 'zod'; import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { booleanParamSchema } from '@/schema.ts'; import { booleanParamSchema } from '@/schema.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
@ -30,6 +29,7 @@ const adminAccountQuerySchema = z.object({
}); });
const adminAccountsController: AppController = async (c) => { const adminAccountsController: AppController = async (c) => {
const { conf } = c.var;
const store = await Storages.db(); const store = await Storages.db();
const params = c.get('pagination'); const params = c.get('pagination');
const { signal } = c.req.raw; const { signal } = c.req.raw;
@ -49,7 +49,7 @@ const adminAccountsController: AppController = async (c) => {
} }
const orig = await store.query( const orig = await store.query(
[{ kinds: [30383], authors: [Conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...params }], [{ kinds: [30383], authors: [conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...params }],
{ signal }, { signal },
); );
@ -86,7 +86,7 @@ const adminAccountsController: AppController = async (c) => {
n.push('moderator'); n.push('moderator');
} }
const events = await store.query([{ kinds: [30382], authors: [Conf.pubkey], '#n': n, ...params }], { signal }); const events = await store.query([{ kinds: [30382], authors: [conf.pubkey], '#n': n, ...params }], { signal });
const pubkeys = new Set<string>( const pubkeys = new Set<string>(
events events
@ -110,7 +110,7 @@ const adminAccountsController: AppController = async (c) => {
const filter: NostrFilter = { kinds: [0], ...params }; const filter: NostrFilter = { kinds: [0], ...params };
if (local) { if (local) {
filter.search = `domain:${Conf.url.host}`; filter.search = `domain:${conf.url.host}`;
} }
const events = await store.query([filter], { signal }) const events = await store.query([filter], { signal })
@ -125,6 +125,7 @@ const adminAccountActionSchema = z.object({
}); });
const adminActionController: AppController = async (c) => { const adminActionController: AppController = async (c) => {
const { conf } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const store = await Storages.db(); const store = await Storages.db();
const result = adminAccountActionSchema.safeParse(body); const result = adminAccountActionSchema.safeParse(body);
@ -156,7 +157,7 @@ const adminActionController: AppController = async (c) => {
} }
if (data.type === 'revoke_name') { if (data.type === 'revoke_name') {
n.revoke_name = true; n.revoke_name = true;
store.remove([{ kinds: [30360], authors: [Conf.pubkey], '#p': [authorId] }]).catch((e: unknown) => { store.remove([{ kinds: [30360], authors: [conf.pubkey], '#p': [authorId] }]).catch((e: unknown) => {
logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) }); logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) });
}); });
} }
@ -167,6 +168,7 @@ const adminActionController: AppController = async (c) => {
}; };
const adminApproveController: AppController = async (c) => { const adminApproveController: AppController = async (c) => {
const { conf } = c.var;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const store = await Storages.db(); const store = await Storages.db();
@ -183,7 +185,7 @@ const adminApproveController: AppController = async (c) => {
return c.json({ error: 'Invalid NIP-05' }, 400); return c.json({ error: 'Invalid NIP-05' }, 400);
} }
const [existing] = await store.query([{ kinds: [30360], authors: [Conf.pubkey], '#d': [r], limit: 1 }]); const [existing] = await store.query([{ kinds: [30360], authors: [conf.pubkey], '#d': [r], limit: 1 }]);
if (existing) { if (existing) {
return c.json({ error: 'NIP-05 already granted to another user' }, 400); return c.json({ error: 'NIP-05 already granted to another user' }, 400);
} }

View file

@ -3,7 +3,6 @@ import TTLCache from '@isaacs/ttlcache';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { updateUser } from '@/utils/api.ts'; import { updateUser } from '@/utils/api.ts';
interface Point { interface Point {
@ -24,6 +23,8 @@ const PUZZLE_SIZE = { w: 65, h: 65 };
/** Puzzle captcha controller. */ /** Puzzle captcha controller. */
export const captchaController: AppController = async (c) => { export const captchaController: AppController = async (c) => {
const { conf } = c.var;
const { bg, puzzle, solution } = generateCaptcha( const { bg, puzzle, solution } = generateCaptcha(
await imagesAsync, await imagesAsync,
BG_SIZE, BG_SIZE,
@ -32,7 +33,7 @@ export const captchaController: AppController = async (c) => {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
const now = new Date(); const now = new Date();
const ttl = Conf.captchaTTL; const ttl = conf.captchaTTL;
captchas.set(id, solution, { ttl }); captchas.set(id, solution, { ttl });

View file

@ -1,3 +1,4 @@
import { confMw } from '@ditto/api/middleware';
import { Env as HonoEnv, Hono } from '@hono/hono'; import { Env as HonoEnv, Hono } from '@hono/hono';
import { NostrSigner, NSecSigner, NStore } from '@nostrify/nostrify'; import { NostrSigner, NSecSigner, NStore } from '@nostrify/nostrify';
import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { generateSecretKey, getPublicKey } from 'nostr-tools';
@ -40,7 +41,10 @@ Deno.test('PUT /wallet must be successful', {
c.set('store', store); c.set('store', store);
await next(); await next();
}, },
).route('/', cashuApp); );
app.use(confMw(new Map()));
app.route('/', cashuApp);
const response = await app.request('/wallet', { const response = await app.request('/wallet', {
method: 'PUT', method: 'PUT',
@ -116,7 +120,10 @@ Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async
c.set('store', store); c.set('store', store);
await next(); await next();
}, },
).route('/', cashuApp); );
app.use(confMw(new Map()));
app.route('/', cashuApp);
const response = await app.request('/wallet', { const response = await app.request('/wallet', {
method: 'PUT', method: 'PUT',
@ -149,7 +156,10 @@ Deno.test('PUT /wallet must NOT be successful: wallet already exists', async ()
c.set('store', store); c.set('store', store);
await next(); await next();
}, },
).route('/', cashuApp); );
app.use(confMw(new Map()));
app.route('/', cashuApp);
await db.store.event(genEvent({ kind: 17375 }, sk)); await db.store.event(genEvent({ kind: 17375 }, sk));
@ -187,7 +197,10 @@ Deno.test('GET /wallet must be successful', async () => {
c.set('store', store); c.set('store', store);
await next(); await next();
}, },
).route('/', cashuApp); );
app.use(confMw(new Map()));
app.route('/', cashuApp);
// Wallet // Wallet
await db.store.event(genEvent({ await db.store.event(genEvent({
@ -290,7 +303,10 @@ Deno.test('GET /mints must be successful', async () => {
c.set('store', store); c.set('store', store);
await next(); await next();
}, },
).route('/', cashuApp); );
app.use(confMw(new Map()));
app.route('/', cashuApp);
const response = await app.request('/mints', { const response = await app.request('/mints', {
method: 'GET', method: 'GET',

View file

@ -1,10 +1,10 @@
import { CashuMint, CashuWallet, Proof } from '@cashu/cashu-ts'; import { CashuMint, CashuWallet, Proof } from '@cashu/cashu-ts';
import { confRequiredMw } from '@ditto/api/middleware';
import { Hono } from '@hono/hono'; import { Hono } from '@hono/hono';
import { generateSecretKey, getPublicKey } from 'nostr-tools'; import { generateSecretKey, getPublicKey } from 'nostr-tools';
import { bytesToString, stringToBytes } from '@scure/base'; import { bytesToString, stringToBytes } from '@scure/base';
import { z } from 'zod'; import { z } from 'zod';
import { Conf } from '@/config.ts';
import { createEvent, parseBody } from '@/utils/api.ts'; import { createEvent, parseBody } from '@/utils/api.ts';
import { requireNip44Signer } from '@/middleware/requireSigner.ts'; import { requireNip44Signer } from '@/middleware/requireSigner.ts';
import { requireStore } from '@/middleware/storeMiddleware.ts'; import { requireStore } from '@/middleware/storeMiddleware.ts';
@ -16,7 +16,7 @@ import { errorJson } from '@/utils/log.ts';
type Wallet = z.infer<typeof walletSchema>; type Wallet = z.infer<typeof walletSchema>;
const app = new Hono().use('*', requireStore); const app = new Hono().use('*', confRequiredMw, requireStore);
// app.delete('/wallet') -> 204 // app.delete('/wallet') -> 204
@ -103,7 +103,7 @@ const createCashuWalletAndNutzapInfoSchema = z.object({
* https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event * https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event
*/ */
app.put('/wallet', requireNip44Signer, async (c) => { app.put('/wallet', requireNip44Signer, async (c) => {
const signer = c.var.signer; const { conf, signer } = c.var;
const store = c.get('store'); const store = c.get('store');
const pubkey = await signer.getPublicKey(); const pubkey = await signer.getPublicKey();
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
@ -146,7 +146,7 @@ app.put('/wallet', requireNip44Signer, async (c) => {
kind: 10019, kind: 10019,
tags: [ tags: [
...mints.map((mint) => ['mint', mint, 'sat']), ...mints.map((mint) => ['mint', mint, 'sat']),
['relay', Conf.relay], // TODO: add more relays once things get more stable ['relay', conf.relay], // TODO: add more relays once things get more stable
['pubkey', p2pk], ['pubkey', p2pk],
], ],
}, c); }, c);
@ -155,7 +155,7 @@ app.put('/wallet', requireNip44Signer, async (c) => {
const walletEntity: Wallet = { const walletEntity: Wallet = {
pubkey_p2pk: p2pk, pubkey_p2pk: p2pk,
mints, mints,
relays: [Conf.relay], relays: [conf.relay],
balance: 0, // Newly created wallet, balance is zero. balance: 0, // Newly created wallet, balance is zero.
}; };
@ -164,7 +164,7 @@ app.put('/wallet', requireNip44Signer, async (c) => {
/** Gets a wallet, if it exists. */ /** Gets a wallet, if it exists. */
app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => { app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => {
const signer = c.get('signer'); const { conf, signer } = c.var;
const store = c.get('store'); const store = c.get('store');
const pubkey = await signer.getPublicKey(); const pubkey = await signer.getPublicKey();
const { signal } = c.req.raw; const { signal } = c.req.raw;
@ -209,7 +209,7 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => {
const walletEntity: Wallet = { const walletEntity: Wallet = {
pubkey_p2pk: p2pk, pubkey_p2pk: p2pk,
mints, mints,
relays: [Conf.relay], relays: [conf.relay],
balance, balance,
}; };
@ -218,8 +218,10 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => {
/** Get mints set by the CASHU_MINTS environment variable. */ /** Get mints set by the CASHU_MINTS environment variable. */
app.get('/mints', (c) => { app.get('/mints', (c) => {
const { conf } = c.var;
// TODO: Return full Mint information: https://github.com/cashubtc/nuts/blob/main/06.md // TODO: Return full Mint information: https://github.com/cashubtc/nuts/blob/main/06.md
const mints = Conf.cashuMints; const mints = conf.cashuMints;
return c.json({ mints }, 200); return c.json({ mints }, 200);
}); });

View file

@ -2,7 +2,6 @@ import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getAuthor } from '@/queries.ts'; import { getAuthor } from '@/queries.ts';
import { addTag } from '@/utils/tags.ts'; import { addTag } from '@/utils/tags.ts';
@ -30,10 +29,11 @@ const relaySchema = z.object({
type RelayEntity = z.infer<typeof relaySchema>; type RelayEntity = z.infer<typeof relaySchema>;
export const adminRelaysController: AppController = async (c) => { export const adminRelaysController: AppController = async (c) => {
const { conf } = c.var;
const store = await Storages.db(); const store = await Storages.db();
const [event] = await store.query([ const [event] = await store.query([
{ kinds: [10002], authors: [Conf.pubkey], limit: 1 }, { kinds: [10002], authors: [conf.pubkey], limit: 1 },
]); ]);
if (!event) { if (!event) {
@ -82,6 +82,7 @@ export const nameRequestController: AppController = async (c) => {
const store = await Storages.db(); const store = await Storages.db();
const signer = c.get('signer')!; const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey(); const pubkey = await signer.getPublicKey();
const { conf } = c.var;
const { name, reason } = nameRequestSchema.parse(await c.req.json()); const { name, reason } = nameRequestSchema.parse(await c.req.json());
@ -97,7 +98,7 @@ export const nameRequestController: AppController = async (c) => {
['r', name], ['r', name],
['L', 'nip05.domain'], ['L', 'nip05.domain'],
['l', name.split('@')[1], 'nip05.domain'], ['l', name.split('@')[1], 'nip05.domain'],
['p', Conf.pubkey], ['p', conf.pubkey],
], ],
}, c); }, c);
@ -113,6 +114,7 @@ const nameRequestsSchema = z.object({
}); });
export const nameRequestsController: AppController = async (c) => { export const nameRequestsController: AppController = async (c) => {
const { conf } = c.var;
const store = await Storages.db(); const store = await Storages.db();
const signer = c.get('signer')!; const signer = c.get('signer')!;
const pubkey = await signer.getPublicKey(); const pubkey = await signer.getPublicKey();
@ -122,7 +124,7 @@ export const nameRequestsController: AppController = async (c) => {
const filter: NostrFilter = { const filter: NostrFilter = {
kinds: [30383], kinds: [30383],
authors: [Conf.pubkey], authors: [conf.pubkey],
'#k': ['3036'], '#k': ['3036'],
'#p': [pubkey], '#p': [pubkey],
...params, ...params,
@ -168,6 +170,7 @@ const zapSplitSchema = z.record(
); );
export const updateZapSplitsController: AppController = async (c) => { export const updateZapSplitsController: AppController = async (c) => {
const { conf } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = zapSplitSchema.safeParse(body); const result = zapSplitSchema.safeParse(body);
const store = c.get('store'); const store = c.get('store');
@ -176,7 +179,7 @@ export const updateZapSplitsController: AppController = async (c) => {
return c.json({ error: result.error }, 400); return c.json({ error: result.error }, 400);
} }
const dittoZapSplit = await getZapSplits(store, Conf.pubkey); const dittoZapSplit = await getZapSplits(store, conf.pubkey);
if (!dittoZapSplit) { if (!dittoZapSplit) {
return c.json({ error: 'Zap split not activated, restart the server.' }, 404); return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
} }
@ -189,7 +192,7 @@ export const updateZapSplitsController: AppController = async (c) => {
} }
await updateListAdminEvent( await updateListAdminEvent(
{ kinds: [30078], authors: [Conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 }, { kinds: [30078], authors: [conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 },
(tags) => (tags) =>
pubkeys.reduce((accumulator, pubkey) => { pubkeys.reduce((accumulator, pubkey) => {
return addTag(accumulator, ['p', pubkey, data[pubkey].weight.toString(), data[pubkey].message]); return addTag(accumulator, ['p', pubkey, data[pubkey].weight.toString(), data[pubkey].message]);
@ -203,6 +206,7 @@ export const updateZapSplitsController: AppController = async (c) => {
const deleteZapSplitSchema = z.array(n.id()).min(1); const deleteZapSplitSchema = z.array(n.id()).min(1);
export const deleteZapSplitsController: AppController = async (c) => { export const deleteZapSplitsController: AppController = async (c) => {
const { conf } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = deleteZapSplitSchema.safeParse(body); const result = deleteZapSplitSchema.safeParse(body);
const store = c.get('store'); const store = c.get('store');
@ -211,7 +215,7 @@ export const deleteZapSplitsController: AppController = async (c) => {
return c.json({ error: result.error }, 400); return c.json({ error: result.error }, 400);
} }
const dittoZapSplit = await getZapSplits(store, Conf.pubkey); const dittoZapSplit = await getZapSplits(store, conf.pubkey);
if (!dittoZapSplit) { if (!dittoZapSplit) {
return c.json({ error: 'Zap split not activated, restart the server.' }, 404); return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
} }
@ -219,7 +223,7 @@ export const deleteZapSplitsController: AppController = async (c) => {
const { data } = result; const { data } = result;
await updateListAdminEvent( await updateListAdminEvent(
{ kinds: [30078], authors: [Conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 }, { kinds: [30078], authors: [conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 },
(tags) => (tags) =>
data.reduce((accumulator, currentValue) => { data.reduce((accumulator, currentValue) => {
return deleteTag(accumulator, ['p', currentValue]); return deleteTag(accumulator, ['p', currentValue]);
@ -231,9 +235,10 @@ export const deleteZapSplitsController: AppController = async (c) => {
}; };
export const getZapSplitsController: AppController = async (c) => { export const getZapSplitsController: AppController = async (c) => {
const { conf } = c.var;
const store = c.get('store'); const store = c.get('store');
const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(store, Conf.pubkey) ?? {}; const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(store, conf.pubkey) ?? {};
if (!dittoZapSplit) { if (!dittoZapSplit) {
return c.json({ error: 'Zap split not activated, restart the server.' }, 404); return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
} }
@ -303,9 +308,10 @@ const updateInstanceSchema = z.object({
}); });
export const updateInstanceController: AppController = async (c) => { export const updateInstanceController: AppController = async (c) => {
const { conf } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = updateInstanceSchema.safeParse(body); const result = updateInstanceSchema.safeParse(body);
const pubkey = Conf.pubkey; const pubkey = conf.pubkey;
if (!result.success) { if (!result.success) {
return c.json(result.error, 422); return c.json(result.error, 422);

View file

@ -1,7 +1,6 @@
import denoJson from 'deno.json' with { type: 'json' }; import denoJson from 'deno.json' with { type: 'json' };
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { getInstanceMetadata } from '@/utils/instance.ts'; import { getInstanceMetadata } from '@/utils/instance.ts';
@ -17,7 +16,8 @@ const features = [
]; ];
const instanceV1Controller: AppController = async (c) => { const instanceV1Controller: AppController = async (c) => {
const { host, protocol } = Conf.url; const { conf } = c.var;
const { host, protocol } = conf.url;
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
/** 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`. */
@ -29,7 +29,7 @@ const instanceV1Controller: AppController = async (c) => {
description: meta.about, description: meta.about,
short_description: meta.tagline, short_description: meta.tagline,
registrations: true, registrations: true,
max_toot_chars: Conf.postCharLimit, max_toot_chars: conf.postCharLimit,
configuration: { configuration: {
media_attachments: { media_attachments: {
image_size_limit: 100000000, image_size_limit: 100000000,
@ -42,7 +42,7 @@ const instanceV1Controller: AppController = async (c) => {
min_expiration: 0, min_expiration: 0,
}, },
statuses: { statuses: {
max_characters: Conf.postCharLimit, max_characters: conf.postCharLimit,
max_media_attachments: 20, max_media_attachments: 20,
}, },
}, },
@ -50,9 +50,9 @@ const instanceV1Controller: AppController = async (c) => {
metadata: { metadata: {
features, features,
fields_limits: { fields_limits: {
max_fields: Conf.profileFields.maxFields, max_fields: conf.profileFields.maxFields,
name_length: Conf.profileFields.nameLength, name_length: conf.profileFields.nameLength,
value_length: Conf.profileFields.valueLength, value_length: conf.profileFields.valueLength,
}, },
}, },
}, },
@ -68,7 +68,7 @@ const instanceV1Controller: AppController = async (c) => {
version, version,
email: meta.email, email: meta.email,
nostr: { nostr: {
pubkey: Conf.pubkey, pubkey: conf.pubkey,
relay: `${wsProtocol}//${host}/relay`, relay: `${wsProtocol}//${host}/relay`,
}, },
rules: [], rules: [],
@ -76,7 +76,8 @@ const instanceV1Controller: AppController = async (c) => {
}; };
const instanceV2Controller: AppController = async (c) => { const instanceV2Controller: AppController = async (c) => {
const { host, protocol } = Conf.url; const { conf } = c.var;
const { host, protocol } = conf.url;
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal); const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
/** 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`. */
@ -111,14 +112,14 @@ const instanceV2Controller: AppController = async (c) => {
streaming: `${wsProtocol}//${host}`, streaming: `${wsProtocol}//${host}`,
}, },
vapid: { vapid: {
public_key: await Conf.vapidPublicKey, public_key: await conf.vapidPublicKey,
}, },
accounts: { accounts: {
max_featured_tags: 10, max_featured_tags: 10,
max_pinned_statuses: 5, max_pinned_statuses: 5,
}, },
statuses: { statuses: {
max_characters: Conf.postCharLimit, max_characters: conf.postCharLimit,
max_media_attachments: 20, max_media_attachments: 20,
characters_reserved_per_url: 23, characters_reserved_per_url: 23,
}, },
@ -136,20 +137,20 @@ const instanceV2Controller: AppController = async (c) => {
max_expiration: 2629746, max_expiration: 2629746,
}, },
translation: { translation: {
enabled: Boolean(Conf.translationProvider), enabled: Boolean(conf.translationProvider),
}, },
}, },
nostr: { nostr: {
pubkey: Conf.pubkey, pubkey: conf.pubkey,
relay: `${wsProtocol}//${host}/relay`, relay: `${wsProtocol}//${host}/relay`,
}, },
pleroma: { pleroma: {
metadata: { metadata: {
features, features,
fields_limits: { fields_limits: {
max_fields: Conf.profileFields.maxFields, max_fields: conf.profileFields.maxFields,
name_length: Conf.profileFields.nameLength, name_length: conf.profileFields.nameLength,
value_length: Conf.profileFields.valueLength, value_length: conf.profileFields.valueLength,
}, },
}, },
}, },

View file

@ -2,7 +2,6 @@ import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { AppContext, AppController } from '@/app.ts'; import { AppContext, AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { DittoPagination } from '@/interfaces/DittoPagination.ts'; import { DittoPagination } from '@/interfaces/DittoPagination.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { paginated } from '@/utils/api.ts'; import { paginated } from '@/utils/api.ts';
@ -31,6 +30,7 @@ const notificationsSchema = z.object({
}); });
const notificationsController: AppController = async (c) => { const notificationsController: AppController = async (c) => {
const { conf } = c.var;
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await c.get('signer')?.getPublicKey()!;
const params = c.get('pagination'); const params = c.get('pagination');
@ -68,7 +68,7 @@ const notificationsController: AppController = async (c) => {
} }
if (types.has('ditto:name_grant') && !account_id) { if (types.has('ditto:name_grant') && !account_id) {
filters.push({ kinds: [30360], authors: [Conf.pubkey], '#p': [pubkey], ...params }); filters.push({ kinds: [30360], authors: [conf.pubkey], '#p': [pubkey], ...params });
} }
return renderNotifications(filters, types, params, c); return renderNotifications(filters, types, params, c);
@ -105,10 +105,11 @@ async function renderNotifications(
params: DittoPagination, params: DittoPagination,
c: AppContext, c: AppContext,
) { ) {
const { conf } = c.var;
const store = c.get('store'); const store = c.get('store');
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw; const { signal } = c.req.raw;
const opts = { signal, limit: params.limit, timeout: Conf.db.timeouts.timelines }; const opts = { signal, limit: params.limit, timeout: conf.db.timeouts.timelines };
const events = await store const events = await store
.query(filters, opts) .query(filters, opts)

View file

@ -4,7 +4,6 @@ import { generateSecretKey } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { nostrNow } from '@/utils.ts'; import { nostrNow } from '@/utils.ts';
import { parseBody } from '@/utils/api.ts'; import { parseBody } from '@/utils/api.ts';
@ -40,6 +39,7 @@ const createTokenSchema = z.discriminatedUnion('grant_type', [
]); ]);
const createTokenController: AppController = async (c) => { const createTokenController: AppController = async (c) => {
const { conf } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = createTokenSchema.safeParse(body); const result = createTokenSchema.safeParse(body);
@ -50,7 +50,7 @@ const createTokenController: AppController = async (c) => {
switch (result.data.grant_type) { switch (result.data.grant_type) {
case 'nostr_bunker': case 'nostr_bunker':
return c.json({ return c.json({
access_token: await getToken(result.data), access_token: await getToken(result.data, conf.seckey),
token_type: 'Bearer', token_type: 'Bearer',
scope: 'read write follow push', scope: 'read write follow push',
created_at: nostrNow(), created_at: nostrNow(),
@ -112,6 +112,7 @@ const revokeTokenController: AppController = async (c) => {
async function getToken( async function getToken(
{ pubkey: bunkerPubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] }, { pubkey: bunkerPubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] },
dittoSeckey: Uint8Array,
): Promise<`token1${string}`> { ): Promise<`token1${string}`> {
const kysely = await Storages.kysely(); const kysely = await Storages.kysely();
const { token, hash } = await generateToken(); const { token, hash } = await generateToken();
@ -133,7 +134,7 @@ async function getToken(
token_hash: hash, token_hash: hash,
pubkey: userPubkey, pubkey: userPubkey,
bunker_pubkey: bunkerPubkey, bunker_pubkey: bunkerPubkey,
nip46_sk_enc: await aesEncrypt(Conf.seckey, nip46Seckey), nip46_sk_enc: await aesEncrypt(dittoSeckey, nip46Seckey),
nip46_relays: relays, nip46_relays: relays,
created_at: new Date(), created_at: new Date(),
}).execute(); }).execute();
@ -143,6 +144,7 @@ async function getToken(
/** Display the OAuth form. */ /** Display the OAuth form. */
const oauthController: AppController = (c) => { const oauthController: AppController = (c) => {
const { conf } = c.var;
const encodedUri = c.req.query('redirect_uri'); const encodedUri = c.req.query('redirect_uri');
if (!encodedUri) { if (!encodedUri) {
return c.text('Missing `redirect_uri` query param.', 422); return c.text('Missing `redirect_uri` query param.', 422);
@ -192,7 +194,7 @@ const oauthController: AppController = (c) => {
<input type="hidden" name="state" value="${escape(state ?? '')}"> <input type="hidden" name="state" value="${escape(state ?? '')}">
<button type="submit">Authorize</button> <button type="submit">Authorize</button>
</form> </form>
<p>Sign in with a Nostr bunker app. Please configure the app to use this relay: ${Conf.relay}</p> <p>Sign in with a Nostr bunker app. Please configure the app to use this relay: ${conf.relay}</p>
</body> </body>
</html> </html>
`); `);
@ -220,6 +222,8 @@ const oauthAuthorizeSchema = z.object({
/** Controller the OAuth form is POSTed to. */ /** Controller the OAuth form is POSTed to. */
const oauthAuthorizeController: AppController = async (c) => { const oauthAuthorizeController: AppController = async (c) => {
const { conf } = c.var;
/** FormData results in JSON. */ /** FormData results in JSON. */
const result = oauthAuthorizeSchema.safeParse(await parseBody(c.req.raw)); const result = oauthAuthorizeSchema.safeParse(await parseBody(c.req.raw));
@ -236,7 +240,7 @@ const oauthAuthorizeController: AppController = async (c) => {
pubkey: bunker.hostname, pubkey: bunker.hostname,
secret: bunker.searchParams.get('secret') || undefined, secret: bunker.searchParams.get('secret') || undefined,
relays: bunker.searchParams.getAll('relay'), relays: bunker.searchParams.getAll('relay'),
}); }, conf.seckey);
if (redirectUri === 'urn:ietf:wg:oauth:2.0:oob') { if (redirectUri === 'urn:ietf:wg:oauth:2.0:oob') {
return c.text(token); return c.text(token);

View file

@ -1,7 +1,6 @@
import { z } from 'zod'; import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts'; import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts'; import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
@ -34,7 +33,8 @@ const configController: AppController = async (c) => {
/** Pleroma admin config controller. */ /** Pleroma admin config controller. */
const updateConfigController: AppController = async (c) => { const updateConfigController: AppController = async (c) => {
const { pubkey } = Conf; const { conf } = c.var;
const { pubkey } = conf;
const store = await Storages.db(); const store = await Storages.db();
const configs = await getPleromaConfigs(store, c.req.raw.signal); const configs = await getPleromaConfigs(store, c.req.raw.signal);
@ -69,6 +69,7 @@ const pleromaAdminTagSchema = z.object({
}); });
const pleromaAdminTagController: AppController = async (c) => { const pleromaAdminTagController: AppController = async (c) => {
const { conf } = c.var;
const params = pleromaAdminTagSchema.parse(await c.req.json()); const params = pleromaAdminTagSchema.parse(await c.req.json());
for (const nickname of params.nicknames) { for (const nickname of params.nicknames) {
@ -76,7 +77,7 @@ const pleromaAdminTagController: AppController = async (c) => {
if (!pubkey) continue; if (!pubkey) continue;
await updateAdminEvent( await updateAdminEvent(
{ kinds: [30382], authors: [Conf.pubkey], '#d': [pubkey], limit: 1 }, { kinds: [30382], authors: [conf.pubkey], '#d': [pubkey], limit: 1 },
(prev) => { (prev) => {
const tags = prev?.tags ?? [['d', pubkey]]; const tags = prev?.tags ?? [['d', pubkey]];
@ -101,6 +102,7 @@ const pleromaAdminTagController: AppController = async (c) => {
}; };
const pleromaAdminUntagController: AppController = async (c) => { const pleromaAdminUntagController: AppController = async (c) => {
const { conf } = c.var;
const params = pleromaAdminTagSchema.parse(await c.req.json()); const params = pleromaAdminTagSchema.parse(await c.req.json());
for (const nickname of params.nicknames) { for (const nickname of params.nicknames) {
@ -108,7 +110,7 @@ const pleromaAdminUntagController: AppController = async (c) => {
if (!pubkey) continue; if (!pubkey) continue;
await updateAdminEvent( await updateAdminEvent(
{ kinds: [30382], authors: [Conf.pubkey], '#d': [pubkey], limit: 1 }, { kinds: [30382], authors: [conf.pubkey], '#d': [pubkey], limit: 1 },
(prev) => ({ (prev) => ({
kind: 30382, kind: 30382,
content: prev?.content ?? '', content: prev?.content ?? '',

View file

@ -3,7 +3,6 @@ import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { parseBody } from '@/utils/api.ts'; import { parseBody } from '@/utils/api.ts';
import { getTokenHash } from '@/utils/auth.ts'; import { getTokenHash } from '@/utils/auth.ts';
@ -43,7 +42,8 @@ const pushSubscribeSchema = z.object({
}); });
export const pushSubscribeController: AppController = async (c) => { export const pushSubscribeController: AppController = async (c) => {
const vapidPublicKey = await Conf.vapidPublicKey; const { conf } = c.var;
const vapidPublicKey = await conf.vapidPublicKey;
if (!vapidPublicKey) { if (!vapidPublicKey) {
return c.json({ error: 'The administrator of this server has not enabled Web Push notifications.' }, 404); return c.json({ error: 'The administrator of this server has not enabled Web Push notifications.' }, 404);
@ -97,7 +97,8 @@ export const pushSubscribeController: AppController = async (c) => {
}; };
export const getSubscriptionController: AppController = async (c) => { export const getSubscriptionController: AppController = async (c) => {
const vapidPublicKey = await Conf.vapidPublicKey; const { conf } = c.var;
const vapidPublicKey = await conf.vapidPublicKey;
if (!vapidPublicKey) { if (!vapidPublicKey) {
return c.json({ error: 'The administrator of this server has not enabled Web Push notifications.' }, 404); return c.json({ error: 'The administrator of this server has not enabled Web Push notifications.' }, 404);

View file

@ -2,7 +2,6 @@ import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { createEvent, paginated, parseBody, updateEventInfo } from '@/utils/api.ts'; import { createEvent, paginated, parseBody, updateEventInfo } from '@/utils/api.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { renderAdminReport } from '@/views/mastodon/reports.ts'; import { renderAdminReport } from '@/views/mastodon/reports.ts';
@ -19,6 +18,7 @@ const reportSchema = z.object({
/** https://docs.joinmastodon.org/methods/reports/#post */ /** https://docs.joinmastodon.org/methods/reports/#post */
const reportController: AppController = async (c) => { const reportController: AppController = async (c) => {
const { conf } = c.var;
const store = c.get('store'); const store = c.get('store');
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = reportSchema.safeParse(body); const result = reportSchema.safeParse(body);
@ -36,7 +36,7 @@ const reportController: AppController = async (c) => {
const tags = [ const tags = [
['p', account_id, category], ['p', account_id, category],
['P', Conf.pubkey], ['P', conf.pubkey],
]; ];
for (const status of status_ids) { for (const status of status_ids) {
@ -61,6 +61,7 @@ const adminReportsSchema = z.object({
/** https://docs.joinmastodon.org/methods/admin/reports/#get */ /** https://docs.joinmastodon.org/methods/admin/reports/#get */
const adminReportsController: AppController = async (c) => { const adminReportsController: AppController = async (c) => {
const { conf } = c.var;
const store = c.get('store'); const store = c.get('store');
const viewerPubkey = await c.get('signer')?.getPublicKey(); const viewerPubkey = await c.get('signer')?.getPublicKey();
@ -69,7 +70,7 @@ const adminReportsController: AppController = async (c) => {
const filter: NostrFilter = { const filter: NostrFilter = {
kinds: [30383], kinds: [30383],
authors: [Conf.pubkey], authors: [conf.pubkey],
'#k': ['1984'], '#k': ['1984'],
...params, ...params,
}; };

View file

@ -6,7 +6,6 @@ import { nip19 } from 'nostr-tools';
import { z } from 'zod'; import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { DittoUpload, dittoUploads } from '@/DittoUploads.ts'; import { DittoUpload, dittoUploads } from '@/DittoUploads.ts';
import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts';
import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts';
@ -66,6 +65,7 @@ const statusController: AppController = async (c) => {
}; };
const createStatusController: AppController = async (c) => { const createStatusController: AppController = async (c) => {
const { conf } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = createStatusSchema.safeParse(body); const result = createStatusSchema.safeParse(body);
const store = c.get('store'); const store = c.get('store');
@ -97,12 +97,12 @@ const createStatusController: AppController = async (c) => {
const root = rootId === ancestor.id ? ancestor : await store.query([{ ids: [rootId] }]).then(([event]) => event); const root = rootId === ancestor.id ? ancestor : await store.query([{ ids: [rootId] }]).then(([event]) => event);
if (root) { if (root) {
tags.push(['e', root.id, Conf.relay, 'root', root.pubkey]); tags.push(['e', root.id, conf.relay, 'root', root.pubkey]);
} else { } else {
tags.push(['e', rootId, Conf.relay, 'root']); tags.push(['e', rootId, conf.relay, 'root']);
} }
tags.push(['e', ancestor.id, Conf.relay, 'reply', ancestor.pubkey]); tags.push(['e', ancestor.id, conf.relay, 'reply', ancestor.pubkey]);
} }
let quoted: DittoEvent | undefined; let quoted: DittoEvent | undefined;
@ -114,7 +114,7 @@ const createStatusController: AppController = async (c) => {
return c.json({ error: 'Quoted post not found.' }, 404); return c.json({ error: 'Quoted post not found.' }, 404);
} }
tags.push(['q', quoted.id, Conf.relay, quoted.pubkey]); tags.push(['q', quoted.id, conf.relay, quoted.pubkey]);
} }
if (data.sensitive && data.spoiler_text) { if (data.sensitive && data.spoiler_text) {
@ -162,7 +162,7 @@ const createStatusController: AppController = async (c) => {
} }
try { try {
return `nostr:${nip19.nprofileEncode({ pubkey, relays: [Conf.relay] })}`; return `nostr:${nip19.nprofileEncode({ pubkey, relays: [conf.relay] })}`;
} catch { } catch {
return match; return match;
} }
@ -178,7 +178,7 @@ const createStatusController: AppController = async (c) => {
} }
for (const pubkey of pubkeys) { for (const pubkey of pubkeys) {
tags.push(['p', pubkey, Conf.relay]); tags.push(['p', pubkey, conf.relay]);
} }
for (const link of linkify.find(data.status ?? '')) { for (const link of linkify.find(data.status ?? '')) {
@ -193,10 +193,10 @@ const createStatusController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await c.get('signer')?.getPublicKey()!;
const author = pubkey ? await getAuthor(pubkey) : undefined; const author = pubkey ? await getAuthor(pubkey) : undefined;
if (Conf.zapSplitsEnabled) { if (conf.zapSplitsEnabled) {
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content); const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
const lnurl = getLnurl(meta); const lnurl = getLnurl(meta);
const dittoZapSplit = await getZapSplits(store, Conf.pubkey); const dittoZapSplit = await getZapSplits(store, conf.pubkey);
if (lnurl && dittoZapSplit) { if (lnurl && dittoZapSplit) {
const totalSplit = Object.values(dittoZapSplit).reduce((total, { weight }) => total + weight, 0); const totalSplit = Object.values(dittoZapSplit).reduce((total, { weight }) => total + weight, 0);
for (const zapPubkey in dittoZapSplit) { for (const zapPubkey in dittoZapSplit) {
@ -204,7 +204,7 @@ const createStatusController: AppController = async (c) => {
tags.push([ tags.push([
'zap', 'zap',
zapPubkey, zapPubkey,
Conf.relay, conf.relay,
(Math.max(0, 100 - totalSplit) + dittoZapSplit[zapPubkey].weight).toString(), (Math.max(0, 100 - totalSplit) + dittoZapSplit[zapPubkey].weight).toString(),
]); ]);
continue; continue;
@ -212,13 +212,13 @@ const createStatusController: AppController = async (c) => {
tags.push([ tags.push([
'zap', 'zap',
zapPubkey, zapPubkey,
Conf.relay, conf.relay,
dittoZapSplit[zapPubkey].weight.toString(), dittoZapSplit[zapPubkey].weight.toString(),
dittoZapSplit[zapPubkey].message, dittoZapSplit[zapPubkey].message,
]); ]);
} }
if (totalSplit && !dittoZapSplit[pubkey]) { if (totalSplit && !dittoZapSplit[pubkey]) {
tags.push(['zap', pubkey, Conf.relay, Math.max(0, 100 - totalSplit).toString()]); tags.push(['zap', pubkey, conf.relay, Math.max(0, 100 - totalSplit).toString()]);
} }
} }
} }
@ -235,7 +235,7 @@ const createStatusController: AppController = async (c) => {
id: quoted.id, id: quoted.id,
kind: quoted.kind, kind: quoted.kind,
author: quoted.pubkey, author: quoted.pubkey,
relays: [Conf.relay], relays: [conf.relay],
}); });
content += `nostr:${nevent}`; content += `nostr:${nevent}`;
} }
@ -265,6 +265,7 @@ const createStatusController: AppController = async (c) => {
}; };
const deleteStatusController: AppController = async (c) => { const deleteStatusController: AppController = async (c) => {
const { conf } = c.var;
const id = c.req.param('id'); const id = c.req.param('id');
const pubkey = await c.get('signer')?.getPublicKey(); const pubkey = await c.get('signer')?.getPublicKey();
@ -274,7 +275,7 @@ const deleteStatusController: AppController = async (c) => {
if (event.pubkey === pubkey) { if (event.pubkey === pubkey) {
await createEvent({ await createEvent({
kind: 5, kind: 5,
tags: [['e', id, Conf.relay, '', pubkey]], tags: [['e', id, conf.relay, '', pubkey]],
}, c); }, c);
const author = await getAuthor(event.pubkey); const author = await getAuthor(event.pubkey);
@ -324,6 +325,7 @@ const contextController: AppController = async (c) => {
}; };
const favouriteController: AppController = async (c) => { const favouriteController: AppController = async (c) => {
const { conf } = c.var;
const id = c.req.param('id'); const id = c.req.param('id');
const store = await Storages.db(); const store = await Storages.db();
const [target] = await store.query([{ ids: [id], kinds: [1, 20] }]); const [target] = await store.query([{ ids: [id], kinds: [1, 20] }]);
@ -333,8 +335,8 @@ const favouriteController: AppController = async (c) => {
kind: 7, kind: 7,
content: '+', content: '+',
tags: [ tags: [
['e', target.id, Conf.relay, '', target.pubkey], ['e', target.id, conf.relay, '', target.pubkey],
['p', target.pubkey, Conf.relay], ['p', target.pubkey, conf.relay],
], ],
}, c); }, c);
@ -364,6 +366,7 @@ const favouritedByController: AppController = (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#boost */ /** https://docs.joinmastodon.org/methods/statuses/#boost */
const reblogStatusController: AppController = async (c) => { const reblogStatusController: AppController = async (c) => {
const { conf } = c.var;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const { signal } = c.req.raw; const { signal } = c.req.raw;
@ -378,8 +381,8 @@ const reblogStatusController: AppController = async (c) => {
const reblogEvent = await createEvent({ const reblogEvent = await createEvent({
kind: 6, kind: 6,
tags: [ tags: [
['e', event.id, Conf.relay, '', event.pubkey], ['e', event.id, conf.relay, '', event.pubkey],
['p', event.pubkey, Conf.relay], ['p', event.pubkey, conf.relay],
], ],
}, c); }, c);
@ -396,6 +399,7 @@ const reblogStatusController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unreblog */ /** https://docs.joinmastodon.org/methods/statuses/#unreblog */
const unreblogStatusController: AppController = async (c) => { const unreblogStatusController: AppController = async (c) => {
const { conf } = c.var;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await c.get('signer')?.getPublicKey()!;
const store = await Storages.db(); const store = await Storages.db();
@ -415,7 +419,7 @@ const unreblogStatusController: AppController = async (c) => {
await createEvent({ await createEvent({
kind: 5, kind: 5,
tags: [['e', repostEvent.id, Conf.relay, '', repostEvent.pubkey]], tags: [['e', repostEvent.id, conf.relay, '', repostEvent.pubkey]],
}, c); }, c);
return c.json(await renderStatus(event, { viewerPubkey: pubkey })); return c.json(await renderStatus(event, { viewerPubkey: pubkey }));
@ -456,6 +460,7 @@ const quotesController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#bookmark */ /** https://docs.joinmastodon.org/methods/statuses/#bookmark */
const bookmarkController: AppController = async (c) => { const bookmarkController: AppController = async (c) => {
const { conf } = c.var;
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
@ -467,7 +472,7 @@ const bookmarkController: AppController = async (c) => {
if (event) { if (event) {
await updateListEvent( await updateListEvent(
{ kinds: [10003], authors: [pubkey], limit: 1 }, { kinds: [10003], authors: [pubkey], limit: 1 },
(tags) => addTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]), (tags) => addTag(tags, ['e', event.id, conf.relay, '', event.pubkey]),
c, c,
); );
@ -483,6 +488,7 @@ const bookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unbookmark */ /** https://docs.joinmastodon.org/methods/statuses/#unbookmark */
const unbookmarkController: AppController = async (c) => { const unbookmarkController: AppController = async (c) => {
const { conf } = c.var;
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
@ -494,7 +500,7 @@ const unbookmarkController: AppController = async (c) => {
if (event) { if (event) {
await updateListEvent( await updateListEvent(
{ kinds: [10003], authors: [pubkey], limit: 1 }, { kinds: [10003], authors: [pubkey], limit: 1 },
(tags) => deleteTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]), (tags) => deleteTag(tags, ['e', event.id, conf.relay, '', event.pubkey]),
c, c,
); );
@ -510,6 +516,7 @@ const unbookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#pin */ /** https://docs.joinmastodon.org/methods/statuses/#pin */
const pinController: AppController = async (c) => { const pinController: AppController = async (c) => {
const { conf } = c.var;
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
@ -521,7 +528,7 @@ const pinController: AppController = async (c) => {
if (event) { if (event) {
await updateListEvent( await updateListEvent(
{ kinds: [10001], authors: [pubkey], limit: 1 }, { kinds: [10001], authors: [pubkey], limit: 1 },
(tags) => addTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]), (tags) => addTag(tags, ['e', event.id, conf.relay, '', event.pubkey]),
c, c,
); );
@ -537,6 +544,7 @@ const pinController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unpin */ /** https://docs.joinmastodon.org/methods/statuses/#unpin */
const unpinController: AppController = async (c) => { const unpinController: AppController = async (c) => {
const { conf } = c.var;
const pubkey = await c.get('signer')?.getPublicKey()!; const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id'); const eventId = c.req.param('id');
const { signal } = c.req.raw; const { signal } = c.req.raw;
@ -550,7 +558,7 @@ const unpinController: AppController = async (c) => {
if (event) { if (event) {
await updateListEvent( await updateListEvent(
{ kinds: [10001], authors: [pubkey], limit: 1 }, { kinds: [10001], authors: [pubkey], limit: 1 },
(tags) => deleteTag(tags, ['e', event.id, Conf.relay, '', event.pubkey]), (tags) => deleteTag(tags, ['e', event.id, conf.relay, '', event.pubkey]),
c, c,
); );
@ -572,6 +580,7 @@ const zapSchema = z.object({
}); });
const zapController: AppController = async (c) => { const zapController: AppController = async (c) => {
const { conf } = c.var;
const body = await parseBody(c.req.raw); const body = await parseBody(c.req.raw);
const result = zapSchema.safeParse(body); const result = zapSchema.safeParse(body);
const { signal } = c.req.raw; const { signal } = c.req.raw;
@ -594,10 +603,10 @@ const zapController: AppController = async (c) => {
lnurl = getLnurl(meta); lnurl = getLnurl(meta);
if (target && lnurl) { if (target && lnurl) {
tags.push( tags.push(
['e', target.id, Conf.relay], ['e', target.id, conf.relay],
['p', target.pubkey, Conf.relay], ['p', target.pubkey, conf.relay],
['amount', amount.toString()], ['amount', amount.toString()],
['relays', Conf.relay], ['relays', conf.relay],
['lnurl', lnurl], ['lnurl', lnurl],
); );
} }
@ -607,9 +616,9 @@ const zapController: AppController = async (c) => {
lnurl = getLnurl(meta); lnurl = getLnurl(meta);
if (target && lnurl) { if (target && lnurl) {
tags.push( tags.push(
['p', target.pubkey, Conf.relay], ['p', target.pubkey, conf.relay],
['amount', amount.toString()], ['amount', amount.toString()],
['relays', Conf.relay], ['relays', conf.relay],
['lnurl', lnurl], ['lnurl', lnurl],
); );
} }

View file

@ -4,7 +4,6 @@ import { logi } from '@soapbox/logi';
import { z } from 'zod'; import { z } from 'zod';
import { type AppController } from '@/app.ts'; import { type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { import {
streamingClientMessagesCounter, streamingClientMessagesCounter,
streamingConnectionsGauge, streamingConnectionsGauge,
@ -69,6 +68,7 @@ const limiter = new TTLCache<string, number>();
const connections = new Set<WebSocket>(); const connections = new Set<WebSocket>();
const streamingController: AppController = async (c) => { const streamingController: AppController = async (c) => {
const { conf } = c.var;
const upgrade = c.req.header('upgrade'); const upgrade = c.req.header('upgrade');
const token = c.req.header('sec-websocket-protocol'); const token = c.req.header('sec-websocket-protocol');
const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream')); const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream'));
@ -137,7 +137,7 @@ const streamingController: AppController = async (c) => {
streamingConnectionsGauge.set(connections.size); streamingConnectionsGauge.set(connections.size);
if (!stream) return; if (!stream) return;
const topicFilter = await topicToFilter(stream, c.req.query(), pubkey); const topicFilter = await topicToFilter(stream, c.req.query(), pubkey, conf.url.host);
if (topicFilter) { if (topicFilter) {
sub([topicFilter], async (event) => { sub([topicFilter], async (event) => {
@ -208,9 +208,8 @@ async function topicToFilter(
topic: Stream, topic: Stream,
query: Record<string, string>, query: Record<string, string>,
pubkey: string | undefined, pubkey: string | undefined,
host: string,
): Promise<NostrFilter | undefined> { ): Promise<NostrFilter | undefined> {
const { host } = Conf.url;
switch (topic) { switch (topic) {
case 'public': case 'public':
return { kinds: [1, 6, 20] }; return { kinds: [1, 6, 20] };

View file

@ -2,7 +2,6 @@ import { NostrFilter } from '@nostrify/nostrify';
import { matchFilter } from 'nostr-tools'; import { matchFilter } from 'nostr-tools';
import { AppContext, AppController } from '@/app.ts'; import { AppContext, AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
import { paginated, paginatedList } from '@/utils/api.ts'; import { paginated, paginatedList } from '@/utils/api.ts';
import { getTagSet } from '@/utils/tags.ts'; import { getTagSet } from '@/utils/tags.ts';
@ -24,6 +23,7 @@ export const suggestionsV2Controller: AppController = async (c) => {
}; };
async function renderV2Suggestions(c: AppContext, params: { offset: number; limit: number }, signal?: AbortSignal) { async function renderV2Suggestions(c: AppContext, params: { offset: number; limit: number }, signal?: AbortSignal) {
const { conf } = c.var;
const { offset, limit } = params; const { offset, limit } = params;
const store = c.get('store'); const store = c.get('store');
@ -31,8 +31,8 @@ async function renderV2Suggestions(c: AppContext, params: { offset: number; limi
const pubkey = await signer?.getPublicKey(); const pubkey = await signer?.getPublicKey();
const filters: NostrFilter[] = [ const filters: NostrFilter[] = [
{ kinds: [30382], authors: [Conf.pubkey], '#n': ['suggested'], limit }, { kinds: [30382], authors: [conf.pubkey], '#n': ['suggested'], limit },
{ kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#p`], authors: [Conf.pubkey], limit: 1 }, { kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#p`], authors: [conf.pubkey], limit: 1 },
]; ];
if (pubkey) { if (pubkey) {
@ -43,11 +43,11 @@ async function renderV2Suggestions(c: AppContext, params: { offset: number; limi
const events = await store.query(filters, { signal }); const events = await store.query(filters, { signal });
const [userEvents, followsEvent, mutesEvent, trendingEvent] = [ const [userEvents, followsEvent, mutesEvent, trendingEvent] = [
events.filter((event) => matchFilter({ kinds: [30382], authors: [Conf.pubkey], '#n': ['suggested'] }, event)), events.filter((event) => matchFilter({ kinds: [30382], authors: [conf.pubkey], '#n': ['suggested'] }, event)),
pubkey ? events.find((event) => matchFilter({ kinds: [3], authors: [pubkey] }, event)) : undefined, pubkey ? events.find((event) => matchFilter({ kinds: [3], authors: [pubkey] }, event)) : undefined,
pubkey ? events.find((event) => matchFilter({ kinds: [10000], authors: [pubkey] }, event)) : undefined, pubkey ? events.find((event) => matchFilter({ kinds: [10000], authors: [pubkey] }, event)) : undefined,
events.find((event) => events.find((event) =>
matchFilter({ kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#p`], authors: [Conf.pubkey], limit: 1 }, event) matchFilter({ kinds: [1985], '#L': ['pub.ditto.trends'], '#l': [`#p`], authors: [conf.pubkey], limit: 1 }, event)
), ),
]; ];
@ -89,12 +89,13 @@ async function renderV2Suggestions(c: AppContext, params: { offset: number; limi
} }
export const localSuggestionsController: AppController = async (c) => { export const localSuggestionsController: AppController = async (c) => {
const { conf } = c.var;
const signal = c.req.raw.signal; const signal = c.req.raw.signal;
const params = c.get('pagination'); const params = c.get('pagination');
const store = c.get('store'); const store = c.get('store');
const grants = await store.query( const grants = await store.query(
[{ kinds: [30360], authors: [Conf.pubkey], ...params }], [{ kinds: [30360], authors: [conf.pubkey], ...params }],
{ signal }, { signal },
); );
@ -108,20 +109,20 @@ export const localSuggestionsController: AppController = async (c) => {
} }
const profiles = await store.query( const profiles = await store.query(
[{ kinds: [0], authors: [...pubkeys], search: `domain:${Conf.url.host}`, ...params }], [{ kinds: [0], authors: [...pubkeys], search: `domain:${conf.url.host}`, ...params }],
{ signal }, { signal },
) )
.then((events) => hydrateEvents({ store, events, signal })); .then((events) => hydrateEvents({ store, events, signal }));
const suggestions = (await Promise.all([...pubkeys].map(async (pubkey) => { const suggestions = [...pubkeys].map((pubkey) => {
const profile = profiles.find((event) => event.pubkey === pubkey); const profile = profiles.find((event) => event.pubkey === pubkey);
if (!profile) return; if (!profile) return;
return { return {
source: 'global', source: 'global',
account: await renderAccount(profile), account: renderAccount(profile),
}; };
}))).filter(Boolean); }).filter(Boolean);
return paginated(c, grants, suggestions); return paginated(c, grants, suggestions);
}; };

View file

@ -2,7 +2,6 @@ import { NostrFilter } from '@nostrify/nostrify';
import { z } from 'zod'; import { z } from 'zod';
import { type AppContext, type AppController } from '@/app.ts'; import { type AppContext, type AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { getFeedPubkeys } from '@/queries.ts'; import { getFeedPubkeys } from '@/queries.ts';
import { booleanParamSchema, languageSchema } from '@/schema.ts'; import { booleanParamSchema, languageSchema } from '@/schema.ts';
import { hydrateEvents } from '@/storages/hydrate.ts'; import { hydrateEvents } from '@/storages/hydrate.ts';
@ -53,6 +52,7 @@ const publicQuerySchema = z.object({
}); });
const publicTimelineController: AppController = (c) => { const publicTimelineController: AppController = (c) => {
const { conf } = c.var;
const params = c.get('pagination'); const params = c.get('pagination');
const result = publicQuerySchema.safeParse(c.req.query()); const result = publicQuerySchema.safeParse(c.req.query());
@ -67,7 +67,7 @@ const publicTimelineController: AppController = (c) => {
const search: `${string}:${string}`[] = []; const search: `${string}:${string}`[] = [];
if (local) { if (local) {
search.push(`domain:${Conf.url.host}`); search.push(`domain:${conf.url.host}`);
} else if (instance) { } else if (instance) {
search.push(`domain:${instance}`); search.push(`domain:${instance}`);
} }
@ -90,11 +90,12 @@ const hashtagTimelineController: AppController = (c) => {
}; };
const suggestedTimelineController: AppController = async (c) => { const suggestedTimelineController: AppController = async (c) => {
const { conf } = c.var;
const store = c.get('store'); const store = c.get('store');
const params = c.get('pagination'); const params = c.get('pagination');
const [follows] = await store.query( const [follows] = await store.query(
[{ kinds: [3], authors: [Conf.pubkey], limit: 1 }], [{ kinds: [3], authors: [conf.pubkey], limit: 1 }],
); );
const authors = [...getTagSet(follows?.tags ?? [], 'p')]; const authors = [...getTagSet(follows?.tags ?? [], 'p')];
@ -104,9 +105,10 @@ const suggestedTimelineController: AppController = async (c) => {
/** Render statuses for timelines. */ /** Render statuses for timelines. */
async function renderStatuses(c: AppContext, filters: NostrFilter[]) { async function renderStatuses(c: AppContext, filters: NostrFilter[]) {
const { conf } = c.var;
const { signal } = c.req.raw; const { signal } = c.req.raw;
const store = c.get('store'); const store = c.get('store');
const opts = { signal, timeout: Conf.db.timeouts.timelines }; const opts = { signal, timeout: conf.db.timeouts.timelines };
const events = await store const events = await store
.query(filters, opts) .query(filters, opts)

View file

@ -1,3 +1,4 @@
import { type DittoConf } from '@ditto/conf';
import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify'; import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { z } from 'zod'; import { z } from 'zod';
@ -13,7 +14,7 @@ import { paginated } from '@/utils/api.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
let trendingHashtagsCache = getTrendingHashtags().catch((e: unknown) => { let trendingHashtagsCache = getTrendingHashtags(Conf).catch((e: unknown) => {
logi({ logi({
level: 'error', level: 'error',
ns: 'ditto.trends.api', ns: 'ditto.trends.api',
@ -26,7 +27,7 @@ let trendingHashtagsCache = getTrendingHashtags().catch((e: unknown) => {
Deno.cron('update trending hashtags cache', '35 * * * *', async () => { Deno.cron('update trending hashtags cache', '35 * * * *', async () => {
try { try {
const trends = await getTrendingHashtags(); const trends = await getTrendingHashtags(Conf);
trendingHashtagsCache = Promise.resolve(trends); trendingHashtagsCache = Promise.resolve(trends);
} catch (e) { } catch (e) {
logi({ logi({
@ -50,9 +51,9 @@ const trendingTagsController: AppController = async (c) => {
return c.json(trends.slice(offset, offset + limit)); return c.json(trends.slice(offset, offset + limit));
}; };
async function getTrendingHashtags() { async function getTrendingHashtags(conf: DittoConf) {
const store = await Storages.db(); const store = await Storages.db();
const trends = await getTrendingTags(store, 't'); const trends = await getTrendingTags(store, 't', conf.pubkey);
return trends.map((trend) => { return trends.map((trend) => {
const hashtag = trend.value; const hashtag = trend.value;
@ -65,13 +66,13 @@ async function getTrendingHashtags() {
return { return {
name: hashtag, name: hashtag,
url: Conf.local(`/tags/${hashtag}`), url: conf.local(`/tags/${hashtag}`),
history, history,
}; };
}); });
} }
let trendingLinksCache = getTrendingLinks().catch((e: unknown) => { let trendingLinksCache = getTrendingLinks(Conf).catch((e: unknown) => {
logi({ logi({
level: 'error', level: 'error',
ns: 'ditto.trends.api', ns: 'ditto.trends.api',
@ -84,7 +85,7 @@ let trendingLinksCache = getTrendingLinks().catch((e: unknown) => {
Deno.cron('update trending links cache', '50 * * * *', async () => { Deno.cron('update trending links cache', '50 * * * *', async () => {
try { try {
const trends = await getTrendingLinks(); const trends = await getTrendingLinks(Conf);
trendingLinksCache = Promise.resolve(trends); trendingLinksCache = Promise.resolve(trends);
} catch (e) { } catch (e) {
logi({ logi({
@ -103,9 +104,9 @@ const trendingLinksController: AppController = async (c) => {
return c.json(trends.slice(offset, offset + limit)); return c.json(trends.slice(offset, offset + limit));
}; };
async function getTrendingLinks() { async function getTrendingLinks(conf: DittoConf) {
const store = await Storages.db(); const store = await Storages.db();
const trends = await getTrendingTags(store, 'r'); const trends = await getTrendingTags(store, 'r', conf.pubkey);
return Promise.all(trends.map(async (trend) => { return Promise.all(trends.map(async (trend) => {
const link = trend.value; const link = trend.value;
@ -139,6 +140,7 @@ async function getTrendingLinks() {
} }
const trendingStatusesController: AppController = async (c) => { const trendingStatusesController: AppController = async (c) => {
const { conf } = c.var;
const store = await Storages.db(); const store = await Storages.db();
const { limit, offset, until } = paginationSchema.parse(c.req.query()); const { limit, offset, until } = paginationSchema.parse(c.req.query());
@ -146,7 +148,7 @@ const trendingStatusesController: AppController = async (c) => {
kinds: [1985], kinds: [1985],
'#L': ['pub.ditto.trends'], '#L': ['pub.ditto.trends'],
'#l': ['#e'], '#l': ['#e'],
authors: [Conf.pubkey], authors: [conf.pubkey],
until, until,
limit: 1, limit: 1,
}]); }]);
@ -185,12 +187,12 @@ interface TrendingTag {
}[]; }[];
} }
export async function getTrendingTags(store: NStore, tagName: string): Promise<TrendingTag[]> { export async function getTrendingTags(store: NStore, tagName: string, pubkey: string): Promise<TrendingTag[]> {
const [label] = await store.query([{ const [label] = await store.query([{
kinds: [1985], kinds: [1985],
'#L': ['pub.ditto.trends'], '#L': ['pub.ditto.trends'],
'#l': [`#${tagName}`], '#l': [`#${tagName}`],
authors: [Conf.pubkey], authors: [pubkey],
limit: 1, limit: 1,
}]); }]);
@ -213,7 +215,7 @@ export async function getTrendingTags(store: NStore, tagName: string): Promise<T
'#L': ['pub.ditto.trends'], '#L': ['pub.ditto.trends'],
'#l': [`#${tagName}`], '#l': [`#${tagName}`],
[`#${tagName}`]: [value], [`#${tagName}`]: [value],
authors: [Conf.pubkey], authors: [pubkey],
since: Math.floor(date.getTime() / 1000), since: Math.floor(date.getTime() / 1000),
until: Math.floor((date.getTime() + Time.days(1)) / 1000), until: Math.floor((date.getTime() + Time.days(1)) / 1000),
limit: 1, limit: 1,

View file

@ -1,11 +1,11 @@
import denoJson from 'deno.json' with { type: 'json' }; import denoJson from 'deno.json' with { type: 'json' };
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { getInstanceMetadata } from '@/utils/instance.ts'; import { getInstanceMetadata } from '@/utils/instance.ts';
const relayInfoController: AppController = async (c) => { const relayInfoController: AppController = async (c) => {
const { conf } = c.var;
const store = await Storages.db(); const store = await Storages.db();
const meta = await getInstanceMetadata(store, c.req.raw.signal); const meta = await getInstanceMetadata(store, c.req.raw.signal);
@ -14,7 +14,7 @@ const relayInfoController: AppController = async (c) => {
return c.json({ return c.json({
name: meta.name, name: meta.name,
description: meta.about, description: meta.about,
pubkey: Conf.pubkey, pubkey: conf.pubkey,
contact: meta.email, contact: meta.email,
supported_nips: [1, 5, 9, 11, 16, 45, 50, 46, 98], supported_nips: [1, 5, 9, 11, 16, 45, 50, 46, 98],
software: 'Ditto', software: 'Ditto',

View file

@ -1,3 +1,4 @@
import { type DittoConf } from '@ditto/conf';
import { logi } from '@soapbox/logi'; import { logi } from '@soapbox/logi';
import { JsonValue } from '@std/json'; import { JsonValue } from '@std/json';
import { import {
@ -12,7 +13,6 @@ import {
} from '@nostrify/nostrify'; } from '@nostrify/nostrify';
import { AppController } from '@/app.ts'; import { AppController } from '@/app.ts';
import { Conf } from '@/config.ts';
import { relayInfoController } from '@/controllers/nostr/relay-info.ts'; import { relayInfoController } from '@/controllers/nostr/relay-info.ts';
import { relayConnectionsGauge, relayEventsCounter, relayMessagesCounter } from '@/metrics.ts'; import { relayConnectionsGauge, relayEventsCounter, relayMessagesCounter } from '@/metrics.ts';
import * as pipeline from '@/pipeline.ts'; import * as pipeline from '@/pipeline.ts';
@ -47,7 +47,7 @@ const limiters = {
const connections = new Set<WebSocket>(); const connections = new Set<WebSocket>();
/** Set up the Websocket connection. */ /** Set up the Websocket connection. */
function connectStream(socket: WebSocket, ip: string | undefined) { function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoConf) {
const controllers = new Map<string, AbortController>(); const controllers = new Map<string, AbortController>();
socket.onopen = () => { socket.onopen = () => {
@ -126,7 +126,7 @@ function connectStream(socket: WebSocket, ip: string | undefined) {
const pubsub = await Storages.pubsub(); const pubsub = await Storages.pubsub();
try { try {
for (const event of await store.query(filters, { limit: FILTER_LIMIT, timeout: Conf.db.timeouts.relay })) { for (const event of await store.query(filters, { limit: FILTER_LIMIT, timeout: conf.db.timeouts.relay })) {
send(['EVENT', subId, purifyEvent(event)]); send(['EVENT', subId, purifyEvent(event)]);
} }
} catch (e) { } catch (e) {
@ -188,7 +188,7 @@ function connectStream(socket: WebSocket, ip: string | undefined) {
async function handleCount([_, subId, ...filters]: NostrClientCOUNT): Promise<void> { async function handleCount([_, subId, ...filters]: NostrClientCOUNT): Promise<void> {
if (rateLimited(limiters.req)) return; if (rateLimited(limiters.req)) return;
const store = await Storages.db(); const store = await Storages.db();
const { count } = await store.count(filters, { timeout: Conf.db.timeouts.relay }); const { count } = await store.count(filters, { timeout: conf.db.timeouts.relay });
send(['COUNT', subId, { count, approximate: false }]); send(['COUNT', subId, { count, approximate: false }]);
} }
@ -201,6 +201,7 @@ function connectStream(socket: WebSocket, ip: string | undefined) {
} }
const relayController: AppController = (c, next) => { const relayController: AppController = (c, next) => {
const { conf } = c.var;
const upgrade = c.req.header('upgrade'); const upgrade = c.req.header('upgrade');
// NIP-11: https://github.com/nostr-protocol/nips/blob/master/11.md // NIP-11: https://github.com/nostr-protocol/nips/blob/master/11.md
@ -214,7 +215,7 @@ const relayController: AppController = (c, next) => {
let ip = c.req.header('x-real-ip'); let ip = c.req.header('x-real-ip');
if (ip && Conf.ipWhitelist.includes(ip)) { if (ip && conf.ipWhitelist.includes(ip)) {
ip = undefined; ip = undefined;
} }
@ -229,7 +230,7 @@ const relayController: AppController = (c, next) => {
} }
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { idleTimeout: 30 }); const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { idleTimeout: 30 });
connectStream(socket, ip); connectStream(socket, ip, conf);
return response; return response;
}; };

View file

@ -1,17 +1,17 @@
import { Conf } from '@/config.ts';
import type { AppController } from '@/app.ts'; import type { AppController } from '@/app.ts';
const nodeInfoController: AppController = (c) => { const nodeInfoController: AppController = (c) => {
const { conf } = c.var;
return c.json({ return c.json({
links: [ links: [
{ {
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0', rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0',
href: Conf.local('/nodeinfo/2.0'), href: conf.local('/nodeinfo/2.0'),
}, },
{ {
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
href: Conf.local('/nodeinfo/2.1'), href: conf.local('/nodeinfo/2.1'),
}, },
], ],
}); });

View file

@ -11,7 +11,6 @@ import {
type ParseAuthRequestOpts, type ParseAuthRequestOpts,
validateAuthEvent, validateAuthEvent,
} from '@/utils/nip98.ts'; } from '@/utils/nip98.ts';
import { Conf } from '@/config.ts';
/** /**
* NIP-98 auth. * NIP-98 auth.
@ -35,12 +34,13 @@ type UserRole = 'user' | 'admin';
/** Require the user to prove their role before invoking the controller. */ /** Require the user to prove their role before invoking the controller. */
function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware { function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware {
return withProof(async (_c, proof, next) => { return withProof(async (c, proof, next) => {
const { conf } = c.var;
const store = await Storages.db(); const store = await Storages.db();
const [user] = await store.query([{ const [user] = await store.query([{
kinds: [30382], kinds: [30382],
authors: [Conf.pubkey], authors: [conf.pubkey],
'#d': [proof.pubkey], '#d': [proof.pubkey],
limit: 1, limit: 1,
}]); }]);

View file

@ -1,5 +1,4 @@
import { AppMiddleware } from '@/app.ts'; import { AppMiddleware } from '@/app.ts';
import { Conf } from '@/config.ts';
import { PleromaConfigDB } from '@/utils/PleromaConfigDB.ts'; import { PleromaConfigDB } from '@/utils/PleromaConfigDB.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
import { getPleromaConfigs } from '@/utils/pleroma.ts'; import { getPleromaConfigs } from '@/utils/pleroma.ts';
@ -8,13 +7,14 @@ let configDBCache: Promise<PleromaConfigDB> | undefined;
export const cspMiddleware = (): AppMiddleware => { export const cspMiddleware = (): AppMiddleware => {
return async (c, next) => { return async (c, next) => {
const { conf } = c.var;
const store = await Storages.db(); const store = await Storages.db();
if (!configDBCache) { if (!configDBCache) {
configDBCache = getPleromaConfigs(store); configDBCache = getPleromaConfigs(store);
} }
const { host, protocol, origin } = Conf.url; const { host, protocol, origin } = conf.url;
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:'; const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
const configDB = await configDBCache; const configDB = await configDBCache;
const sentryDsn = configDB.getIn(':pleroma', ':frontend_configurations', ':soapbox_fe', 'sentryDsn'); const sentryDsn = configDB.getIn(':pleroma', ':frontend_configurations', ':soapbox_fe', 'sentryDsn');

View file

@ -1,14 +1,13 @@
import { type DittoConf } from '@ditto/conf';
import { MiddlewareHandler } from '@hono/hono'; import { MiddlewareHandler } from '@hono/hono';
import { rateLimiter } from 'hono-rate-limiter'; import { rateLimiter } from 'hono-rate-limiter';
import { Conf } from '@/config.ts';
/** /**
* Rate limit middleware for Hono, based on [`hono-rate-limiter`](https://github.com/rhinobase/hono-rate-limiter). * Rate limit middleware for Hono, based on [`hono-rate-limiter`](https://github.com/rhinobase/hono-rate-limiter).
*/ */
export function rateLimitMiddleware(limit: number, windowMs: number, includeHeaders?: boolean): MiddlewareHandler { export function rateLimitMiddleware(limit: number, windowMs: number, includeHeaders?: boolean): MiddlewareHandler {
// @ts-ignore Mismatched hono versions. // @ts-ignore Mismatched hono versions.
return rateLimiter({ return rateLimiter<{ Variables: { conf: DittoConf } }>({
limit, limit,
windowMs, windowMs,
standardHeaders: includeHeaders, standardHeaders: includeHeaders,
@ -17,8 +16,9 @@ export function rateLimitMiddleware(limit: number, windowMs: number, includeHead
return c.text('Too many requests, please try again later.', 429); return c.text('Too many requests, please try again later.', 429);
}, },
skip: (c) => { skip: (c) => {
const { conf } = c.var;
const ip = c.req.header('x-real-ip'); const ip = c.req.header('x-real-ip');
return !ip || Conf.ipWhitelist.includes(ip); return !ip || conf.ipWhitelist.includes(ip);
}, },
keyGenerator: (c) => c.req.header('x-real-ip')!, keyGenerator: (c) => c.req.header('x-real-ip')!,
}); });

View file

@ -1,9 +1,9 @@
import { type DittoConf } from '@ditto/conf';
import { MiddlewareHandler } from '@hono/hono'; import { MiddlewareHandler } from '@hono/hono';
import { HTTPException } from '@hono/hono/http-exception'; import { HTTPException } from '@hono/hono/http-exception';
import { NostrSigner, NSecSigner } from '@nostrify/nostrify'; import { NostrSigner, NSecSigner } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { Conf } from '@/config.ts';
import { ConnectSigner } from '@/signers/ConnectSigner.ts'; import { ConnectSigner } from '@/signers/ConnectSigner.ts';
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts'; import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
import { Storages } from '@/storages.ts'; import { Storages } from '@/storages.ts';
@ -14,7 +14,11 @@ import { getTokenHash } from '@/utils/auth.ts';
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`); const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
/** Make a `signer` object available to all controllers, or unset if the user isn't logged in. */ /** Make a `signer` object available to all controllers, or unset if the user isn't logged in. */
export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSigner } }> = async (c, next) => { export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSigner; conf: DittoConf } }> = async (
c,
next,
) => {
const { conf } = c.var;
const header = c.req.header('authorization'); const header = c.req.header('authorization');
const match = header?.match(BEARER_REGEX); const match = header?.match(BEARER_REGEX);
@ -32,7 +36,7 @@ export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSig
.where('token_hash', '=', tokenHash) .where('token_hash', '=', tokenHash)
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
const nep46Seckey = await aesDecrypt(Conf.seckey, nip46_sk_enc); const nep46Seckey = await aesDecrypt(conf.seckey, nip46_sk_enc);
c.set( c.set(
'signer', 'signer',

View file

@ -1,4 +1,5 @@
import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts'; import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts';
import { type DittoConf } from '@ditto/conf';
import { MiddlewareHandler } from '@hono/hono'; import { MiddlewareHandler } from '@hono/hono';
import { HTTPException } from '@hono/hono/http-exception'; import { HTTPException } from '@hono/hono/http-exception';
import { getPublicKey } from 'nostr-tools'; import { getPublicKey } from 'nostr-tools';
@ -9,7 +10,6 @@ import { logi } from '@soapbox/logi';
import { isNostrId } from '@/utils.ts'; import { isNostrId } from '@/utils.ts';
import { errorJson } from '@/utils/log.ts'; import { errorJson } from '@/utils/log.ts';
import { Conf } from '@/config.ts';
import { createEvent } from '@/utils/api.ts'; import { createEvent } from '@/utils/api.ts';
import { z } from 'zod'; import { z } from 'zod';
@ -18,8 +18,9 @@ import { z } from 'zod';
* Errors are only thrown if 'signer' and 'store' middlewares are not set. * Errors are only thrown if 'signer' and 'store' middlewares are not set.
*/ */
export const swapNutzapsMiddleware: MiddlewareHandler< export const swapNutzapsMiddleware: MiddlewareHandler<
{ Variables: { signer: SetRequired<NostrSigner, 'nip44'>; store: NStore } } { Variables: { signer: SetRequired<NostrSigner, 'nip44'>; store: NStore; conf: DittoConf } }
> = async (c, next) => { > = async (c, next) => {
const { conf } = c.var;
const signer = c.get('signer'); const signer = c.get('signer');
const store = c.get('store'); const store = c.get('store');
@ -133,7 +134,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
[ [
'e', // nutzap event that has been redeemed 'e', // nutzap event that has been redeemed
event.id, event.id,
Conf.relay, conf.relay,
'redeemed', 'redeemed',
], ],
['p', event.pubkey], // pubkey of the author of the 9321 event (nutzap sender) ['p', event.pubkey], // pubkey of the author of the 9321 event (nutzap sender)
@ -173,7 +174,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
JSON.stringify([ JSON.stringify([
['direction', 'in'], ['direction', 'in'],
['amount', amount], ['amount', amount],
['e', unspentProofs.id, Conf.relay, 'created'], ['e', unspentProofs.id, conf.relay, 'created'],
]), ]),
), ),
tags: mintsToProofs[mint].redeemed, tags: mintsToProofs[mint].redeemed,

View file

@ -1,15 +1,16 @@
import { safeFetch } from '@soapbox/safe-fetch'; import { safeFetch } from '@soapbox/safe-fetch';
import { AppMiddleware } from '@/app.ts'; import { AppMiddleware } from '@/app.ts';
import { Conf } from '@/config.ts';
import { DeepLTranslator } from '@/translators/DeepLTranslator.ts'; import { DeepLTranslator } from '@/translators/DeepLTranslator.ts';
import { LibreTranslateTranslator } from '@/translators/LibreTranslateTranslator.ts'; import { LibreTranslateTranslator } from '@/translators/LibreTranslateTranslator.ts';
/** Set the translator used for translating posts. */ /** Set the translator used for translating posts. */
export const translatorMiddleware: AppMiddleware = async (c, next) => { export const translatorMiddleware: AppMiddleware = async (c, next) => {
switch (Conf.translationProvider) { const { conf } = c.var;
switch (conf.translationProvider) {
case 'deepl': { case 'deepl': {
const { deeplApiKey: apiKey, deeplBaseUrl: baseUrl } = Conf; const { deeplApiKey: apiKey, deeplBaseUrl: baseUrl } = conf;
if (apiKey) { if (apiKey) {
c.set('translator', new DeepLTranslator({ baseUrl, apiKey, fetch: safeFetch })); c.set('translator', new DeepLTranslator({ baseUrl, apiKey, fetch: safeFetch }));
} }
@ -17,7 +18,7 @@ export const translatorMiddleware: AppMiddleware = async (c, next) => {
} }
case 'libretranslate': { case 'libretranslate': {
const { libretranslateApiKey: apiKey, libretranslateBaseUrl: baseUrl } = Conf; const { libretranslateApiKey: apiKey, libretranslateBaseUrl: baseUrl } = conf;
if (apiKey) { if (apiKey) {
c.set('translator', new LibreTranslateTranslator({ baseUrl, apiKey, fetch: safeFetch })); c.set('translator', new LibreTranslateTranslator({ baseUrl, apiKey, fetch: safeFetch }));
} }

View file

@ -2,44 +2,44 @@ import { BlossomUploader, NostrBuildUploader } from '@nostrify/nostrify/uploader
import { safeFetch } from '@soapbox/safe-fetch'; import { safeFetch } from '@soapbox/safe-fetch';
import { AppMiddleware } from '@/app.ts'; import { AppMiddleware } from '@/app.ts';
import { Conf } from '@/config.ts';
import { DenoUploader } from '@/uploaders/DenoUploader.ts'; import { DenoUploader } from '@/uploaders/DenoUploader.ts';
import { IPFSUploader } from '@/uploaders/IPFSUploader.ts'; import { IPFSUploader } from '@/uploaders/IPFSUploader.ts';
import { S3Uploader } from '@/uploaders/S3Uploader.ts'; import { S3Uploader } from '@/uploaders/S3Uploader.ts';
/** Set an uploader for the user. */ /** Set an uploader for the user. */
export const uploaderMiddleware: AppMiddleware = async (c, next) => { export const uploaderMiddleware: AppMiddleware = async (c, next) => {
const signer = c.get('signer'); const { signer, conf } = c.var;
switch (Conf.uploader) { switch (conf.uploader) {
case 's3': case 's3':
c.set( c.set(
'uploader', 'uploader',
new S3Uploader({ new S3Uploader({
accessKey: Conf.s3.accessKey, accessKey: conf.s3.accessKey,
bucket: Conf.s3.bucket, bucket: conf.s3.bucket,
endPoint: Conf.s3.endPoint!, endPoint: conf.s3.endPoint!,
pathStyle: Conf.s3.pathStyle, pathStyle: conf.s3.pathStyle,
port: Conf.s3.port, port: conf.s3.port,
region: Conf.s3.region!, region: conf.s3.region!,
secretKey: Conf.s3.secretKey, secretKey: conf.s3.secretKey,
sessionToken: Conf.s3.sessionToken, sessionToken: conf.s3.sessionToken,
useSSL: Conf.s3.useSSL, useSSL: conf.s3.useSSL,
baseUrl: conf.mediaDomain,
}), }),
); );
break; break;
case 'ipfs': case 'ipfs':
c.set('uploader', new IPFSUploader({ baseUrl: Conf.mediaDomain, apiUrl: Conf.ipfs.apiUrl, fetch: safeFetch })); c.set('uploader', new IPFSUploader({ baseUrl: conf.mediaDomain, apiUrl: conf.ipfs.apiUrl, fetch: safeFetch }));
break; break;
case 'local': case 'local':
c.set('uploader', new DenoUploader({ baseUrl: Conf.mediaDomain, dir: Conf.uploadsDir })); c.set('uploader', new DenoUploader({ baseUrl: conf.mediaDomain, dir: conf.uploadsDir }));
break; break;
case 'nostrbuild': case 'nostrbuild':
c.set('uploader', new NostrBuildUploader({ endpoint: Conf.nostrbuildEndpoint, signer, fetch: safeFetch })); c.set('uploader', new NostrBuildUploader({ endpoint: conf.nostrbuildEndpoint, signer, fetch: safeFetch }));
break; break;
case 'blossom': case 'blossom':
if (signer) { if (signer) {
c.set('uploader', new BlossomUploader({ servers: Conf.blossomServers, signer, fetch: safeFetch })); c.set('uploader', new BlossomUploader({ servers: conf.blossomServers, signer, fetch: safeFetch }));
} }
break; break;
} }

View file

@ -6,8 +6,6 @@ import { crypto } from '@std/crypto';
import { encodeHex } from '@std/encoding/hex'; import { encodeHex } from '@std/encoding/hex';
import { extensionsByType } from '@std/media-types'; import { extensionsByType } from '@std/media-types';
import { Conf } from '@/config.ts';
export interface S3UploaderOpts { export interface S3UploaderOpts {
endPoint: string; endPoint: string;
region: string; region: string;
@ -18,13 +16,14 @@ export interface S3UploaderOpts {
port?: number; port?: number;
sessionToken?: string; sessionToken?: string;
useSSL?: boolean; useSSL?: boolean;
baseUrl?: string;
} }
/** S3-compatible uploader for AWS, Wasabi, DigitalOcean Spaces, and more. */ /** S3-compatible uploader for AWS, Wasabi, DigitalOcean Spaces, and more. */
export class S3Uploader implements NUploader { export class S3Uploader implements NUploader {
private client: S3Client; private client: S3Client;
constructor(opts: S3UploaderOpts) { constructor(private opts: S3UploaderOpts) {
this.client = new S3Client(opts); this.client = new S3Client(opts);
} }
@ -40,10 +39,10 @@ export class S3Uploader implements NUploader {
}, },
}); });
const { pathStyle, bucket } = Conf.s3; const { pathStyle, bucket, baseUrl } = this.opts;
const path = (pathStyle && bucket) ? join(bucket, filename) : filename; const path = (pathStyle && bucket) ? join(bucket, filename) : filename;
const url = new URL(path, Conf.mediaDomain).toString(); const url = new URL(path, baseUrl).toString();
return [ return [
['url', url], ['url', url],