mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Merge branch 'rewrite' into 'main'
Draft: Big Refactor See merge request soapbox-pub/ditto!670
This commit is contained in:
commit
9cac7b6866
201 changed files with 3735 additions and 3229 deletions
|
|
@ -2,6 +2,7 @@
|
|||
"version": "1.1.0",
|
||||
"workspace": [
|
||||
"./packages/api",
|
||||
"./packages/auth",
|
||||
"./packages/conf",
|
||||
"./packages/db",
|
||||
"./packages/ditto",
|
||||
|
|
@ -9,8 +10,11 @@
|
|||
"./packages/metrics",
|
||||
"./packages/policies",
|
||||
"./packages/ratelimiter",
|
||||
"./packages/signers",
|
||||
"./packages/storages",
|
||||
"./packages/translators",
|
||||
"./packages/uploaders"
|
||||
"./packages/uploaders",
|
||||
"./packages/utils"
|
||||
],
|
||||
"tasks": {
|
||||
"start": "deno run -A --env-file --deny-read=.env packages/ditto/server.ts",
|
||||
|
|
|
|||
11
deno.lock
generated
11
deno.lock
generated
|
|
@ -99,6 +99,7 @@
|
|||
"npm:@scure/base@^1.1.6": "1.1.6",
|
||||
"npm:@scure/bip32@^1.4.0": "1.4.0",
|
||||
"npm:@scure/bip39@^1.3.0": "1.3.0",
|
||||
"npm:@types/http-link-header@*": "1.0.7",
|
||||
"npm:@types/node@*": "22.5.4",
|
||||
"npm:blurhash@2.0.5": "2.0.5",
|
||||
"npm:comlink-async-generator@*": "0.0.1",
|
||||
|
|
@ -109,6 +110,7 @@
|
|||
"npm:fast-stable-stringify@1": "1.0.0",
|
||||
"npm:formdata-helper@0.3": "0.3.0",
|
||||
"npm:hono-rate-limiter@0.3": "0.3.0_hono@4.2.5",
|
||||
"npm:http-link-header@*": "1.1.3",
|
||||
"npm:iso-639-1@^3.1.5": "3.1.5",
|
||||
"npm:isomorphic-dompurify@^2.16.0": "2.16.0",
|
||||
"npm:kysely-postgres-js@2.0.0": "2.0.0_kysely@0.27.3_postgres@3.4.4",
|
||||
|
|
@ -994,6 +996,12 @@
|
|||
"@types/trusted-types"
|
||||
]
|
||||
},
|
||||
"@types/http-link-header@1.0.7": {
|
||||
"integrity": "sha512-snm5oLckop0K3cTDAiBnZDy6ncx9DJ3mCRDvs42C884MbVYPP74Tiq2hFsSDRTyjK6RyDYDIulPiW23ge+g5Lw==",
|
||||
"dependencies": [
|
||||
"@types/node"
|
||||
]
|
||||
},
|
||||
"@types/node@22.5.4": {
|
||||
"integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==",
|
||||
"dependencies": [
|
||||
|
|
@ -1255,6 +1263,9 @@
|
|||
"entities"
|
||||
]
|
||||
},
|
||||
"http-link-header@1.1.3": {
|
||||
"integrity": "sha512-3cZ0SRL8fb9MUlU3mKM61FcQvPfXx2dBrZW3Vbg5CXa8jFlK8OaEpePenLe1oEXQduhz8b0QjsqfS59QP4AJDQ=="
|
||||
},
|
||||
"http-proxy-agent@7.0.2": {
|
||||
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
|
||||
"dependencies": [
|
||||
|
|
|
|||
23
packages/api/DittoApp.test.ts
Normal file
23
packages/api/DittoApp.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { DittoConf } from '@ditto/conf';
|
||||
import { DittoDB } from '@ditto/db';
|
||||
import { Hono } from '@hono/hono';
|
||||
import { MockRelay } from '@nostrify/nostrify/test';
|
||||
|
||||
import { DittoApp } from './DittoApp.ts';
|
||||
import { DittoRoute } from './DittoRoute.ts';
|
||||
|
||||
Deno.test('DittoApp', async () => {
|
||||
await using db = DittoDB.create('memory://');
|
||||
const conf = new DittoConf(new Map());
|
||||
const relay = new MockRelay();
|
||||
|
||||
const app = new DittoApp({ conf, db, relay });
|
||||
|
||||
const hono = new Hono();
|
||||
const route = new DittoRoute();
|
||||
|
||||
app.route('/', route);
|
||||
|
||||
// @ts-expect-error Passing a non-DittoRoute to route.
|
||||
app.route('/', hono);
|
||||
});
|
||||
21
packages/api/DittoApp.ts
Normal file
21
packages/api/DittoApp.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { Hono } from '@hono/hono';
|
||||
|
||||
import type { HonoOptions } from '@hono/hono/hono-base';
|
||||
import type { DittoEnv } from './DittoEnv.ts';
|
||||
|
||||
export class DittoApp extends Hono<DittoEnv> {
|
||||
// @ts-ignore Require a DittoRoute for type safety.
|
||||
declare route: (path: string, app: Hono<DittoEnv>) => Hono<DittoEnv>;
|
||||
|
||||
constructor(vars: Omit<DittoEnv['Variables'], 'signal'>, opts: HonoOptions<DittoEnv> = {}) {
|
||||
super(opts);
|
||||
|
||||
this.use((c, next) => {
|
||||
c.set('db', vars.db);
|
||||
c.set('conf', vars.conf);
|
||||
c.set('relay', vars.relay);
|
||||
c.set('signal', c.req.raw.signal);
|
||||
return next();
|
||||
});
|
||||
}
|
||||
}
|
||||
20
packages/api/DittoEnv.ts
Normal file
20
packages/api/DittoEnv.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import type { DittoConf } from '@ditto/conf';
|
||||
import type { DittoDatabase } from '@ditto/db';
|
||||
import type { Env } from '@hono/hono';
|
||||
import type { NRelay } from '@nostrify/nostrify';
|
||||
|
||||
export interface DittoEnv extends Env {
|
||||
Variables: {
|
||||
/** Ditto site configuration. */
|
||||
conf: DittoConf;
|
||||
/** Relay store. */
|
||||
relay: NRelay;
|
||||
/**
|
||||
* Database object.
|
||||
* @deprecated Store data as Nostr events instead.
|
||||
*/
|
||||
db: DittoDatabase;
|
||||
/** Abort signal for the request. */
|
||||
signal: AbortSignal;
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { NostrEvent } from '@nostrify/nostrify';
|
||||
import { LanguageCode } from 'iso-639-1';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { LanguageCode } from 'iso-639-1';
|
||||
|
||||
/** Ditto internal stats for the event's author. */
|
||||
export interface AuthorStats {
|
||||
5
packages/api/DittoMiddleware.ts
Normal file
5
packages/api/DittoMiddleware.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { MiddlewareHandler } from '@hono/hono';
|
||||
import type { DittoEnv } from './DittoEnv.ts';
|
||||
|
||||
// deno-lint-ignore ban-types
|
||||
export type DittoMiddleware<T extends {}> = MiddlewareHandler<DittoEnv & { Variables: T }>;
|
||||
12
packages/api/DittoRoute.test.ts
Normal file
12
packages/api/DittoRoute.test.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import { DittoRoute } from './DittoRoute.ts';
|
||||
|
||||
Deno.test('DittoRoute', async () => {
|
||||
const route = new DittoRoute();
|
||||
const response = await route.request('/');
|
||||
const body = await response.json();
|
||||
|
||||
assertEquals(response.status, 500);
|
||||
assertEquals(body, { error: 'Missing required variable: db' });
|
||||
});
|
||||
53
packages/api/DittoRoute.ts
Normal file
53
packages/api/DittoRoute.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { type ErrorHandler, Hono } from '@hono/hono';
|
||||
import { HTTPException } from '@hono/hono/http-exception';
|
||||
|
||||
import type { HonoOptions } from '@hono/hono/hono-base';
|
||||
import type { DittoEnv } from './DittoEnv.ts';
|
||||
|
||||
/**
|
||||
* Ditto base route class.
|
||||
* Ensures that required variables are set for type safety.
|
||||
*/
|
||||
export class DittoRoute extends Hono<DittoEnv> {
|
||||
constructor(opts: HonoOptions<DittoEnv> = {}) {
|
||||
super(opts);
|
||||
|
||||
this.use((c, next) => {
|
||||
this.assertVars(c.var);
|
||||
return next();
|
||||
});
|
||||
|
||||
this.onError(this._errorHandler);
|
||||
}
|
||||
|
||||
private assertVars(vars: Partial<DittoEnv['Variables']>): DittoEnv['Variables'] {
|
||||
if (!vars.db) this.throwMissingVar('db');
|
||||
if (!vars.conf) this.throwMissingVar('conf');
|
||||
if (!vars.store) this.throwMissingVar('store');
|
||||
if (!vars.signal) this.throwMissingVar('signal');
|
||||
|
||||
return {
|
||||
...vars,
|
||||
db: vars.db,
|
||||
conf: vars.conf,
|
||||
store: vars.store,
|
||||
signal: vars.signal,
|
||||
};
|
||||
}
|
||||
|
||||
private throwMissingVar(name: string): never {
|
||||
throw new HTTPException(500, { message: `Missing required variable: ${name}` });
|
||||
}
|
||||
|
||||
private _errorHandler: ErrorHandler = (error, c) => {
|
||||
if (error instanceof HTTPException) {
|
||||
if (error.res) {
|
||||
return error.res;
|
||||
} else {
|
||||
return c.json({ error: error.message }, error.status);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ error: 'Something went wrong' }, 500);
|
||||
};
|
||||
}
|
||||
|
|
@ -2,6 +2,11 @@
|
|||
"name": "@ditto/api",
|
||||
"version": "1.1.0",
|
||||
"exports": {
|
||||
"./middleware": "./middleware/mod.ts"
|
||||
".": "./mod.ts",
|
||||
"./middleware": "./middleware/mod.ts",
|
||||
"./pagination": "./pagination/mod.ts",
|
||||
"./routes": "./routes/mod.ts",
|
||||
"./schema": "./schema.ts",
|
||||
"./views": "./views/mod.ts"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
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');
|
||||
});
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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();
|
||||
};
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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();
|
||||
};
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
export { confMw } from './confMw.ts';
|
||||
export { confRequiredMw } from './confRequiredMw.ts';
|
||||
export { paginationMiddleware } from './paginationMiddleware.ts';
|
||||
export { requireVar } from './requireVar.ts';
|
||||
export { userMiddleware } from './userMiddleware.ts';
|
||||
|
|
|
|||
80
packages/api/middleware/paginationMiddleware.ts
Normal file
80
packages/api/middleware/paginationMiddleware.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { paginated, paginatedList, paginationSchema } from '@ditto/api/pagination';
|
||||
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import type { DittoMiddleware } from '../DittoMiddleware.ts';
|
||||
|
||||
interface Pagination {
|
||||
since?: number;
|
||||
until?: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
interface ListPagination {
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
type HeaderRecord = Record<string, string | string[]>;
|
||||
type PaginateFn = (events: NostrEvent[], body: object | unknown[], headers?: HeaderRecord) => Response;
|
||||
type ListPaginateFn = (params: ListPagination, body: object | unknown[], headers?: HeaderRecord) => Response;
|
||||
|
||||
/** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */
|
||||
// @ts-ignore Types are right.
|
||||
export function paginationMiddleware(): DittoMiddleware<{ pagination: Pagination; paginate: PaginateFn }>;
|
||||
export function paginationMiddleware(
|
||||
type: 'list',
|
||||
): DittoMiddleware<{ pagination: ListPagination; paginate: ListPaginateFn }>;
|
||||
export function paginationMiddleware(
|
||||
type?: string,
|
||||
): DittoMiddleware<{ pagination?: Pagination | ListPagination; paginate: PaginateFn | ListPaginateFn }> {
|
||||
return async (c, next) => {
|
||||
const { relay } = c.var;
|
||||
|
||||
const pagination = paginationSchema.parse(c.req.query());
|
||||
|
||||
const {
|
||||
max_id: maxId,
|
||||
min_id: minId,
|
||||
since,
|
||||
until,
|
||||
} = pagination;
|
||||
|
||||
if ((maxId && !until) || (minId && !since)) {
|
||||
const ids: string[] = [];
|
||||
|
||||
if (maxId) ids.push(maxId);
|
||||
if (minId) ids.push(minId);
|
||||
|
||||
if (ids.length) {
|
||||
const events = await relay.query(
|
||||
[{ ids, limit: ids.length }],
|
||||
{ signal: c.req.raw.signal },
|
||||
);
|
||||
|
||||
for (const event of events) {
|
||||
if (!until && maxId === event.id) pagination.until = event.created_at;
|
||||
if (!since && minId === event.id) pagination.since = event.created_at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'list') {
|
||||
c.set('pagination', {
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
});
|
||||
const fn: ListPaginateFn = (params, body, headers) => paginatedList(c, params, body, headers);
|
||||
c.set('paginate', fn);
|
||||
} else {
|
||||
c.set('pagination', {
|
||||
since: pagination.since,
|
||||
until: pagination.until,
|
||||
limit: pagination.limit,
|
||||
});
|
||||
const fn: PaginateFn = (events, body, headers) => paginated(c, events, body, headers);
|
||||
c.set('paginate', fn);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
87
packages/api/middleware/userMiddleware.ts
Normal file
87
packages/api/middleware/userMiddleware.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { aesDecrypt, getTokenHash } from '@ditto/auth';
|
||||
import { ConnectSigner, ReadOnlySigner } from '@ditto/signers';
|
||||
import { HTTPException } from '@hono/hono/http-exception';
|
||||
import { type NostrSigner, NSecSigner, type NStore } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import type { DittoMiddleware } from '../DittoMiddleware.ts';
|
||||
|
||||
interface User {
|
||||
signer: NostrSigner;
|
||||
relay: NStore;
|
||||
}
|
||||
|
||||
// @ts-ignore The types are right.
|
||||
export function userMiddleware(opts: { privileged: false; required: true }): DittoMiddleware<{ user: User }>;
|
||||
export function userMiddleware(opts: { privileged: true; required?: boolean }): DittoMiddleware<{ user: User }>;
|
||||
export function userMiddleware(opts: { privileged: false; required?: boolean }): DittoMiddleware<{ user?: User }>;
|
||||
export function userMiddleware(opts: { privileged: boolean; required?: boolean }): DittoMiddleware<{ user?: User }> {
|
||||
/** We only accept "Bearer" type. */
|
||||
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
|
||||
|
||||
const { privileged, required = false } = opts;
|
||||
|
||||
return async (c, next) => {
|
||||
const { conf, db, relay } = c.var;
|
||||
|
||||
const header = c.req.header('authorization');
|
||||
const match = header?.match(BEARER_REGEX);
|
||||
|
||||
let signer: NostrSigner | undefined;
|
||||
|
||||
if (match) {
|
||||
const [_, bech32] = match;
|
||||
|
||||
if (bech32.startsWith('token1')) {
|
||||
try {
|
||||
const tokenHash = await getTokenHash(bech32 as `token1${string}`);
|
||||
|
||||
const { pubkey: userPubkey, bunker_pubkey: bunkerPubkey, nip46_sk_enc, nip46_relays } = await db.kysely
|
||||
.selectFrom('auth_tokens')
|
||||
.select(['pubkey', 'bunker_pubkey', 'nip46_sk_enc', 'nip46_relays'])
|
||||
.where('token_hash', '=', tokenHash)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const nep46Seckey = await aesDecrypt(conf.seckey, nip46_sk_enc);
|
||||
|
||||
signer = new ConnectSigner({
|
||||
bunkerPubkey,
|
||||
userPubkey,
|
||||
signer: new NSecSigner(nep46Seckey),
|
||||
relays: nip46_relays,
|
||||
relay,
|
||||
});
|
||||
} catch {
|
||||
throw new HTTPException(401);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const decoded = nip19.decode(bech32!);
|
||||
|
||||
switch (decoded.type) {
|
||||
case 'npub':
|
||||
signer = new ReadOnlySigner(decoded.data);
|
||||
break;
|
||||
case 'nprofile':
|
||||
signer = new ReadOnlySigner(decoded.data.pubkey);
|
||||
break;
|
||||
case 'nsec':
|
||||
signer = new NSecSigner(decoded.data);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
throw new HTTPException(401);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (signer) {
|
||||
const user: User = { signer, relay };
|
||||
c.set('user', user);
|
||||
} else if (privileged || required) {
|
||||
throw new HTTPException(401);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
5
packages/api/mod.ts
Normal file
5
packages/api/mod.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export { DittoApp } from './DittoApp.ts';
|
||||
export { DittoRoute } from './DittoRoute.ts';
|
||||
|
||||
export type { DittoEnv } from './DittoEnv.ts';
|
||||
export type { DittoEvent } from './DittoEvent.ts';
|
||||
34
packages/api/pagination/link-header.test.ts
Normal file
34
packages/api/pagination/link-header.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { genEvent } from '@nostrify/nostrify/test';
|
||||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import { buildLinkHeader, buildListLinkHeader } from './link-header.ts';
|
||||
|
||||
Deno.test('buildLinkHeader', () => {
|
||||
const url = 'https://ditto.test/api/v1/events';
|
||||
|
||||
const events = [
|
||||
genEvent({ created_at: 1 }),
|
||||
genEvent({ created_at: 2 }),
|
||||
genEvent({ created_at: 3 }),
|
||||
];
|
||||
|
||||
const link = buildLinkHeader(url, events);
|
||||
|
||||
assertEquals(
|
||||
link?.toString(),
|
||||
'<https://ditto.test/api/v1/events?until=3>; rel="next", <https://ditto.test/api/v1/events?since=1>; rel="prev"',
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('buildListLinkHeader', () => {
|
||||
const url = 'https://ditto.test/api/v1/tags';
|
||||
|
||||
const params = { offset: 0, limit: 3 };
|
||||
|
||||
const link = buildListLinkHeader(url, params);
|
||||
|
||||
assertEquals(
|
||||
link?.toString(),
|
||||
'<https://ditto.test/api/v1/tags?offset=3&limit=3>; rel="next", <https://ditto.test/api/v1/tags?offset=0&limit=3>; rel="prev"',
|
||||
);
|
||||
});
|
||||
39
packages/api/pagination/link-header.ts
Normal file
39
packages/api/pagination/link-header.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
/** Build HTTP Link header for Mastodon API pagination. */
|
||||
export function buildLinkHeader(url: string, events: NostrEvent[]): string | undefined {
|
||||
if (events.length <= 1) return;
|
||||
|
||||
const firstEvent = events[0];
|
||||
const lastEvent = events[events.length - 1];
|
||||
|
||||
const { pathname, search } = new URL(url);
|
||||
|
||||
const next = new URL(pathname + search, url);
|
||||
const prev = new URL(pathname + search, url);
|
||||
|
||||
next.searchParams.set('until', String(lastEvent.created_at));
|
||||
prev.searchParams.set('since', String(firstEvent.created_at));
|
||||
|
||||
return `<${next}>; rel="next", <${prev}>; rel="prev"`;
|
||||
}
|
||||
|
||||
/** Build HTTP Link header for paginating Nostr lists. */
|
||||
export function buildListLinkHeader(
|
||||
url: string,
|
||||
params: { offset: number; limit: number },
|
||||
): string | undefined {
|
||||
const { pathname, search } = new URL(url);
|
||||
const { offset, limit } = params;
|
||||
|
||||
const next = new URL(pathname + search, url);
|
||||
const prev = new URL(pathname + search, url);
|
||||
|
||||
next.searchParams.set('offset', String(offset + limit));
|
||||
prev.searchParams.set('offset', String(Math.max(offset - limit, 0)));
|
||||
|
||||
next.searchParams.set('limit', String(limit));
|
||||
prev.searchParams.set('limit', String(limit));
|
||||
|
||||
return `<${next}>; rel="next", <${prev}>; rel="prev"`;
|
||||
}
|
||||
3
packages/api/pagination/mod.ts
Normal file
3
packages/api/pagination/mod.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { buildLinkHeader, buildListLinkHeader } from './link-header.ts';
|
||||
export { paginated, paginatedList } from './paginate.ts';
|
||||
export { paginationSchema } from './schema.ts';
|
||||
0
packages/api/pagination/paginate.test.ts
Normal file
0
packages/api/pagination/paginate.test.ts
Normal file
43
packages/api/pagination/paginate.ts
Normal file
43
packages/api/pagination/paginate.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { buildLinkHeader, buildListLinkHeader } from './link-header.ts';
|
||||
|
||||
import type { Context } from '@hono/hono';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
type HeaderRecord = Record<string, string | string[]>;
|
||||
|
||||
/** Return results with pagination headers. Assumes chronological sorting of events. */
|
||||
export function paginated(
|
||||
c: Context,
|
||||
events: NostrEvent[],
|
||||
body: object | unknown[],
|
||||
headers: HeaderRecord = {},
|
||||
): Response {
|
||||
const link = buildLinkHeader(c.req.url, events);
|
||||
|
||||
if (link) {
|
||||
headers.link = link;
|
||||
}
|
||||
|
||||
// Filter out undefined entities.
|
||||
const results = Array.isArray(body) ? body.filter(Boolean) : body;
|
||||
return c.json(results, 200, headers);
|
||||
}
|
||||
|
||||
/** paginate a list of tags. */
|
||||
export function paginatedList(
|
||||
c: Context,
|
||||
params: { offset: number; limit: number },
|
||||
body: object | unknown[],
|
||||
headers: HeaderRecord = {},
|
||||
): Response {
|
||||
const link = buildListLinkHeader(c.req.url, params);
|
||||
const hasMore = Array.isArray(body) ? body.length > 0 : true;
|
||||
|
||||
if (link) {
|
||||
headers.link = hasMore ? link : link.split(', ').find((link) => link.endsWith('; rel="prev"'))!;
|
||||
}
|
||||
|
||||
// Filter out undefined entities.
|
||||
const results = Array.isArray(body) ? body.filter(Boolean) : body;
|
||||
return c.json(results, 200, headers);
|
||||
}
|
||||
23
packages/api/pagination/schema.test.ts
Normal file
23
packages/api/pagination/schema.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import { paginationSchema } from './schema.ts';
|
||||
|
||||
Deno.test('paginationSchema', () => {
|
||||
const pagination = paginationSchema.parse({
|
||||
limit: '10',
|
||||
offset: '20',
|
||||
max_id: '1',
|
||||
min_id: '2',
|
||||
since: '3',
|
||||
until: '4',
|
||||
});
|
||||
|
||||
assertEquals(pagination, {
|
||||
limit: 10,
|
||||
offset: 20,
|
||||
max_id: '1',
|
||||
min_id: '2',
|
||||
since: 3,
|
||||
until: 4,
|
||||
});
|
||||
});
|
||||
1
packages/api/routes/mod.ts
Normal file
1
packages/api/routes/mod.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { timelinesRoute } from './timelinesRoute.ts';
|
||||
0
packages/api/routes/timelinesRoute.test.tst
Normal file
0
packages/api/routes/timelinesRoute.test.tst
Normal file
|
|
@ -1,22 +1,24 @@
|
|||
import { NostrFilter } from '@nostrify/nostrify';
|
||||
import { DittoRoute } from '@ditto/api';
|
||||
import { paginationMiddleware, userMiddleware } from '@ditto/api/middleware';
|
||||
import { booleanParamSchema, languageSchema } from '@ditto/api/schema';
|
||||
import { getTagSet } from '@ditto/utils/tags';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type AppContext, type AppController } from '@/app.ts';
|
||||
import { getFeedPubkeys } from '@/queries.ts';
|
||||
import { booleanParamSchema, languageSchema } from '@/schema.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { paginated } from '@/utils/api.ts';
|
||||
import { getTagSet } from '@/utils/tags.ts';
|
||||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import type { Context } from '@hono/hono';
|
||||
import type { NostrFilter } from '@nostrify/nostrify';
|
||||
import type { DittoEnv } from '../DittoEnv.ts';
|
||||
|
||||
const route = new DittoRoute().use(paginationMiddleware());
|
||||
|
||||
const homeQuerySchema = z.object({
|
||||
exclude_replies: booleanParamSchema.optional(),
|
||||
only_media: booleanParamSchema.optional(),
|
||||
});
|
||||
|
||||
const homeTimelineController: AppController = async (c) => {
|
||||
const params = c.get('pagination');
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
route.get('/home', userMiddleware({ privileged: false, required: true }), async (c) => {
|
||||
const { user, pagination } = c.var;
|
||||
|
||||
const pubkey = await user.signer.getPublicKey();
|
||||
const result = homeQuerySchema.safeParse(c.req.query());
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -25,8 +27,8 @@ const homeTimelineController: AppController = async (c) => {
|
|||
|
||||
const { exclude_replies, only_media } = result.data;
|
||||
|
||||
const authors = [...await getFeedPubkeys(pubkey)];
|
||||
const filter: NostrFilter = { authors, kinds: [1, 6, 20], ...params };
|
||||
const authors = [...await getFeedPubkeys(c.var, pubkey)];
|
||||
const filter: NostrFilter = { authors, kinds: [1, 6, 20], ...pagination };
|
||||
|
||||
const search: string[] = [];
|
||||
|
||||
|
|
@ -43,7 +45,7 @@ const homeTimelineController: AppController = async (c) => {
|
|||
}
|
||||
|
||||
return renderStatuses(c, [filter]);
|
||||
};
|
||||
});
|
||||
|
||||
const publicQuerySchema = z.object({
|
||||
local: booleanParamSchema.default('false'),
|
||||
|
|
@ -51,9 +53,8 @@ const publicQuerySchema = z.object({
|
|||
language: languageSchema.optional(),
|
||||
});
|
||||
|
||||
const publicTimelineController: AppController = (c) => {
|
||||
const { conf } = c.var;
|
||||
const params = c.get('pagination');
|
||||
route.get('/public', (c) => {
|
||||
const { conf, pagination } = c.var;
|
||||
const result = publicQuerySchema.safeParse(c.req.query());
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -62,7 +63,7 @@ const publicTimelineController: AppController = (c) => {
|
|||
|
||||
const { local, instance, language } = result.data;
|
||||
|
||||
const filter: NostrFilter = { kinds: [1, 20], ...params };
|
||||
const filter: NostrFilter = { kinds: [1, 20], ...pagination };
|
||||
|
||||
const search: `${string}:${string}`[] = [];
|
||||
|
||||
|
|
@ -81,51 +82,42 @@ const publicTimelineController: AppController = (c) => {
|
|||
}
|
||||
|
||||
return renderStatuses(c, [filter]);
|
||||
};
|
||||
});
|
||||
|
||||
const hashtagTimelineController: AppController = (c) => {
|
||||
const hashtag = c.req.param('hashtag')!.toLowerCase();
|
||||
const params = c.get('pagination');
|
||||
return renderStatuses(c, [{ kinds: [1, 20], '#t': [hashtag], ...params }]);
|
||||
};
|
||||
route.get('/tag/:hashtag', (c) => {
|
||||
const { pagination } = c.var;
|
||||
const hashtag = c.req.param('hashtag').toLowerCase();
|
||||
|
||||
const suggestedTimelineController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const store = c.get('store');
|
||||
const params = c.get('pagination');
|
||||
return renderStatuses(c, [{ kinds: [1, 20], '#t': [hashtag], ...pagination }]);
|
||||
});
|
||||
|
||||
const [follows] = await store.query(
|
||||
route.get('/suggested', async (c) => {
|
||||
const { conf, relay, pagination } = c.var;
|
||||
|
||||
const [follows] = await relay.query(
|
||||
[{ kinds: [3], authors: [conf.pubkey], limit: 1 }],
|
||||
);
|
||||
|
||||
const authors = [...getTagSet(follows?.tags ?? [], 'p')];
|
||||
|
||||
return renderStatuses(c, [{ authors, kinds: [1, 20], ...params }]);
|
||||
};
|
||||
return renderStatuses(c, [{ authors, kinds: [1, 20], ...pagination }]);
|
||||
});
|
||||
|
||||
/** Render statuses for timelines. */
|
||||
async function renderStatuses(c: AppContext, filters: NostrFilter[]) {
|
||||
const { conf } = c.var;
|
||||
const { signal } = c.req.raw;
|
||||
const store = c.get('store');
|
||||
async function renderStatuses(c: Context<DittoEnv>, filters: NostrFilter[]) {
|
||||
const { conf, store, user, signal } = c.var;
|
||||
const opts = { signal, timeout: conf.db.timeouts.timelines };
|
||||
|
||||
const events = await store
|
||||
.query(filters, opts)
|
||||
.then((events) => hydrateEvents({ events, store, signal }));
|
||||
.then((events) => hydrateEvents(c.var, events));
|
||||
|
||||
if (!events.length) {
|
||||
return c.json([]);
|
||||
}
|
||||
|
||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
||||
|
||||
const statuses = (await Promise.all(events.map((event) => {
|
||||
if (event.kind === 6) {
|
||||
return renderReblog(event, { viewerPubkey });
|
||||
}
|
||||
return renderStatus(event, { viewerPubkey });
|
||||
}))).filter(Boolean);
|
||||
const view = new StatusView(c.var);
|
||||
const statuses = (await Promise.all(events.map((event) => view.render(event)))).filter(Boolean);
|
||||
|
||||
if (!statuses.length) {
|
||||
return c.json([]);
|
||||
|
|
@ -134,4 +126,4 @@ async function renderStatuses(c: AppContext, filters: NostrFilter[]) {
|
|||
return paginated(c, events, statuses);
|
||||
}
|
||||
|
||||
export { hashtagTimelineController, homeTimelineController, publicTimelineController, suggestedTimelineController };
|
||||
export { route as timelinesRoute };
|
||||
13
packages/api/schema.test.ts
Normal file
13
packages/api/schema.test.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { assertEquals, assertThrows } from '@std/assert';
|
||||
|
||||
import { booleanParamSchema } from './schema.ts';
|
||||
|
||||
Deno.test('booleanParamSchema', () => {
|
||||
assertEquals(booleanParamSchema.parse('true'), true);
|
||||
assertEquals(booleanParamSchema.parse('false'), false);
|
||||
|
||||
assertThrows(() => booleanParamSchema.parse('invalid'));
|
||||
assertThrows(() => booleanParamSchema.parse('undefined'));
|
||||
|
||||
assertEquals(booleanParamSchema.optional().parse(undefined), undefined);
|
||||
});
|
||||
11
packages/api/schema.ts
Normal file
11
packages/api/schema.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import ISO6391 from 'iso-639-1';
|
||||
import { z } from 'zod';
|
||||
|
||||
/** https://github.com/colinhacks/zod/issues/1630#issuecomment-1365983831 */
|
||||
export const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value === 'true');
|
||||
|
||||
/** Value is a ISO-639-1 language code. */
|
||||
export const languageSchema = z.string().refine(
|
||||
(val) => ISO6391.validate(val),
|
||||
{ message: 'Not a valid language in ISO-639-1 format' },
|
||||
);
|
||||
170
packages/api/views/AccountView.ts
Normal file
170
packages/api/views/AccountView.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { type NostrEvent, NSchema as n } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import type { DittoConf } from '@ditto/conf';
|
||||
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
|
||||
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { metadataSchema } from '@/schemas/nostr.ts';
|
||||
import { getLnurl } from '@/utils/lnurl.ts';
|
||||
import { parseNoteContent } from '@/utils/note.ts';
|
||||
import { getTagSet } from '@/utils/tags.ts';
|
||||
import { nostrDate, nostrNow, parseNip05 } from '@/utils.ts';
|
||||
import { renderEmojis } from '@/views/mastodon/emojis.ts';
|
||||
|
||||
type ToAccountOpts = {
|
||||
withSource: true;
|
||||
settingsStore: Record<string, unknown> | undefined;
|
||||
} | {
|
||||
withSource?: false;
|
||||
};
|
||||
|
||||
interface AccountViewOpts {
|
||||
conf: DittoConf;
|
||||
}
|
||||
|
||||
export class AccountView {
|
||||
constructor(private opts: AccountViewOpts) {}
|
||||
|
||||
render(event: Omit<DittoEvent, 'id' | 'sig'>, pubkey?: string, opts?: ToAccountOpts): MastodonAccount;
|
||||
render(event: Omit<DittoEvent, 'id' | 'sig'> | undefined, pubkey: string, opts?: ToAccountOpts): MastodonAccount;
|
||||
render(event: Omit<DittoEvent, 'id' | 'sig'> | undefined, pubkey: string, opts: ToAccountOpts = {}): MastodonAccount {
|
||||
const { conf } = this.opts;
|
||||
|
||||
if (!event) {
|
||||
return this.accountFromPubkey(pubkey, opts);
|
||||
}
|
||||
|
||||
const stats = event.author_stats;
|
||||
const names = getTagSet(event.user?.tags ?? [], 'n');
|
||||
|
||||
if (names.has('disabled')) {
|
||||
const account = this.accountFromPubkey(pubkey, opts);
|
||||
account.pleroma.deactivated = true;
|
||||
return account;
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
nip05,
|
||||
picture = conf.local('/images/avi.png'),
|
||||
banner = conf.local('/images/banner.png'),
|
||||
about,
|
||||
lud06,
|
||||
lud16,
|
||||
website,
|
||||
fields: _fields,
|
||||
} = n.json().pipe(metadataSchema).catch({}).parse(event.content);
|
||||
|
||||
const npub = nip19.npubEncode(pubkey);
|
||||
const nprofile = nip19.nprofileEncode({ pubkey, relays: [conf.relay] });
|
||||
const parsed05 = stats?.nip05 ? parseNip05(stats.nip05) : undefined;
|
||||
const acct = parsed05?.handle || npub;
|
||||
|
||||
const { html } = parseNoteContent(conf, about || '', []);
|
||||
|
||||
const fields = _fields
|
||||
?.slice(0, conf.profileFields.maxFields)
|
||||
.map(([name, value]) => ({
|
||||
name: name.slice(0, conf.profileFields.nameLength),
|
||||
value: value.slice(0, conf.profileFields.valueLength),
|
||||
verified_at: null,
|
||||
})) ?? [];
|
||||
|
||||
let streakDays = 0;
|
||||
let streakStart = stats?.streak_start ?? null;
|
||||
let streakEnd = stats?.streak_end ?? null;
|
||||
const { streakWindow } = conf;
|
||||
|
||||
if (streakStart && streakEnd) {
|
||||
const broken = nostrNow() - streakEnd > streakWindow;
|
||||
if (broken) {
|
||||
streakStart = null;
|
||||
streakEnd = null;
|
||||
} else {
|
||||
const delta = streakEnd - streakStart;
|
||||
streakDays = Math.max(Math.ceil(delta / 86400), 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: pubkey,
|
||||
acct,
|
||||
avatar: picture,
|
||||
avatar_static: picture,
|
||||
bot: false,
|
||||
created_at: nostrDate(event.user?.created_at ?? event.created_at).toISOString(),
|
||||
discoverable: true,
|
||||
display_name: name ?? '',
|
||||
emojis: renderEmojis(event),
|
||||
fields: fields.map((field) => ({ ...field, value: parseNoteContent(conf, field.value, []).html })),
|
||||
follow_requests_count: 0,
|
||||
followers_count: stats?.followers_count ?? 0,
|
||||
following_count: stats?.following_count ?? 0,
|
||||
fqn: parsed05?.handle || npub,
|
||||
header: banner,
|
||||
header_static: banner,
|
||||
last_status_at: null,
|
||||
locked: false,
|
||||
note: html,
|
||||
roles: [],
|
||||
source: opts.withSource
|
||||
? {
|
||||
fields,
|
||||
language: '',
|
||||
note: about || '',
|
||||
privacy: 'public',
|
||||
sensitive: false,
|
||||
follow_requests_count: 0,
|
||||
nostr: {
|
||||
nip05,
|
||||
},
|
||||
ditto: {
|
||||
captcha_solved: names.has('captcha_solved'),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
statuses_count: stats?.notes_count ?? 0,
|
||||
uri: conf.local(`/users/${acct}`),
|
||||
url: conf.local(`/@${acct}`),
|
||||
username: parsed05?.nickname || npub.substring(0, 8),
|
||||
ditto: {
|
||||
accepts_zaps: Boolean(getLnurl({ lud06, lud16 })),
|
||||
external_url: conf.external(nprofile),
|
||||
streak: {
|
||||
days: streakDays,
|
||||
start: streakStart ? nostrDate(streakStart).toISOString() : null,
|
||||
end: streakEnd ? nostrDate(streakEnd).toISOString() : null,
|
||||
expires: streakEnd ? nostrDate(streakEnd + streakWindow).toISOString() : null,
|
||||
},
|
||||
},
|
||||
domain: parsed05?.domain,
|
||||
pleroma: {
|
||||
deactivated: names.has('disabled'),
|
||||
is_admin: names.has('admin'),
|
||||
is_moderator: names.has('admin') || names.has('moderator'),
|
||||
is_suggested: names.has('suggested'),
|
||||
is_local: parsed05?.domain === conf.url.host,
|
||||
settings_store: opts.withSource ? opts.settingsStore : undefined,
|
||||
tags: [...getTagSet(event.user?.tags ?? [], 't')],
|
||||
favicon: stats?.favicon,
|
||||
},
|
||||
nostr: {
|
||||
pubkey,
|
||||
lud16,
|
||||
},
|
||||
website: website && /^https?:\/\//.test(website) ? website : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private accountFromPubkey(pubkey: string, opts: ToAccountOpts = {}): MastodonAccount {
|
||||
const event: Omit<NostrEvent, 'id' | 'sig'> = {
|
||||
kind: 0,
|
||||
pubkey,
|
||||
content: '',
|
||||
tags: [],
|
||||
created_at: nostrNow(),
|
||||
};
|
||||
|
||||
return this.render(event, pubkey, opts);
|
||||
}
|
||||
}
|
||||
79
packages/api/views/AdminAccountView.ts
Normal file
79
packages/api/views/AdminAccountView.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { AccountView } from './AccountView.ts';
|
||||
|
||||
import type { DittoEvent } from '@ditto/api';
|
||||
import type { DittoConf } from '@ditto/conf';
|
||||
|
||||
interface AdminAccountViewOpts {
|
||||
conf: DittoConf;
|
||||
}
|
||||
|
||||
export class AdminAccountView {
|
||||
private accountView: AccountView;
|
||||
|
||||
constructor(opts: AdminAccountViewOpts) {
|
||||
this.accountView = new AccountView(opts);
|
||||
}
|
||||
|
||||
/** Expects a kind 0 fully hydrated */
|
||||
render(event: DittoEvent | undefined, pubkey: string) {
|
||||
if (!event) {
|
||||
return this.renderAdminAccountFromPubkey(pubkey);
|
||||
}
|
||||
|
||||
const account = this.accountView.render(event, pubkey);
|
||||
const names = getTagSet(event.user?.tags ?? [], 'n');
|
||||
|
||||
let role = 'user';
|
||||
|
||||
if (names.has('admin')) {
|
||||
role = 'admin';
|
||||
}
|
||||
if (names.has('moderator')) {
|
||||
role = 'moderator';
|
||||
}
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
username: account.username,
|
||||
domain: account.acct.split('@')[1] || null,
|
||||
created_at: account.created_at,
|
||||
email: '',
|
||||
ip: null,
|
||||
ips: [],
|
||||
locale: '',
|
||||
invite_request: null,
|
||||
role,
|
||||
confirmed: true,
|
||||
approved: true,
|
||||
disabled: names.has('disabled'),
|
||||
silenced: names.has('silenced'),
|
||||
suspended: names.has('suspended'),
|
||||
sensitized: names.has('sensitized'),
|
||||
account,
|
||||
};
|
||||
}
|
||||
|
||||
/** Expects a target pubkey */
|
||||
private renderAdminAccountFromPubkey(pubkey: string) {
|
||||
const account = this.accountView.render(undefined, pubkey);
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
username: account.username,
|
||||
domain: account.acct.split('@')[1] || null,
|
||||
created_at: account.created_at,
|
||||
email: '',
|
||||
ip: null,
|
||||
ips: [],
|
||||
locale: '',
|
||||
invite_request: null,
|
||||
role: 'user',
|
||||
confirmed: true,
|
||||
approved: true,
|
||||
disabled: false,
|
||||
silenced: false,
|
||||
suspended: false,
|
||||
account,
|
||||
};
|
||||
}
|
||||
}
|
||||
200
packages/api/views/StatusView.ts
Normal file
200
packages/api/views/StatusView.ts
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { DittoConf } from '@ditto/conf';
|
||||
import { NostrEvent, NostrSigner, NStore } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { MastodonAttachment } from '@/entities/MastodonAttachment.ts';
|
||||
import { MastodonMention } from '@/entities/MastodonMention.ts';
|
||||
import { MastodonStatus } from '@/entities/MastodonStatus.ts';
|
||||
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { nostrDate } from '@/utils.ts';
|
||||
import { getMediaLinks, parseNoteContent, stripimeta } from '@/utils/note.ts';
|
||||
import { findReplyTag } from '@/utils/tags.ts';
|
||||
import { unfurlCardCached } from '@/utils/unfurl.ts';
|
||||
import { AccountView } from '@/views/mastodon/AccountView.ts';
|
||||
import { renderAttachment } from '@/views/mastodon/attachments.ts';
|
||||
import { renderEmojis } from '@/views/mastodon/emojis.ts';
|
||||
|
||||
interface RenderStatusOpts {
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
interface StatusViewOpts {
|
||||
conf: DittoConf;
|
||||
store: NStore;
|
||||
user?: {
|
||||
signer: NostrSigner;
|
||||
};
|
||||
}
|
||||
|
||||
export class StatusView {
|
||||
private accountView: AccountView;
|
||||
|
||||
constructor(private opts: StatusViewOpts) {
|
||||
this.accountView = new AccountView(opts);
|
||||
}
|
||||
|
||||
async render(event: DittoEvent, opts?: RenderStatusOpts): Promise<MastodonStatus | undefined> {
|
||||
if (event.kind === 6) {
|
||||
return await this.renderReblog(event, opts);
|
||||
}
|
||||
return await this.renderStatus(event, opts);
|
||||
}
|
||||
|
||||
async renderStatus(event: DittoEvent, opts?: RenderStatusOpts): Promise<MastodonStatus | undefined> {
|
||||
const { conf, store, user } = this.opts;
|
||||
const { depth = 1 } = opts ?? {};
|
||||
|
||||
if (depth > 2 || depth < 0) return;
|
||||
|
||||
const nevent = nip19.neventEncode({
|
||||
id: event.id,
|
||||
author: event.pubkey,
|
||||
kind: event.kind,
|
||||
relays: [conf.relay],
|
||||
});
|
||||
|
||||
const account = this.accountView.render(event.author, event.pubkey);
|
||||
|
||||
const viewerPubkey = await user?.signer.getPublicKey();
|
||||
|
||||
const replyId = findReplyTag(event.tags)?.[1];
|
||||
|
||||
const mentions = event.mentions?.map((event) => this.renderMention(event)) ?? [];
|
||||
|
||||
const { html, links, firstUrl } = parseNoteContent(conf, stripimeta(event.content, event.tags), mentions);
|
||||
|
||||
const [card, relatedEvents] = await Promise
|
||||
.all([
|
||||
firstUrl ? unfurlCardCached(conf, firstUrl, AbortSignal.timeout(500)) : null,
|
||||
viewerPubkey
|
||||
? await store.query([
|
||||
{ kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
{ kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
{ kinds: [9734], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
{ kinds: [10001], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
{ kinds: [10003], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
|
||||
])
|
||||
: [],
|
||||
]);
|
||||
|
||||
const reactionEvent = relatedEvents.find((event) => event.kind === 7);
|
||||
const repostEvent = relatedEvents.find((event) => event.kind === 6);
|
||||
const pinEvent = relatedEvents.find((event) => event.kind === 10001);
|
||||
const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003);
|
||||
const zapEvent = relatedEvents.find((event) => event.kind === 9734);
|
||||
|
||||
const compatMentions = this.buildInlineRecipients(mentions.filter((m) => {
|
||||
if (m.id === account.id) return false;
|
||||
if (html.includes(m.url)) return false;
|
||||
return true;
|
||||
}));
|
||||
|
||||
const cw = event.tags.find(([name]) => name === 'content-warning');
|
||||
const subject = event.tags.find(([name]) => name === 'subject');
|
||||
|
||||
const imeta: string[][][] = event.tags
|
||||
.filter(([name]) => name === 'imeta')
|
||||
.map(([_, ...entries]) =>
|
||||
entries.map((entry) => {
|
||||
const split = entry.split(' ');
|
||||
return [split[0], split.splice(1).join(' ')];
|
||||
})
|
||||
);
|
||||
|
||||
const media = imeta.length ? imeta : getMediaLinks(links);
|
||||
|
||||
/** Pleroma emoji reactions object. */
|
||||
const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [emoji, count]) => {
|
||||
if (['+', '-'].includes(emoji)) return acc;
|
||||
acc.push({ name: emoji, count, me: reactionEvent?.content === emoji });
|
||||
return acc;
|
||||
}, [] as { name: string; count: number; me: boolean }[]);
|
||||
|
||||
const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000);
|
||||
|
||||
return {
|
||||
id: event.id,
|
||||
account,
|
||||
card,
|
||||
content: compatMentions + html,
|
||||
created_at: nostrDate(event.created_at).toISOString(),
|
||||
in_reply_to_id: replyId ?? null,
|
||||
in_reply_to_account_id: null,
|
||||
sensitive: !!cw,
|
||||
spoiler_text: (cw ? cw[1] : subject?.[1]) || '',
|
||||
visibility: 'public',
|
||||
language: event.language ?? null,
|
||||
replies_count: event.event_stats?.replies_count ?? 0,
|
||||
reblogs_count: event.event_stats?.reposts_count ?? 0,
|
||||
favourites_count: event.event_stats?.reactions['+'] ?? 0,
|
||||
zaps_amount: event.event_stats?.zaps_amount ?? 0,
|
||||
favourited: reactionEvent?.content === '+',
|
||||
reblogged: Boolean(repostEvent),
|
||||
muted: false,
|
||||
bookmarked: Boolean(bookmarkEvent),
|
||||
pinned: Boolean(pinEvent),
|
||||
reblog: null,
|
||||
application: null,
|
||||
media_attachments: media
|
||||
.map((m) => renderAttachment({ tags: m }))
|
||||
.filter((m): m is MastodonAttachment => Boolean(m)),
|
||||
mentions,
|
||||
tags: [],
|
||||
emojis: renderEmojis(event),
|
||||
poll: null,
|
||||
quote: !event.quote ? null : await this.renderStatus(event.quote, { depth: depth + 1 }),
|
||||
quote_id: event.quote?.id ?? null,
|
||||
uri: conf.local(`/users/${account.acct}/statuses/${event.id}`),
|
||||
url: conf.local(`/@${account.acct}/${event.id}`),
|
||||
zapped: Boolean(zapEvent),
|
||||
ditto: {
|
||||
external_url: conf.external(nevent),
|
||||
},
|
||||
pleroma: {
|
||||
emoji_reactions: reactions,
|
||||
expires_at: !isNaN(expiresAt.getTime()) ? expiresAt.toISOString() : undefined,
|
||||
quotes_count: event.event_stats?.quotes_count ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async renderReblog(event: DittoEvent, opts?: RenderStatusOpts): Promise<MastodonStatus | undefined> {
|
||||
if (!event.repost) return;
|
||||
|
||||
const status = await this.renderStatus(event, opts);
|
||||
if (!status) return;
|
||||
|
||||
const reblog = await this.renderStatus(event.repost, opts) ?? null;
|
||||
|
||||
return {
|
||||
...status,
|
||||
in_reply_to_id: null,
|
||||
in_reply_to_account_id: null,
|
||||
reblog,
|
||||
};
|
||||
}
|
||||
|
||||
renderMention(event: NostrEvent): MastodonMention {
|
||||
const account = this.accountView.render(event, event.pubkey);
|
||||
return {
|
||||
id: account.id,
|
||||
acct: account.acct,
|
||||
username: account.username,
|
||||
url: account.url,
|
||||
};
|
||||
}
|
||||
|
||||
buildInlineRecipients(mentions: MastodonMention[]): string {
|
||||
if (!mentions.length) return '';
|
||||
|
||||
const elements = mentions.reduce<string[]>((acc, { url, username }) => {
|
||||
const name = nip19.BECH32_REGEX.test(username) ? username.substring(0, 8) : username;
|
||||
acc.push(
|
||||
`<span class="h-card"><a class="u-url mention" href="${url}" rel="ugc">@<span>${name}</span></a></span>`,
|
||||
);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return `<span class="recipients-inline">${elements.join(' ')} </span>`;
|
||||
}
|
||||
}
|
||||
3
packages/api/views/mod.ts
Normal file
3
packages/api/views/mod.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { AccountView } from './AccountView.ts';
|
||||
export { AdminAccountView } from './AdminAccountView.ts';
|
||||
export { StatusView } from './StatusView.ts';
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { generateSecretKey } from 'nostr-tools';
|
||||
|
||||
import { aesDecrypt, aesEncrypt } from '@/utils/aes.ts';
|
||||
import { aesDecrypt, aesEncrypt } from './aes.ts';
|
||||
|
||||
Deno.bench('aesEncrypt', async (b) => {
|
||||
const sk = generateSecretKey();
|
||||
|
|
@ -2,7 +2,7 @@ import { assertEquals } from '@std/assert';
|
|||
import { encodeHex } from '@std/encoding/hex';
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
|
||||
import { aesDecrypt, aesEncrypt } from '@/utils/aes.ts';
|
||||
import { aesDecrypt, aesEncrypt } from './aes.ts';
|
||||
|
||||
Deno.test('aesDecrypt & aesEncrypt', async () => {
|
||||
const sk = generateSecretKey();
|
||||
6
packages/auth/deno.json
Normal file
6
packages/auth/deno.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@ditto/auth",
|
||||
"exports": {
|
||||
".": "./mod.ts"
|
||||
}
|
||||
}
|
||||
2
packages/auth/mod.ts
Normal file
2
packages/auth/mod.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { aesDecrypt, aesEncrypt } from './aes.ts';
|
||||
export { generateToken, getTokenHash } from './token.ts';
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { generateToken, getTokenHash } from '@/utils/auth.ts';
|
||||
import { generateToken, getTokenHash } from './token.ts';
|
||||
|
||||
Deno.bench('generateToken', async () => {
|
||||
await generateToken();
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { assertEquals } from '@std/assert';
|
||||
import { decodeHex, encodeHex } from '@std/encoding/hex';
|
||||
|
||||
import { generateToken, getTokenHash } from '@/utils/auth.ts';
|
||||
import { generateToken, getTokenHash } from './token.ts';
|
||||
|
||||
Deno.test('generateToken', async () => {
|
||||
const sk = decodeHex('a0968751df8fd42f362213f08751911672f2a037113b392403bbb7dd31b71c95');
|
||||
|
|
@ -329,6 +329,11 @@ export class DittoConf {
|
|||
.map(Number);
|
||||
}
|
||||
|
||||
/** Whether to perform prechecks when Ditto is starting. Setting this to `false` can suppress errors, but is not recommended. */
|
||||
get precheck(): boolean {
|
||||
return optionalBooleanSchema.parse(this.env.get('DITTO_PRECHECK')) ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
|
@ -357,6 +362,11 @@ export class DittoConf {
|
|||
return this.env.get('DITTO_DATA_DIR') || path.join(cwd(), 'data');
|
||||
}
|
||||
|
||||
/** Directory to serve the frontend from. */
|
||||
get publicDir(): string {
|
||||
return this.env.get('DITTO_PUBLIC_DIR') || path.join(this.dataDir, 'public');
|
||||
}
|
||||
|
||||
/** Absolute path of the Deno directory. */
|
||||
get denoDir(): string {
|
||||
return this.env.get('DENO_DIR') || `${os.userInfo().homedir}/.cache/deno`;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { Kysely } from 'kysely';
|
|||
|
||||
import type { DittoTables } from './DittoTables.ts';
|
||||
|
||||
export interface DittoDatabase {
|
||||
export interface DittoDatabase extends AsyncDisposable {
|
||||
readonly kysely: Kysely<DittoTables>;
|
||||
readonly poolSize: number;
|
||||
readonly availableConnections: number;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,10 @@ export class DittoPglite {
|
|||
poolSize: 1,
|
||||
availableConnections: 1,
|
||||
listen,
|
||||
[Symbol.asyncDispose]: async () => {
|
||||
await pglite.close();
|
||||
await kysely.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export class DittoPostgres {
|
|||
return pg.connections.idle;
|
||||
},
|
||||
listen,
|
||||
[Symbol.asyncDispose]: () => kysely.destroy(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
412
packages/ditto/DittoPipeline.ts
Normal file
412
packages/ditto/DittoPipeline.ts
Normal file
|
|
@ -0,0 +1,412 @@
|
|||
import { type DittoConf } from '@ditto/conf';
|
||||
import { DittoTables } from '@ditto/db';
|
||||
import { pipelineEventsCounter, policyEventsCounter, webPushNotificationsCounter } from '@ditto/metrics';
|
||||
import { NKinds, NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify';
|
||||
import { logi } from '@soapbox/logi';
|
||||
import { Kysely, UpdateObject } from 'kysely';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import tldts from 'tldts';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DittoPush } from '@/DittoPush.ts';
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { RelayError } from '@/RelayError.ts';
|
||||
import { AdminSigner } from '../signers/AdminSigner.ts';
|
||||
import { type EventsDB } from '@/storages/EventsDB.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { eventAge, Time } from '@/utils.ts';
|
||||
import { getAmount } from '../utils/bolt11.ts';
|
||||
import { resolveFavicon } from '../utils/favicon.ts';
|
||||
import { errorJson } from '../utils/log.ts';
|
||||
import { resolveNip05 } from '../utils/nip05.ts';
|
||||
import { parseNoteContent, stripimeta } from '../utils/note.ts';
|
||||
import { purifyEvent } from '../utils/purify.ts';
|
||||
import { updateStats } from '../utils/stats.ts';
|
||||
import { getTagSet } from '../utils/tags.ts';
|
||||
import { unfurlCardCached } from '../utils/unfurl.ts';
|
||||
import { renderWebPushNotification } from '@/views/mastodon/push.ts';
|
||||
import { PolicyWorker } from '@/workers/policy.ts';
|
||||
import { verifyEventWorker } from '@/workers/verify.ts';
|
||||
|
||||
interface PipelineOpts {
|
||||
conf: DittoConf;
|
||||
kysely: Kysely<DittoTables>;
|
||||
store: EventsDB;
|
||||
pubsub: NStore;
|
||||
}
|
||||
|
||||
export class DittoPipeline {
|
||||
private push: DittoPush;
|
||||
private policyWorker: PolicyWorker;
|
||||
|
||||
encounters = new LRUCache<string, true>({ max: 5000 });
|
||||
|
||||
constructor(private opts: PipelineOpts) {
|
||||
this.push = new DittoPush(opts);
|
||||
this.policyWorker = new PolicyWorker(opts.conf);
|
||||
}
|
||||
|
||||
/**
|
||||
* Common pipeline function to process (and maybe store) events.
|
||||
* It is idempotent, so it can be called multiple times for the same event.
|
||||
*/
|
||||
async event(event: DittoEvent, opts?: { signal: AbortSignal; source?: string }): Promise<void> {
|
||||
const { kysely, conf } = this.opts;
|
||||
|
||||
// Skip events that have already been encountered.
|
||||
if (this.encounters.get(event.id)) {
|
||||
throw new RelayError('duplicate', 'already have this event');
|
||||
}
|
||||
// Reject events that are too far in the future.
|
||||
if (eventAge(event) < -Time.minutes(1)) {
|
||||
throw new RelayError('invalid', 'event too far in the future');
|
||||
}
|
||||
// Integer max value for Postgres.
|
||||
if (event.kind >= 2_147_483_647) {
|
||||
throw new RelayError('invalid', 'event kind too large');
|
||||
}
|
||||
// The only point of ephemeral events is to stream them,
|
||||
// so throw an error if we're not even going to do that.
|
||||
if (NKinds.ephemeral(event.kind) && !this.isFresh(event)) {
|
||||
throw new RelayError('invalid', 'event too old');
|
||||
}
|
||||
// Block NIP-70 events, because we have no way to `AUTH`.
|
||||
if (event.tags.some(([name]) => name === '-')) {
|
||||
throw new RelayError('invalid', 'protected event');
|
||||
}
|
||||
// Validate the event's signature.
|
||||
if (!(await verifyEventWorker(event))) {
|
||||
throw new RelayError('invalid', 'invalid signature');
|
||||
}
|
||||
// Recheck encountered after async ops.
|
||||
if (this.encounters.has(event.id)) {
|
||||
throw new RelayError('duplicate', 'already have this event');
|
||||
}
|
||||
// Set the event as encountered after verifying the signature.
|
||||
this.encounters.set(event.id, true);
|
||||
|
||||
// Log the event.
|
||||
logi({ level: 'debug', ns: 'ditto.event', source: 'pipeline', id: event.id, kind: event.kind });
|
||||
pipelineEventsCounter.inc({ kind: event.kind });
|
||||
|
||||
// NIP-46 events get special treatment.
|
||||
// They are exempt from policies and other side-effects, and should be streamed out immediately.
|
||||
// If streaming fails, an error should be returned.
|
||||
if (event.kind === 24133) {
|
||||
await this.streamOut(event);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the event doesn't violate the policy.
|
||||
if (event.pubkey !== conf.pubkey) {
|
||||
await this.policyFilter(event, opts?.signal);
|
||||
}
|
||||
|
||||
// Prepare the event for additional checks.
|
||||
// FIXME: This is kind of hacky. Should be reorganized to fetch only what's needed for each stage.
|
||||
await this.hydrateEvent(event, opts?.signal);
|
||||
|
||||
// Ensure that the author is not banned.
|
||||
const n = getTagSet(event.user?.tags ?? [], 'n');
|
||||
if (n.has('disabled')) {
|
||||
throw new RelayError('blocked', 'author is blocked');
|
||||
}
|
||||
|
||||
// Ephemeral events must throw if they are not streamed out.
|
||||
if (NKinds.ephemeral(event.kind)) {
|
||||
await Promise.all([
|
||||
this.streamOut(event),
|
||||
this.webPush(event),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Events received through notify are thought to already be in the database, so they only need to be streamed.
|
||||
if (opts?.source === 'notify') {
|
||||
await Promise.all([
|
||||
this.streamOut(event),
|
||||
this.webPush(event),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.storeEvent(purifyEvent(event), opts?.signal);
|
||||
} finally {
|
||||
// This needs to run in steps, and should not block the API from responding.
|
||||
Promise.allSettled([
|
||||
this.handleZaps(kysely, event),
|
||||
this.updateAuthorData(event, opts?.signal),
|
||||
this.prewarmLinkPreview(event, opts?.signal),
|
||||
this.generateSetEvents(event),
|
||||
])
|
||||
.then(() =>
|
||||
Promise.allSettled([
|
||||
this.streamOut(event),
|
||||
this.webPush(event),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async policyFilter(event: NostrEvent, signal?: AbortSignal): Promise<void> {
|
||||
try {
|
||||
const result = await this.policyWorker.call(event, signal);
|
||||
const [, , ok, reason] = result;
|
||||
logi({ level: 'debug', ns: 'ditto.policy', id: event.id, kind: event.kind, ok, reason });
|
||||
policyEventsCounter.inc({ ok: String(ok) });
|
||||
RelayError.assert(result);
|
||||
} catch (e) {
|
||||
if (e instanceof RelayError) {
|
||||
throw e;
|
||||
} else {
|
||||
logi({ level: 'error', ns: 'ditto.policy', id: event.id, kind: event.kind, error: errorJson(e) });
|
||||
throw new RelayError('blocked', 'policy error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Hydrate the event with the user, if applicable. */
|
||||
async hydrateEvent(event: DittoEvent, signal?: AbortSignal): Promise<void> {
|
||||
await hydrateEvents({ ...this.opts, signal }, [event]);
|
||||
}
|
||||
|
||||
/** Maybe store the event, if eligible. */
|
||||
async storeEvent(event: NostrEvent, signal?: AbortSignal): Promise<undefined> {
|
||||
if (NKinds.ephemeral(event.kind)) return;
|
||||
const { conf, store } = this.opts;
|
||||
|
||||
try {
|
||||
await store.transaction(async (store, kysely) => {
|
||||
await updateStats({ conf, store, kysely }, event);
|
||||
await store.event(event, { signal });
|
||||
});
|
||||
} catch (e) {
|
||||
// If the failure is only because of updateStats (which runs first), insert the event anyway.
|
||||
// We can't catch this in the transaction because the error aborts the transaction on the Postgres side.
|
||||
if (e instanceof Error && e.message.includes('event_stats' satisfies keyof DittoTables)) {
|
||||
await store.event(event, { signal });
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse kind 0 metadata and track indexes in the database. */
|
||||
async updateAuthorData(event: NostrEvent, signal?: AbortSignal): Promise<void> {
|
||||
if (event.kind !== 0) return;
|
||||
|
||||
const { conf, kysely, store } = this.opts;
|
||||
|
||||
// Parse metadata.
|
||||
const metadata = n.json().pipe(n.metadata()).catch({}).safeParse(event.content);
|
||||
if (!metadata.success) return;
|
||||
|
||||
const { name, nip05 } = metadata.data;
|
||||
|
||||
const updates: UpdateObject<DittoTables, 'author_stats'> = {};
|
||||
|
||||
const authorStats = await kysely
|
||||
.selectFrom('author_stats')
|
||||
.selectAll()
|
||||
.where('pubkey', '=', event.pubkey)
|
||||
.executeTakeFirst();
|
||||
|
||||
const lastVerified = authorStats?.nip05_last_verified_at;
|
||||
const eventNewer = !lastVerified || event.created_at > lastVerified;
|
||||
|
||||
try {
|
||||
if (nip05 !== authorStats?.nip05 && eventNewer || !lastVerified) {
|
||||
if (nip05) {
|
||||
const tld = tldts.parse(nip05);
|
||||
if (tld.isIcann && !tld.isIp && !tld.isPrivate) {
|
||||
const pointer = await resolveNip05({ conf, store, signal }, nip05.toLowerCase());
|
||||
if (pointer.pubkey === event.pubkey) {
|
||||
updates.nip05 = nip05;
|
||||
updates.nip05_domain = tld.domain;
|
||||
updates.nip05_hostname = tld.hostname;
|
||||
updates.nip05_last_verified_at = event.created_at;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updates.nip05 = null;
|
||||
updates.nip05_domain = null;
|
||||
updates.nip05_hostname = null;
|
||||
updates.nip05_last_verified_at = event.created_at;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallthrough.
|
||||
}
|
||||
|
||||
// Fetch favicon.
|
||||
const domain = nip05?.split('@')[1].toLowerCase();
|
||||
if (domain) {
|
||||
try {
|
||||
await resolveFavicon({ ...this.opts, signal }, domain);
|
||||
} catch {
|
||||
// Fallthrough.
|
||||
}
|
||||
}
|
||||
|
||||
const search = [name, nip05].filter(Boolean).join(' ').trim();
|
||||
|
||||
if (search !== authorStats?.search) {
|
||||
updates.search = search;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length) {
|
||||
await kysely.insertInto('author_stats')
|
||||
.values({
|
||||
pubkey: event.pubkey,
|
||||
followers_count: 0,
|
||||
following_count: 0,
|
||||
notes_count: 0,
|
||||
search,
|
||||
...updates,
|
||||
})
|
||||
.onConflict((oc) => oc.column('pubkey').doUpdateSet(updates))
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
async prewarmLinkPreview(event: NostrEvent, signal?: AbortSignal): Promise<void> {
|
||||
const { conf } = this.opts;
|
||||
const { firstUrl } = parseNoteContent(conf, stripimeta(event.content, event.tags), []);
|
||||
if (firstUrl) {
|
||||
await unfurlCardCached(conf, firstUrl, signal);
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine if the event is being received in a timely manner. */
|
||||
isFresh(event: NostrEvent): boolean {
|
||||
return eventAge(event) < Time.minutes(1);
|
||||
}
|
||||
|
||||
/** Distribute the event through active subscriptions. */
|
||||
async streamOut(event: NostrEvent): Promise<void> {
|
||||
if (!this.isFresh(event)) {
|
||||
throw new RelayError('invalid', 'event too old');
|
||||
}
|
||||
|
||||
const { pubsub } = this.opts;
|
||||
|
||||
await pubsub.event(event);
|
||||
}
|
||||
|
||||
async webPush(event: NostrEvent): Promise<void> {
|
||||
if (!this.isFresh(event)) {
|
||||
throw new RelayError('invalid', 'event too old');
|
||||
}
|
||||
|
||||
const { kysely } = this.opts;
|
||||
const pubkeys = getTagSet(event.tags, 'p');
|
||||
|
||||
if (!pubkeys.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = await kysely
|
||||
.selectFrom('push_subscriptions')
|
||||
.selectAll()
|
||||
.where('pubkey', 'in', [...pubkeys])
|
||||
.execute();
|
||||
|
||||
for (const row of rows) {
|
||||
const viewerPubkey = row.pubkey;
|
||||
|
||||
if (viewerPubkey === event.pubkey) {
|
||||
continue; // Don't notify authors about their own events.
|
||||
}
|
||||
|
||||
const message = await renderWebPushNotification(event, viewerPubkey);
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subscription = {
|
||||
endpoint: row.endpoint,
|
||||
keys: {
|
||||
auth: row.auth,
|
||||
p256dh: row.p256dh,
|
||||
},
|
||||
};
|
||||
|
||||
await this.push.push(subscription, message);
|
||||
webPushNotificationsCounter.inc({ type: message.notification_type });
|
||||
}
|
||||
}
|
||||
|
||||
async generateSetEvents(event: NostrEvent): Promise<void> {
|
||||
const { conf } = this.opts;
|
||||
|
||||
const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === conf.pubkey);
|
||||
|
||||
if (event.kind === 1984 && tagsAdmin) {
|
||||
const signer = new AdminSigner(conf);
|
||||
|
||||
const rel = await signer.signEvent({
|
||||
kind: 30383,
|
||||
content: '',
|
||||
tags: [
|
||||
['d', event.id],
|
||||
['p', event.pubkey],
|
||||
['k', '1984'],
|
||||
['n', 'open'],
|
||||
...[...getTagSet(event.tags, 'p')].map((pubkey) => ['P', pubkey]),
|
||||
...[...getTagSet(event.tags, 'e')].map((pubkey) => ['e', pubkey]),
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
await this.event(rel, { source: 'pipeline', signal: AbortSignal.timeout(1000) });
|
||||
}
|
||||
|
||||
if (event.kind === 3036 && tagsAdmin) {
|
||||
const signer = new AdminSigner(conf);
|
||||
|
||||
const rel = await signer.signEvent({
|
||||
kind: 30383,
|
||||
content: '',
|
||||
tags: [
|
||||
['d', event.id],
|
||||
['p', event.pubkey],
|
||||
['k', '3036'],
|
||||
['n', 'pending'],
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
await this.event(rel, { source: 'pipeline', signal: AbortSignal.timeout(1000) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Stores the event in the 'event_zaps' table */
|
||||
async handleZaps(kysely: Kysely<DittoTables>, event: NostrEvent) {
|
||||
if (event.kind !== 9735) return;
|
||||
|
||||
const zapRequestString = event?.tags?.find(([name]) => name === 'description')?.[1];
|
||||
if (!zapRequestString) return;
|
||||
const zapRequest = n.json().pipe(n.event()).optional().catch(undefined).parse(zapRequestString);
|
||||
if (!zapRequest) return;
|
||||
|
||||
const amountSchema = z.coerce.number().int().nonnegative().catch(0);
|
||||
const amount_millisats = amountSchema.parse(getAmount(event?.tags.find(([name]) => name === 'bolt11')?.[1]));
|
||||
if (!amount_millisats || amount_millisats < 1) return;
|
||||
|
||||
const zappedEventId = zapRequest.tags.find(([name]) => name === 'e')?.[1];
|
||||
if (!zappedEventId) return;
|
||||
|
||||
try {
|
||||
await kysely.insertInto('event_zaps').values({
|
||||
receipt_id: event.id,
|
||||
target_event_id: zappedEventId,
|
||||
sender_pubkey: zapRequest.pubkey,
|
||||
amount_millisats,
|
||||
comment: zapRequest.content,
|
||||
}).execute();
|
||||
} catch {
|
||||
// receipt_id is unique, do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,27 @@
|
|||
import { type DittoConf } from '@ditto/conf';
|
||||
import { ApplicationServer, PushMessageOptions, PushSubscriber, PushSubscription } from '@negrel/webpush';
|
||||
import { type NStore } from '@nostrify/nostrify';
|
||||
import { logi } from '@soapbox/logi';
|
||||
|
||||
import { Conf } from '@/config.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||
import { getInstanceMetadata } from '../utils/instance.ts';
|
||||
|
||||
interface DittoPushOpts {
|
||||
conf: DittoConf;
|
||||
store: NStore;
|
||||
}
|
||||
|
||||
export class DittoPush {
|
||||
static _server: Promise<ApplicationServer | undefined> | undefined;
|
||||
_server: Promise<ApplicationServer | undefined> | undefined;
|
||||
|
||||
static get server(): Promise<ApplicationServer | undefined> {
|
||||
constructor(private opts: DittoPushOpts) {}
|
||||
|
||||
get server(): Promise<ApplicationServer | undefined> {
|
||||
if (!this._server) {
|
||||
this._server = (async () => {
|
||||
const store = await Storages.db();
|
||||
const meta = await getInstanceMetadata(store);
|
||||
const keys = await Conf.vapidKeys;
|
||||
const { conf } = this.opts;
|
||||
|
||||
const meta = await getInstanceMetadata(this.opts);
|
||||
const keys = await conf.vapidKeys;
|
||||
|
||||
if (keys) {
|
||||
return await ApplicationServer.new({
|
||||
|
|
@ -33,7 +41,7 @@ export class DittoPush {
|
|||
return this._server;
|
||||
}
|
||||
|
||||
static async push(
|
||||
async push(
|
||||
subscription: PushSubscription,
|
||||
json: object,
|
||||
opts: PushMessageOptions = {},
|
||||
|
|
|
|||
|
|
@ -1,32 +1,34 @@
|
|||
// deno-lint-ignore-file require-await
|
||||
import { DittoConf } from '@ditto/conf';
|
||||
import { type DittoDatabase, DittoDB } from '@ditto/db';
|
||||
import { internalSubscriptionsSizeGauge } from '@ditto/metrics';
|
||||
import { logi } from '@soapbox/logi';
|
||||
|
||||
import { Conf } from '@/config.ts';
|
||||
import { wsUrlSchema } from '@/schema.ts';
|
||||
import { AdminStore } from '@/storages/AdminStore.ts';
|
||||
import { EventsDB } from '@/storages/EventsDB.ts';
|
||||
import { SearchStore } from '@/storages/search-store.ts';
|
||||
import { InternalRelay } from '@/storages/InternalRelay.ts';
|
||||
import { NPool, NRelay1 } from '@nostrify/nostrify';
|
||||
import { getRelays } from '@/utils/outbox.ts';
|
||||
import { seedZapSplits } from '@/utils/zap-split.ts';
|
||||
import { getRelays } from '../utils/outbox.ts';
|
||||
import { seedZapSplits } from '../utils/zap-split.ts';
|
||||
|
||||
export class Storages {
|
||||
private static _db: Promise<EventsDB> | undefined;
|
||||
private static _database: Promise<DittoDatabase> | undefined;
|
||||
private static _admin: Promise<AdminStore> | undefined;
|
||||
private static _client: Promise<NPool<NRelay1>> | undefined;
|
||||
private static _pubsub: Promise<InternalRelay> | undefined;
|
||||
private static _search: Promise<SearchStore> | undefined;
|
||||
export class DittoStorages {
|
||||
private _db: Promise<EventsDB> | undefined;
|
||||
private _database: Promise<DittoDatabase> | undefined;
|
||||
private _admin: Promise<AdminStore> | undefined;
|
||||
private _pool: Promise<NPool<NRelay1>> | undefined;
|
||||
private _pubsub: Promise<InternalRelay> | undefined;
|
||||
|
||||
constructor(private conf: DittoConf) {}
|
||||
|
||||
public async database(): Promise<DittoDatabase> {
|
||||
const { conf } = this;
|
||||
|
||||
public static async database(): Promise<DittoDatabase> {
|
||||
if (!this._database) {
|
||||
this._database = (async () => {
|
||||
const db = DittoDB.create(Conf.databaseUrl, {
|
||||
poolSize: Conf.pg.poolSize,
|
||||
debug: Conf.pgliteDebug,
|
||||
const db = DittoDB.create(conf.databaseUrl, {
|
||||
poolSize: conf.pg.poolSize,
|
||||
debug: conf.pgliteDebug,
|
||||
});
|
||||
await DittoDB.migrate(db.kysely);
|
||||
return db;
|
||||
|
|
@ -35,17 +37,19 @@ export class Storages {
|
|||
return this._database;
|
||||
}
|
||||
|
||||
public static async kysely(): Promise<DittoDatabase['kysely']> {
|
||||
public async kysely(): Promise<DittoDatabase['kysely']> {
|
||||
const { kysely } = await this.database();
|
||||
return kysely;
|
||||
}
|
||||
|
||||
/** SQL database to store events this Ditto server cares about. */
|
||||
public static async db(): Promise<EventsDB> {
|
||||
public async db(): Promise<EventsDB> {
|
||||
const { conf } = this;
|
||||
|
||||
if (!this._db) {
|
||||
this._db = (async () => {
|
||||
const kysely = await this.kysely();
|
||||
const store = new EventsDB({ kysely, pubkey: Conf.pubkey, timeout: Conf.db.timeouts.default });
|
||||
const store = new EventsDB({ kysely, pubkey: conf.pubkey, timeout: conf.db.timeouts.default });
|
||||
await seedZapSplits(store);
|
||||
return store;
|
||||
})();
|
||||
|
|
@ -54,7 +58,7 @@ export class Storages {
|
|||
}
|
||||
|
||||
/** Admin user storage. */
|
||||
public static async admin(): Promise<AdminStore> {
|
||||
public async admin(): Promise<AdminStore> {
|
||||
if (!this._admin) {
|
||||
this._admin = Promise.resolve(new AdminStore(await this.db()));
|
||||
}
|
||||
|
|
@ -62,7 +66,7 @@ export class Storages {
|
|||
}
|
||||
|
||||
/** Internal pubsub relay between controllers and the pipeline. */
|
||||
public static async pubsub(): Promise<InternalRelay> {
|
||||
public async pubsub(): Promise<InternalRelay> {
|
||||
if (!this._pubsub) {
|
||||
this._pubsub = Promise.resolve(new InternalRelay({ gauge: internalSubscriptionsSizeGauge }));
|
||||
}
|
||||
|
|
@ -70,13 +74,15 @@ export class Storages {
|
|||
}
|
||||
|
||||
/** Relay pool storage. */
|
||||
public static async client(): Promise<NPool<NRelay1>> {
|
||||
if (!this._client) {
|
||||
this._client = (async () => {
|
||||
public async pool(): Promise<NPool<NRelay1>> {
|
||||
const { conf } = this;
|
||||
|
||||
if (!this._pool) {
|
||||
this._pool = (async () => {
|
||||
const db = await this.db();
|
||||
|
||||
const [relayList] = await db.query([
|
||||
{ kinds: [10002], authors: [Conf.pubkey], limit: 1 },
|
||||
{ kinds: [10002], authors: [conf.pubkey], limit: 1 },
|
||||
]);
|
||||
|
||||
const tags = relayList?.tags ?? [];
|
||||
|
|
@ -113,8 +119,8 @@ export class Storages {
|
|||
}));
|
||||
},
|
||||
eventRouter: async (event) => {
|
||||
const relaySet = await getRelays(await Storages.db(), event.pubkey);
|
||||
relaySet.delete(Conf.relay);
|
||||
const relaySet = await getRelays(await this.db(), event.pubkey);
|
||||
relaySet.delete(conf.relay);
|
||||
|
||||
const relays = [...relaySet].slice(0, 4);
|
||||
return relays;
|
||||
|
|
@ -122,19 +128,6 @@ export class Storages {
|
|||
});
|
||||
})();
|
||||
}
|
||||
return this._client;
|
||||
}
|
||||
|
||||
/** Storage to use for remote search. */
|
||||
public static async search(): Promise<SearchStore> {
|
||||
if (!this._search) {
|
||||
this._search = Promise.resolve(
|
||||
new SearchStore({
|
||||
relay: Conf.searchRelay,
|
||||
fallback: await this.db(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return this._search;
|
||||
return this._pool;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
import { Time } from '@/utils/time.ts';
|
||||
import { Time } from '../utils/time.ts';
|
||||
|
||||
export interface DittoUpload {
|
||||
id: string;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,21 @@
|
|||
import { confMw } from '@ditto/api/middleware';
|
||||
import { type DittoConf } from '@ditto/conf';
|
||||
import { DittoTables } from '@ditto/db';
|
||||
import { DittoApp } from '@ditto/api';
|
||||
import { DittoConf } from '@ditto/conf';
|
||||
import { DittoDatabase, DittoTables } from '@ditto/db';
|
||||
import { type DittoTranslator } from '@ditto/translators';
|
||||
import { type Context, Env as HonoEnv, Handler, Hono, Input as HonoInput, MiddlewareHandler } from '@hono/hono';
|
||||
import { type Context, Env as HonoEnv, Handler, Input as HonoInput, MiddlewareHandler } from '@hono/hono';
|
||||
import { every } from '@hono/hono/combine';
|
||||
import { cors } from '@hono/hono/cors';
|
||||
import { serveStatic } from '@hono/hono/deno';
|
||||
import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify';
|
||||
import { NostrSigner, NPool, NRelay, NRelay1, NStore, NUploader } from '@nostrify/nostrify';
|
||||
import { Kysely } from 'kysely';
|
||||
|
||||
import '@/startup.ts';
|
||||
|
||||
import { Time } from '@/utils/time.ts';
|
||||
import { cron } from '@/cron.ts';
|
||||
import { DittoPipeline } from '@/DittoPipeline.ts';
|
||||
import { DittoStorages } from '@/DittoStorages.ts';
|
||||
import { startFirehose } from '@/firehose.ts';
|
||||
import { startNotify } from '@/notify.ts';
|
||||
import { EventsDB } from '@/storages/EventsDB.ts';
|
||||
import { Time } from '../utils/time.ts';
|
||||
|
||||
import {
|
||||
accountController,
|
||||
|
|
@ -135,7 +139,11 @@ import { metricsController } from '@/controllers/metrics.ts';
|
|||
import { manifestController } from '@/controllers/manifest.ts';
|
||||
import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts';
|
||||
import { nostrController } from '@/controllers/well-known/nostr.ts';
|
||||
<<<<<<< HEAD
|
||||
import { requireProof, requireRole } from '@/middleware/auth98Middleware.ts';
|
||||
=======
|
||||
import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts';
|
||||
>>>>>>> origin/main
|
||||
import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts';
|
||||
import { cspMiddleware } from '@/middleware/cspMiddleware.ts';
|
||||
import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts';
|
||||
|
|
@ -144,30 +152,73 @@ import { paginationMiddleware } from '@/middleware/paginationMiddleware.ts';
|
|||
import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts';
|
||||
import { requireSigner } from '@/middleware/requireSigner.ts';
|
||||
import { signerMiddleware } from '@/middleware/signerMiddleware.ts';
|
||||
import { storeMiddleware } from '@/middleware/storeMiddleware.ts';
|
||||
import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
|
||||
import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts';
|
||||
import { logiMiddleware } from '@/middleware/logiMiddleware.ts';
|
||||
|
||||
const conf = new DittoConf(Deno.env);
|
||||
const storages = new DittoStorages(conf);
|
||||
|
||||
const [kysely, relay, pool, pubsub, db] = await Promise.all([
|
||||
storages.kysely(),
|
||||
storages.db(),
|
||||
storages.pool(),
|
||||
storages.pubsub(),
|
||||
storages.database(),
|
||||
]);
|
||||
|
||||
const pipeline = new DittoPipeline({ conf, kysely, store, pubsub });
|
||||
|
||||
if (conf.firehoseEnabled) {
|
||||
startFirehose(
|
||||
{ concurrency: conf.firehoseConcurrency, kinds: conf.firehoseKinds, store: pool },
|
||||
(event) => pipeline.event(event, { signal: AbortSignal.timeout(5000) }),
|
||||
);
|
||||
}
|
||||
|
||||
if (conf.notifyEnabled) {
|
||||
startNotify({ conf, db, store, pipeline });
|
||||
}
|
||||
|
||||
if (conf.cronEnabled) {
|
||||
cron({ conf, kysely, store });
|
||||
}
|
||||
|
||||
export interface AppEnv extends HonoEnv {
|
||||
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?: NostrSigner;
|
||||
user?: {
|
||||
/** 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;
|
||||
/** Storage for the user, might filter out unwanted content. */
|
||||
store: NStore;
|
||||
};
|
||||
service?: {
|
||||
/** Service signer. */
|
||||
signer: NostrSigner;
|
||||
/** Store for service actions. */
|
||||
store: NStore;
|
||||
};
|
||||
/** Uploader for the user to upload files. */
|
||||
uploader?: NUploader;
|
||||
/** NIP-98 signed event proving the pubkey is owned by the user. */
|
||||
proof?: NostrEvent;
|
||||
/** Kysely instance for the database. */
|
||||
kysely: Kysely<DittoTables>;
|
||||
/** Storage for the user, might filter out unwanted content. */
|
||||
store: NStore;
|
||||
/** Main database. */
|
||||
store: EventsDB;
|
||||
/** Internal Nostr relay for realtime subscriptions. */
|
||||
pubsub: NRelay;
|
||||
/** Nostr relay pool. */
|
||||
pool: NPool<NRelay1>;
|
||||
/** Database object. */
|
||||
db: DittoDatabase;
|
||||
/** Normalized pagination params. */
|
||||
pagination: { since?: number; until?: number; limit: number };
|
||||
/** Normalized list pagination params. */
|
||||
listPagination: { offset: number; limit: number };
|
||||
/** Translation service. */
|
||||
translator?: DittoTranslator;
|
||||
signal: AbortSignal;
|
||||
pipeline: DittoPipeline;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -176,14 +227,14 @@ type AppMiddleware = MiddlewareHandler<AppEnv>;
|
|||
// deno-lint-ignore no-explicit-any
|
||||
type AppController<P extends string = any> = Handler<AppEnv, P, HonoInput, Response | Promise<Response>>;
|
||||
|
||||
const app = new Hono<AppEnv>({ strict: false });
|
||||
const app = new DittoApp({ conf, db, relay }, { strict: false });
|
||||
|
||||
/** User-provided files in the gitignored `public/` directory. */
|
||||
const publicFiles = serveStatic({ root: './public/' });
|
||||
const publicFiles = serveStatic({ root: conf.publicDir });
|
||||
/** 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(confMw(Deno.env), cacheControlMiddleware({ noStore: true }));
|
||||
app.use(cacheControlMiddleware({ noStore: true }));
|
||||
|
||||
const ratelimit = every(
|
||||
rateLimitMiddleware(30, Time.seconds(5), false),
|
||||
|
|
@ -203,8 +254,6 @@ app.use(
|
|||
cors({ origin: '*', exposeHeaders: ['link'] }),
|
||||
signerMiddleware,
|
||||
uploaderMiddleware,
|
||||
auth98Middleware(),
|
||||
storeMiddleware,
|
||||
);
|
||||
|
||||
app.get('/metrics', metricsController);
|
||||
|
|
@ -251,7 +300,7 @@ app.post('/oauth/revoke', revokeTokenController);
|
|||
app.post('/oauth/authorize', oauthAuthorizeController);
|
||||
app.get('/oauth/authorize', oauthController);
|
||||
|
||||
app.post('/api/v1/accounts', requireProof({ pow: 20 }), createAccountController);
|
||||
app.post('/api/v1/accounts', requireProof(), createAccountController);
|
||||
app.get('/api/v1/accounts/verify_credentials', requireSigner, verifyCredentialsController);
|
||||
app.patch('/api/v1/accounts/update_credentials', requireSigner, updateCredentialsController);
|
||||
app.get('/api/v1/accounts/search', accountSearchController);
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
export const pipelineEncounters = new LRUCache<string, true>({ max: 5000 });
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { LanguageCode } from 'iso-639-1';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
|
||||
import { Conf } from '@/config.ts';
|
||||
import { DittoConf } from '@ditto/conf';
|
||||
import { MastodonTranslation } from '@/entities/MastodonTranslation.ts';
|
||||
|
||||
/** Translations LRU cache. */
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
import { DittoConf } from '@ditto/conf';
|
||||
|
||||
/** @deprecated Use middleware to set/get the config instead. */
|
||||
export const Conf = new DittoConf(Deno.env);
|
||||
|
|
@ -1,39 +1,46 @@
|
|||
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
||||
import { NostrEvent, NostrFilter, NSchema as n, NStore } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type AppController } from '@/app.ts';
|
||||
import { getAuthor, getFollowedPubkeys } from '@/queries.ts';
|
||||
import { booleanParamSchema, fileSchema } from '@/schema.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { uploadFile } from '@/utils/upload.ts';
|
||||
import { uploadFile } from '../../../utils/upload.ts';
|
||||
import { nostrNow } from '@/utils.ts';
|
||||
import { assertAuthenticated, createEvent, paginated, parseBody, updateEvent, updateListEvent } from '@/utils/api.ts';
|
||||
import { extractIdentifier, lookupAccount, lookupPubkey } from '@/utils/lookup.ts';
|
||||
import {
|
||||
assertAuthenticated,
|
||||
createEvent,
|
||||
paginated,
|
||||
parseBody,
|
||||
updateEvent,
|
||||
updateListEvent,
|
||||
} from '../../../utils/api.ts';
|
||||
import { extractIdentifier, lookupAccount, lookupPubkey } from '../../../utils/lookup.ts';
|
||||
import { renderAccounts, renderEventAccounts, renderStatuses } from '@/views.ts';
|
||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
import { AccountView } from '@/views/mastodon/AccountView.ts';
|
||||
import { renderRelationship } from '@/views/mastodon/relationships.ts';
|
||||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { StatusView } from '@/views/mastodon/StatusView.ts';
|
||||
import { metadataSchema } from '@/schemas/nostr.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { bech32ToPubkey } from '@/utils.ts';
|
||||
import { addTag, deleteTag, findReplyTag, getTagSet } from '@/utils/tags.ts';
|
||||
import { getPubkeysBySearch } from '@/utils/search.ts';
|
||||
import { MastodonAccount } from '@/entities/MastodonAccount.ts';
|
||||
import { addTag, deleteTag, findReplyTag, getTagSet } from '../../../utils/tags.ts';
|
||||
import { getPubkeysBySearch } from '../../../utils/search.ts';
|
||||
|
||||
const createAccountSchema = z.object({
|
||||
username: z.string().min(1).max(30).regex(/^[a-z0-9_]+$/i),
|
||||
});
|
||||
|
||||
const createAccountController: AppController = async (c) => {
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { conf, user } = c.var;
|
||||
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
const result = createAccountSchema.safeParse(await c.req.json());
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'Bad request', schema: result.error }, 400);
|
||||
}
|
||||
|
||||
if (c.var.conf.forbiddenUsernames.includes(result.data.username)) {
|
||||
if (conf.forbiddenUsernames.includes(result.data.username)) {
|
||||
return c.json({ error: 'Username is reserved.' }, 422);
|
||||
}
|
||||
|
||||
|
|
@ -46,13 +53,11 @@ const createAccountController: AppController = async (c) => {
|
|||
};
|
||||
|
||||
const verifyCredentialsController: AppController = async (c) => {
|
||||
const signer = c.get('signer')!;
|
||||
const pubkey = await signer.getPublicKey();
|
||||
|
||||
const store = await Storages.db();
|
||||
const { user, store } = c.var;
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
|
||||
const [author, [settingsEvent]] = await Promise.all([
|
||||
getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }),
|
||||
getAuthor(c.var, pubkey),
|
||||
|
||||
store.query([{
|
||||
kinds: [30078],
|
||||
|
|
@ -69,23 +74,24 @@ const verifyCredentialsController: AppController = async (c) => {
|
|||
// Do nothing
|
||||
}
|
||||
|
||||
const account = author
|
||||
? await renderAccount(author, { withSource: true, settingsStore })
|
||||
: await accountFromPubkey(pubkey, { withSource: true, settingsStore });
|
||||
const view = new AccountView(c.var);
|
||||
const account = view.render(author, pubkey, { withSource: true, settingsStore });
|
||||
|
||||
return c.json(account);
|
||||
};
|
||||
|
||||
const accountController: AppController = async (c) => {
|
||||
const pubkey = c.req.param('pubkey');
|
||||
const event = await getAuthor(c.var, pubkey);
|
||||
|
||||
const event = await getAuthor(pubkey);
|
||||
if (event) {
|
||||
assertAuthenticated(c, event);
|
||||
return c.json(await renderAccount(event));
|
||||
} else {
|
||||
return c.json(await accountFromPubkey(pubkey));
|
||||
}
|
||||
|
||||
const view = new AccountView(c.var);
|
||||
const account = view.render(event, pubkey);
|
||||
|
||||
return c.json(account);
|
||||
};
|
||||
|
||||
const accountLookupController: AppController = async (c) => {
|
||||
|
|
@ -95,17 +101,23 @@ const accountLookupController: AppController = async (c) => {
|
|||
return c.json({ error: 'Missing `acct` query parameter.' }, 422);
|
||||
}
|
||||
|
||||
const event = await lookupAccount(decodeURIComponent(acct));
|
||||
const view = new AccountView(c.var);
|
||||
const event = await lookupAccount(c.var, decodeURIComponent(acct));
|
||||
|
||||
if (event) {
|
||||
assertAuthenticated(c, event);
|
||||
return c.json(await renderAccount(event));
|
||||
const account = view.render(event);
|
||||
return c.json(account);
|
||||
}
|
||||
try {
|
||||
const pubkey = bech32ToPubkey(decodeURIComponent(acct));
|
||||
return c.json(await accountFromPubkey(pubkey!));
|
||||
} catch {
|
||||
return c.json({ error: 'Could not find user.' }, 404);
|
||||
|
||||
const pubkey = bech32ToPubkey(decodeURIComponent(acct));
|
||||
|
||||
if (pubkey) {
|
||||
const account = view.render(undefined, pubkey);
|
||||
return c.json(account);
|
||||
}
|
||||
|
||||
return c.json({ error: 'Could not find user.' }, 404);
|
||||
};
|
||||
|
||||
const accountSearchQuerySchema = z.object({
|
||||
|
|
@ -115,11 +127,8 @@ const accountSearchQuerySchema = z.object({
|
|||
});
|
||||
|
||||
const accountSearchController: AppController = async (c) => {
|
||||
const { signal } = c.req.raw;
|
||||
const { limit } = c.get('pagination');
|
||||
|
||||
const kysely = await Storages.kysely();
|
||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
||||
const { store, kysely, user, pagination, signal } = c.var;
|
||||
const { limit } = pagination;
|
||||
|
||||
const result = accountSearchQuerySchema.safeParse(c.req.query());
|
||||
|
||||
|
|
@ -128,14 +137,14 @@ const accountSearchController: AppController = async (c) => {
|
|||
}
|
||||
|
||||
const query = decodeURIComponent(result.data.q);
|
||||
const store = await Storages.search();
|
||||
|
||||
const lookup = extractIdentifier(query);
|
||||
const event = await lookupAccount(lookup ?? query);
|
||||
const event = await lookupAccount(c.var, lookup ?? query);
|
||||
const view = new AccountView(c.var);
|
||||
const viewerPubkey = await user?.signer.getPublicKey();
|
||||
|
||||
if (!event && lookup) {
|
||||
const pubkey = await lookupPubkey(lookup);
|
||||
return c.json(pubkey ? [accountFromPubkey(pubkey)] : []);
|
||||
const pubkey = await lookupPubkey(c.var, lookup);
|
||||
return c.json(pubkey ? [view.render(undefined, pubkey)] : []);
|
||||
}
|
||||
|
||||
const events: NostrEvent[] = [];
|
||||
|
|
@ -143,7 +152,7 @@ const accountSearchController: AppController = async (c) => {
|
|||
if (event) {
|
||||
events.push(event);
|
||||
} else {
|
||||
const following = viewerPubkey ? await getFollowedPubkeys(viewerPubkey) : new Set<string>();
|
||||
const following = viewerPubkey ? await getFollowedPubkeys(c.var, viewerPubkey) : new Set<string>();
|
||||
const authors = [...await getPubkeysBySearch(kysely, { q: query, limit, offset: 0, following })];
|
||||
const profiles = await store.query([{ kinds: [0], authors, limit }], { signal });
|
||||
|
||||
|
|
@ -155,25 +164,25 @@ const accountSearchController: AppController = async (c) => {
|
|||
}
|
||||
}
|
||||
|
||||
const accounts = await hydrateEvents({ events, store, signal })
|
||||
.then((events) => events.map((event) => renderAccount(event)));
|
||||
const accounts = await hydrateEvents(c.var, events)
|
||||
.then((events) => events.map((event) => view.render(event)));
|
||||
|
||||
return c.json(accounts);
|
||||
};
|
||||
|
||||
const relationshipsController: AppController = async (c) => {
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { store, user } = c.var;
|
||||
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
const ids = z.array(z.string()).safeParse(c.req.queries('id[]'));
|
||||
|
||||
if (!ids.success) {
|
||||
return c.json({ error: 'Missing `id[]` query parameters.' }, 422);
|
||||
}
|
||||
|
||||
const db = await Storages.db();
|
||||
|
||||
const [sourceEvents, targetEvents] = await Promise.all([
|
||||
db.query([{ kinds: [3, 10000], authors: [pubkey] }]),
|
||||
db.query([{ kinds: [3], authors: ids.data }]),
|
||||
store.query([{ kinds: [3, 10000], authors: [pubkey] }]),
|
||||
store.query([{ kinds: [3], authors: ids.data }]),
|
||||
]);
|
||||
|
||||
const event3 = sourceEvents.find((event) => event.kind === 3 && event.pubkey === pubkey);
|
||||
|
|
@ -194,7 +203,6 @@ const relationshipsController: AppController = async (c) => {
|
|||
|
||||
const accountStatusesQuerySchema = z.object({
|
||||
pinned: booleanParamSchema.optional(),
|
||||
limit: z.coerce.number().nonnegative().transform((v) => Math.min(v, 40)).catch(20),
|
||||
exclude_replies: booleanParamSchema.optional(),
|
||||
tagged: z.string().optional(),
|
||||
only_media: booleanParamSchema.optional(),
|
||||
|
|
@ -202,12 +210,9 @@ const accountStatusesQuerySchema = z.object({
|
|||
|
||||
const accountStatusesController: AppController = async (c) => {
|
||||
const pubkey = c.req.param('pubkey');
|
||||
const { conf } = c.var;
|
||||
const { since, until } = c.var.pagination;
|
||||
const { pinned, limit, exclude_replies, tagged, only_media } = accountStatusesQuerySchema.parse(c.req.query());
|
||||
const { signal } = c.req.raw;
|
||||
|
||||
const store = await Storages.db();
|
||||
const { conf, store, pagination, signal } = c.var;
|
||||
const { pinned, exclude_replies, tagged, only_media } = accountStatusesQuerySchema.parse(c.req.query());
|
||||
|
||||
const [[author], [user]] = await Promise.all([
|
||||
store.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal }),
|
||||
|
|
@ -237,9 +242,7 @@ const accountStatusesController: AppController = async (c) => {
|
|||
const filter: NostrFilter = {
|
||||
authors: [pubkey],
|
||||
kinds: [1, 6, 20],
|
||||
since,
|
||||
until,
|
||||
limit,
|
||||
...pagination,
|
||||
};
|
||||
|
||||
const search: string[] = [];
|
||||
|
|
@ -260,10 +263,10 @@ const accountStatusesController: AppController = async (c) => {
|
|||
filter.search = search.join(' ');
|
||||
}
|
||||
|
||||
const opts = { signal, limit, timeout: conf.db.timeouts.timelines };
|
||||
const opts = { signal, timeout: conf.db.timeouts.timelines };
|
||||
|
||||
const events = await store.query([filter], opts)
|
||||
.then((events) => hydrateEvents({ events, store, signal }))
|
||||
.then((events) => hydrateEvents(c.var, events))
|
||||
.then((events) => {
|
||||
if (exclude_replies) {
|
||||
return events.filter((event) => {
|
||||
|
|
@ -274,14 +277,12 @@ const accountStatusesController: AppController = async (c) => {
|
|||
return events;
|
||||
});
|
||||
|
||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
||||
const view = new StatusView(c.var);
|
||||
|
||||
const statuses = await Promise.all(
|
||||
events.map((event) => {
|
||||
if (event.kind === 6) return renderReblog(event, { viewerPubkey });
|
||||
return renderStatus(event, { viewerPubkey });
|
||||
}),
|
||||
events.map((event) => view.render(event)),
|
||||
);
|
||||
|
||||
return paginated(c, events, statuses);
|
||||
};
|
||||
|
||||
|
|
@ -301,12 +302,11 @@ const updateCredentialsSchema = z.object({
|
|||
});
|
||||
|
||||
const updateCredentialsController: AppController = async (c) => {
|
||||
const signer = c.get('signer')!;
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const { store, user } = c.var;
|
||||
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
const body = await parseBody(c.req.raw);
|
||||
const result = updateCredentialsSchema.safeParse(body);
|
||||
const store = await Storages.db();
|
||||
const signal = c.req.raw.signal;
|
||||
|
||||
if (!result.success) {
|
||||
return c.json(result.error, 422);
|
||||
|
|
@ -319,6 +319,7 @@ const updateCredentialsController: AppController = async (c) => {
|
|||
event = (await store.query([{ kinds: [0], authors: [pubkey] }]))[0];
|
||||
} else {
|
||||
event = await updateEvent(
|
||||
c.var,
|
||||
{ kinds: [0], authors: [pubkey], limit: 1 },
|
||||
async (prev) => {
|
||||
const meta = n.json().pipe(metadataSchema).catch({}).parse(prev.content);
|
||||
|
|
@ -364,26 +365,22 @@ const updateCredentialsController: AppController = async (c) => {
|
|||
tags: [],
|
||||
};
|
||||
},
|
||||
c,
|
||||
);
|
||||
}
|
||||
|
||||
const settingsStore = result.data.pleroma_settings_store;
|
||||
|
||||
let account: MastodonAccount;
|
||||
if (event) {
|
||||
await hydrateEvents({ events: [event], store, signal });
|
||||
account = await renderAccount(event, { withSource: true, settingsStore });
|
||||
} else {
|
||||
account = await accountFromPubkey(pubkey, { withSource: true, settingsStore });
|
||||
}
|
||||
await hydrateEvents(c.var, [event]);
|
||||
|
||||
const view = new AccountView(c.var);
|
||||
const account = view.render(event, pubkey, { withSource: true, settingsStore });
|
||||
|
||||
if (settingsStore) {
|
||||
await createEvent({
|
||||
await createEvent(c.var, {
|
||||
kind: 30078,
|
||||
tags: [['d', 'pub.ditto.pleroma_settings_store']],
|
||||
content: JSON.stringify(settingsStore),
|
||||
}, c);
|
||||
});
|
||||
}
|
||||
|
||||
return c.json(account);
|
||||
|
|
@ -391,16 +388,19 @@ const updateCredentialsController: AppController = async (c) => {
|
|||
|
||||
/** https://docs.joinmastodon.org/methods/accounts/#follow */
|
||||
const followController: AppController = async (c) => {
|
||||
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { store, user } = c.var;
|
||||
|
||||
const sourcePubkey = await user!.signer.getPublicKey();
|
||||
const targetPubkey = c.req.param('pubkey');
|
||||
|
||||
await updateListEvent(
|
||||
c.var,
|
||||
{ kinds: [3], authors: [sourcePubkey], limit: 1 },
|
||||
(tags) => addTag(tags, ['p', targetPubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
const relationship = await getRelationship(sourcePubkey, targetPubkey);
|
||||
const relationship = await getRelationship(store, sourcePubkey, targetPubkey);
|
||||
|
||||
relationship.following = true;
|
||||
|
||||
return c.json(relationship);
|
||||
|
|
@ -408,16 +408,18 @@ const followController: AppController = async (c) => {
|
|||
|
||||
/** https://docs.joinmastodon.org/methods/accounts/#unfollow */
|
||||
const unfollowController: AppController = async (c) => {
|
||||
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { store, user } = c.var;
|
||||
|
||||
const sourcePubkey = await user!.signer.getPublicKey();
|
||||
const targetPubkey = c.req.param('pubkey');
|
||||
|
||||
await updateListEvent(
|
||||
c.var,
|
||||
{ kinds: [3], authors: [sourcePubkey], limit: 1 },
|
||||
(tags) => deleteTag(tags, ['p', targetPubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
const relationship = await getRelationship(sourcePubkey, targetPubkey);
|
||||
const relationship = await getRelationship(store, sourcePubkey, targetPubkey);
|
||||
return c.json(relationship);
|
||||
};
|
||||
|
||||
|
|
@ -429,7 +431,7 @@ const followersController: AppController = (c) => {
|
|||
|
||||
const followingController: AppController = async (c) => {
|
||||
const pubkey = c.req.param('pubkey');
|
||||
const pubkeys = await getFollowedPubkeys(pubkey);
|
||||
const pubkeys = await getFollowedPubkeys(c.var, pubkey);
|
||||
return renderAccounts(c, [...pubkeys]);
|
||||
};
|
||||
|
||||
|
|
@ -445,43 +447,45 @@ const unblockController: AppController = (c) => {
|
|||
|
||||
/** https://docs.joinmastodon.org/methods/accounts/#mute */
|
||||
const muteController: AppController = async (c) => {
|
||||
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { store, user } = c.var;
|
||||
|
||||
const sourcePubkey = await user!.signer.getPublicKey();
|
||||
const targetPubkey = c.req.param('pubkey');
|
||||
|
||||
await updateListEvent(
|
||||
c.var,
|
||||
{ kinds: [10000], authors: [sourcePubkey], limit: 1 },
|
||||
(tags) => addTag(tags, ['p', targetPubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
const relationship = await getRelationship(sourcePubkey, targetPubkey);
|
||||
const relationship = await getRelationship(store, sourcePubkey, targetPubkey);
|
||||
return c.json(relationship);
|
||||
};
|
||||
|
||||
/** https://docs.joinmastodon.org/methods/accounts/#unmute */
|
||||
const unmuteController: AppController = async (c) => {
|
||||
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { store, user } = c.var;
|
||||
|
||||
const sourcePubkey = await user!.signer.getPublicKey();
|
||||
const targetPubkey = c.req.param('pubkey');
|
||||
|
||||
await updateListEvent(
|
||||
c.var,
|
||||
{ kinds: [10000], authors: [sourcePubkey], limit: 1 },
|
||||
(tags) => deleteTag(tags, ['p', targetPubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
const relationship = await getRelationship(sourcePubkey, targetPubkey);
|
||||
const relationship = await getRelationship(store, sourcePubkey, targetPubkey);
|
||||
return c.json(relationship);
|
||||
};
|
||||
|
||||
const favouritesController: AppController = async (c) => {
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const params = c.get('pagination');
|
||||
const { signal } = c.req.raw;
|
||||
const { store, user, pagination, signal } = c.var;
|
||||
|
||||
const store = await Storages.db();
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
|
||||
const events7 = await store.query(
|
||||
[{ kinds: [7], authors: [pubkey], ...params }],
|
||||
[{ kinds: [7], authors: [pubkey], ...pagination }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
|
|
@ -489,32 +493,32 @@ const favouritesController: AppController = async (c) => {
|
|||
.map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1])
|
||||
.filter((id): id is string => !!id);
|
||||
|
||||
const events1 = await store.query([{ kinds: [1, 20], ids }], { signal })
|
||||
.then((events) => hydrateEvents({ events, store, signal }));
|
||||
const events = await store.query([{ kinds: [1, 20], ids }], { signal })
|
||||
.then((events) => hydrateEvents(c.var, events));
|
||||
|
||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
||||
const view = new StatusView(c.var);
|
||||
|
||||
const statuses = await Promise.all(
|
||||
events1.map((event) => renderStatus(event, { viewerPubkey })),
|
||||
events.map((event) => view.render(event)),
|
||||
);
|
||||
return paginated(c, events1, statuses);
|
||||
|
||||
return paginated(c, events, statuses);
|
||||
};
|
||||
|
||||
const familiarFollowersController: AppController = async (c) => {
|
||||
const store = await Storages.db();
|
||||
const signer = c.get('signer')!;
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const { store, user } = c.var;
|
||||
|
||||
const ids = z.array(z.string()).parse(c.req.queries('id[]'));
|
||||
const follows = await getFollowedPubkeys(pubkey);
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
const follows = await getFollowedPubkeys(c.var, pubkey);
|
||||
const view = new AccountView(c.var);
|
||||
|
||||
const results = await Promise.all(ids.map(async (id) => {
|
||||
const followLists = await store.query([{ kinds: [3], authors: [...follows], '#p': [id] }])
|
||||
.then((events) => hydrateEvents({ events, store }));
|
||||
const followLists = await store
|
||||
.query([{ kinds: [3], authors: [...follows], '#p': [id] }])
|
||||
.then((events) => hydrateEvents(c.var, events));
|
||||
|
||||
const accounts = await Promise.all(
|
||||
followLists.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)),
|
||||
);
|
||||
const accounts = followLists.map((event) => view.render(event.author, event.pubkey));
|
||||
|
||||
return { id, accounts };
|
||||
}));
|
||||
|
|
@ -522,12 +526,10 @@ const familiarFollowersController: AppController = async (c) => {
|
|||
return c.json(results);
|
||||
};
|
||||
|
||||
async function getRelationship(sourcePubkey: string, targetPubkey: string) {
|
||||
const db = await Storages.db();
|
||||
|
||||
async function getRelationship(store: NStore, sourcePubkey: string, targetPubkey: string) {
|
||||
const [sourceEvents, targetEvents] = await Promise.all([
|
||||
db.query([{ kinds: [3, 10000], authors: [sourcePubkey] }]),
|
||||
db.query([{ kinds: [3], authors: [targetPubkey] }]),
|
||||
store.query([{ kinds: [3, 10000], authors: [sourcePubkey] }]),
|
||||
store.query([{ kinds: [3], authors: [targetPubkey] }]),
|
||||
]);
|
||||
|
||||
return renderRelationship({
|
||||
|
|
|
|||
|
|
@ -4,12 +4,11 @@ import { z } from 'zod';
|
|||
|
||||
import { type AppController } from '@/app.ts';
|
||||
import { booleanParamSchema } from '@/schema.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { createAdminEvent, paginated, parseBody, updateEventInfo, updateUser } from '@/utils/api.ts';
|
||||
import { createAdminEvent, paginated, parseBody, updateEventInfo, updateUser } from '../../../utils/api.ts';
|
||||
import { renderNameRequest } from '@/views/ditto.ts';
|
||||
import { renderAdminAccount, renderAdminAccountFromPubkey } from '@/views/mastodon/admin-accounts.ts';
|
||||
import { errorJson } from '@/utils/log.ts';
|
||||
import { AdminAccountView } from '@/views/mastodon/AdminAccountView.ts';
|
||||
import { errorJson } from '../../../utils/log.ts';
|
||||
|
||||
const adminAccountQuerySchema = z.object({
|
||||
local: booleanParamSchema.optional(),
|
||||
|
|
@ -29,10 +28,8 @@ const adminAccountQuerySchema = z.object({
|
|||
});
|
||||
|
||||
const adminAccountsController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const store = await Storages.db();
|
||||
const params = c.get('pagination');
|
||||
const { signal } = c.req.raw;
|
||||
const { conf, store, pagination, signal } = c.var;
|
||||
|
||||
const {
|
||||
local,
|
||||
pending,
|
||||
|
|
@ -43,13 +40,15 @@ const adminAccountsController: AppController = async (c) => {
|
|||
staff,
|
||||
} = adminAccountQuerySchema.parse(c.req.query());
|
||||
|
||||
const view = new AdminAccountView(c.var);
|
||||
|
||||
if (pending) {
|
||||
if (disabled || silenced || suspended || sensitized) {
|
||||
return c.json([]);
|
||||
}
|
||||
|
||||
const orig = await store.query(
|
||||
[{ kinds: [30383], authors: [conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...params }],
|
||||
[{ kinds: [30383], authors: [conf.pubkey], '#k': ['3036'], '#n': ['pending'], ...pagination }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
|
|
@ -59,8 +58,9 @@ const adminAccountsController: AppController = async (c) => {
|
|||
.filter((id): id is string => !!id),
|
||||
);
|
||||
|
||||
const events = await store.query([{ kinds: [3036], ids: [...ids] }])
|
||||
.then((events) => hydrateEvents({ store, events, signal }));
|
||||
const events = await store
|
||||
.query([{ kinds: [3036], ids: [...ids] }])
|
||||
.then((events) => hydrateEvents(c.var, events));
|
||||
|
||||
const nameRequests = await Promise.all(events.map(renderNameRequest));
|
||||
return paginated(c, orig, nameRequests);
|
||||
|
|
@ -86,7 +86,10 @@ const adminAccountsController: AppController = async (c) => {
|
|||
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, ...pagination }],
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const pubkeys = new Set<string>(
|
||||
events
|
||||
|
|
@ -94,29 +97,30 @@ const adminAccountsController: AppController = async (c) => {
|
|||
.filter((pubkey): pubkey is string => !!pubkey),
|
||||
);
|
||||
|
||||
const authors = await store.query([{ kinds: [0], authors: [...pubkeys] }])
|
||||
.then((events) => hydrateEvents({ store, events, signal }));
|
||||
const authors = await store
|
||||
.query([{ kinds: [0], authors: [...pubkeys] }])
|
||||
.then((events) => hydrateEvents(c.var, events));
|
||||
|
||||
const accounts = await Promise.all(
|
||||
[...pubkeys].map((pubkey) => {
|
||||
const author = authors.find((e) => e.pubkey === pubkey);
|
||||
return author ? renderAdminAccount(author) : renderAdminAccountFromPubkey(pubkey);
|
||||
}),
|
||||
);
|
||||
const accounts = [...pubkeys].map((pubkey) => {
|
||||
const author = authors.find((e) => e.pubkey === pubkey);
|
||||
return view.render(author, pubkey);
|
||||
});
|
||||
|
||||
return paginated(c, events, accounts);
|
||||
}
|
||||
|
||||
const filter: NostrFilter = { kinds: [0], ...params };
|
||||
const filter: NostrFilter = { kinds: [0], ...pagination };
|
||||
|
||||
if (local) {
|
||||
filter.search = `domain:${conf.url.host}`;
|
||||
}
|
||||
|
||||
const events = await store.query([filter], { signal })
|
||||
.then((events) => hydrateEvents({ store, events, signal }));
|
||||
const events = await store
|
||||
.query([filter], { signal })
|
||||
.then((events) => hydrateEvents(c.var, events));
|
||||
|
||||
const accounts = events.map((event) => view.render(event, event.pubkey));
|
||||
|
||||
const accounts = await Promise.all(events.map(renderAdminAccount));
|
||||
return paginated(c, events, accounts);
|
||||
};
|
||||
|
||||
|
|
@ -125,12 +129,13 @@ const adminAccountActionSchema = z.object({
|
|||
});
|
||||
|
||||
const adminActionController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const body = await parseBody(c.req.raw);
|
||||
const store = await Storages.db();
|
||||
const result = adminAccountActionSchema.safeParse(body);
|
||||
const authorId = c.req.param('id');
|
||||
|
||||
const { conf, store } = c.var;
|
||||
|
||||
const body = await parseBody(c.req.raw);
|
||||
const result = adminAccountActionSchema.safeParse(body);
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'This action is not allowed' }, 403);
|
||||
}
|
||||
|
|
@ -151,46 +156,52 @@ const adminActionController: AppController = async (c) => {
|
|||
if (data.type === 'suspend') {
|
||||
n.disabled = true;
|
||||
n.suspended = true;
|
||||
store.remove([{ authors: [authorId] }]).catch((e: unknown) => {
|
||||
|
||||
store.remove!([{ authors: [authorId] }]).catch((e: unknown) => {
|
||||
logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) });
|
||||
});
|
||||
}
|
||||
if (data.type === 'revoke_name') {
|
||||
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) });
|
||||
});
|
||||
}
|
||||
|
||||
await updateUser(authorId, n, c);
|
||||
await updateUser(c.var, authorId, n);
|
||||
|
||||
return c.json({}, 200);
|
||||
};
|
||||
|
||||
const adminApproveController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const eventId = c.req.param('id');
|
||||
const store = await Storages.db();
|
||||
|
||||
const { conf, store } = c.var;
|
||||
|
||||
const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
const r = event.tags.find(([name]) => name === 'r')?.[1];
|
||||
|
||||
if (!r) {
|
||||
return c.json({ error: 'NIP-05 not found' }, 404);
|
||||
}
|
||||
|
||||
if (!z.string().email().safeParse(r).success) {
|
||||
return c.json({ error: 'Invalid NIP-05' }, 400);
|
||||
}
|
||||
|
||||
const [existing] = await store.query([{ kinds: [30360], authors: [conf.pubkey], '#d': [r], limit: 1 }]);
|
||||
|
||||
if (existing) {
|
||||
return c.json({ error: 'NIP-05 already granted to another user' }, 400);
|
||||
}
|
||||
|
||||
await createAdminEvent({
|
||||
await createAdminEvent(c.var, {
|
||||
kind: 30360,
|
||||
tags: [
|
||||
['d', r],
|
||||
|
|
@ -199,10 +210,10 @@ const adminApproveController: AppController = async (c) => {
|
|||
['p', event.pubkey],
|
||||
['e', event.id],
|
||||
],
|
||||
}, c);
|
||||
});
|
||||
|
||||
await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c);
|
||||
await hydrateEvents({ events: [event], store });
|
||||
await updateEventInfo(c.var, eventId, { pending: false, approved: true, rejected: false });
|
||||
await hydrateEvents(c.var, [event]);
|
||||
|
||||
const nameRequest = await renderNameRequest(event);
|
||||
return c.json(nameRequest);
|
||||
|
|
@ -210,17 +221,20 @@ const adminApproveController: AppController = async (c) => {
|
|||
|
||||
const adminRejectController: AppController = async (c) => {
|
||||
const eventId = c.req.param('id');
|
||||
const store = await Storages.db();
|
||||
|
||||
const { store } = c.var;
|
||||
|
||||
const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
await updateEventInfo(eventId, { pending: false, approved: false, rejected: true }, c);
|
||||
await hydrateEvents({ events: [event], store });
|
||||
await updateEventInfo(c.var, eventId, { pending: false, approved: false, rejected: true });
|
||||
await hydrateEvents(c.var, [event]);
|
||||
|
||||
const nameRequest = await renderNameRequest(event);
|
||||
return c.json(nameRequest);
|
||||
};
|
||||
|
||||
export { adminAccountsController, adminActionController, adminApproveController, adminRejectController };
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import type { AppController } from '@/app.ts';
|
||||
import { parseBody } from '@/utils/api.ts';
|
||||
import { parseBody } from '../../../utils/api.ts';
|
||||
|
||||
/**
|
||||
* Apps are unnecessary cruft in Mastodon API, but necessary to make clients work.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { type AppController } from '@/app.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { getTagSet } from '@/utils/tags.ts';
|
||||
import { getTagSet } from '../../../utils/tags.ts';
|
||||
import { renderStatuses } from '@/views.ts';
|
||||
|
||||
/** https://docs.joinmastodon.org/methods/bookmarks/#get */
|
||||
const bookmarksController: AppController = async (c) => {
|
||||
const store = await Storages.db();
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { signal } = c.req.raw;
|
||||
const { store, user, signal } = c.var;
|
||||
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
|
||||
const [event10003] = await store.query(
|
||||
[{ kinds: [10003], authors: [pubkey], limit: 1 }],
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import TTLCache from '@isaacs/ttlcache';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { updateUser } from '@/utils/api.ts';
|
||||
import { updateUser } from '../../../utils/api.ts';
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
|
|
|
|||
|
|
@ -1,22 +1,20 @@
|
|||
import { Proof } from '@cashu/cashu-ts';
|
||||
import { confRequiredMw } from '@ditto/api/middleware';
|
||||
import { Hono } from '@hono/hono';
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
||||
import { DittoRoute } from '@ditto/api';
|
||||
import { bytesToString, stringToBytes } from '@scure/base';
|
||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { createEvent, parseBody } from '@/utils/api.ts';
|
||||
import { createEvent, parseBody } from '../../../utils/api.ts';
|
||||
import { requireNip44Signer } from '@/middleware/requireSigner.ts';
|
||||
import { requireStore } from '@/middleware/storeMiddleware.ts';
|
||||
import { walletSchema } from '@/schema.ts';
|
||||
import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts';
|
||||
import { isNostrId } from '@/utils.ts';
|
||||
import { logi } from '@soapbox/logi';
|
||||
import { errorJson } from '@/utils/log.ts';
|
||||
import { errorJson } from '../../../utils/log.ts';
|
||||
|
||||
type Wallet = z.infer<typeof walletSchema>;
|
||||
|
||||
const app = new Hono().use('*', confRequiredMw, requireStore);
|
||||
const app = new DittoRoute();
|
||||
|
||||
// app.delete('/wallet') -> 204
|
||||
|
||||
|
|
@ -45,9 +43,8 @@ const createCashuWalletAndNutzapInfoSchema = z.object({
|
|||
* https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event
|
||||
*/
|
||||
app.put('/wallet', requireNip44Signer, async (c) => {
|
||||
const { conf, signer } = c.var;
|
||||
const store = c.get('store');
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const { conf, store, user } = c.var;
|
||||
const pubkey = await user.signer.getPublicKey();
|
||||
const body = await parseBody(c.req.raw);
|
||||
const { signal } = c.req.raw;
|
||||
const result = createCashuWalletAndNutzapInfoSchema.safeParse(body);
|
||||
|
|
@ -75,23 +72,23 @@ app.put('/wallet', requireNip44Signer, async (c) => {
|
|||
walletContentTags.push(['mint', mint]);
|
||||
}
|
||||
|
||||
const encryptedWalletContentTags = await signer.nip44.encrypt(pubkey, JSON.stringify(walletContentTags));
|
||||
const encryptedWalletContentTags = await user.signer.nip44.encrypt(pubkey, JSON.stringify(walletContentTags));
|
||||
|
||||
// Wallet
|
||||
await createEvent({
|
||||
await createEvent(c.var, {
|
||||
kind: 17375,
|
||||
content: encryptedWalletContentTags,
|
||||
}, c);
|
||||
});
|
||||
|
||||
// Nutzap information
|
||||
await createEvent({
|
||||
await createEvent(c.var, {
|
||||
kind: 10019,
|
||||
tags: [
|
||||
...mints.map((mint) => ['mint', mint, 'sat']),
|
||||
['relay', conf.relay], // TODO: add more relays once things get more stable
|
||||
['pubkey', p2pk],
|
||||
],
|
||||
}, c);
|
||||
});
|
||||
|
||||
// TODO: hydrate wallet and add a 'balance' field when a 'renderWallet' view function is created
|
||||
const walletEntity: Wallet = {
|
||||
|
|
@ -106,9 +103,9 @@ app.put('/wallet', requireNip44Signer, async (c) => {
|
|||
|
||||
/** Gets a wallet, if it exists. */
|
||||
app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => {
|
||||
const { conf, signer } = c.var;
|
||||
const { conf, user } = c.var;
|
||||
const store = c.get('store');
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const pubkey = await user.signer.getPublicKey();
|
||||
const { signal } = c.req.raw;
|
||||
|
||||
const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
||||
|
|
@ -116,7 +113,7 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => {
|
|||
return c.json({ error: 'Wallet not found' }, 404);
|
||||
}
|
||||
|
||||
const decryptedContent: string[][] = JSON.parse(await signer.nip44.decrypt(pubkey, event.content));
|
||||
const decryptedContent: string[][] = JSON.parse(await user.signer.nip44.decrypt(pubkey, event.content));
|
||||
|
||||
const privkey = decryptedContent.find(([value]) => value === 'privkey')?.[1];
|
||||
if (!privkey || !isNostrId(privkey)) {
|
||||
|
|
@ -132,7 +129,7 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => {
|
|||
for (const token of tokens) {
|
||||
try {
|
||||
const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse(
|
||||
await signer.nip44.decrypt(pubkey, token.content),
|
||||
await user.signer.nip44.decrypt(pubkey, token.content),
|
||||
);
|
||||
|
||||
if (!mints.includes(decryptedContent.mint)) {
|
||||
|
|
|
|||
|
|
@ -2,22 +2,19 @@ import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { getAuthor } from '@/queries.ts';
|
||||
import { addTag } from '@/utils/tags.ts';
|
||||
import { createEvent, paginated, parseBody, updateAdminEvent } from '@/utils/api.ts';
|
||||
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||
import { deleteTag } from '@/utils/tags.ts';
|
||||
import { DittoZapSplits, getZapSplits } from '@/utils/zap-split.ts';
|
||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||
import { addTag } from '../../../utils/tags.ts';
|
||||
import { createEvent, paginated, parseBody, updateAdminEvent } from '../../../utils/api.ts';
|
||||
import { getInstanceMetadata } from '../../../utils/instance.ts';
|
||||
import { deleteTag } from '../../../utils/tags.ts';
|
||||
import { DittoZapSplits, getZapSplits } from '../../../utils/zap-split.ts';
|
||||
import { AdminSigner } from '../../../signers/AdminSigner.ts';
|
||||
import { screenshotsSchema } from '@/schemas/nostr.ts';
|
||||
import { booleanParamSchema, percentageSchema, wsUrlSchema } from '@/schema.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { renderNameRequest } from '@/views/ditto.ts';
|
||||
import { accountFromPubkey } from '@/views/mastodon/accounts.ts';
|
||||
import { renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { updateListAdminEvent } from '@/utils/api.ts';
|
||||
import { AccountView } from '@/views/mastodon/AccountView.ts';
|
||||
import { updateListAdminEvent } from '../../../utils/api.ts';
|
||||
|
||||
const markerSchema = z.enum(['read', 'write']);
|
||||
|
||||
|
|
@ -29,8 +26,7 @@ const relaySchema = z.object({
|
|||
type RelayEntity = z.infer<typeof relaySchema>;
|
||||
|
||||
export const adminRelaysController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const store = await Storages.db();
|
||||
const { conf, store } = c.var;
|
||||
|
||||
const [event] = await store.query([
|
||||
{ kinds: [10002], authors: [conf.pubkey], limit: 1 },
|
||||
|
|
@ -44,10 +40,10 @@ export const adminRelaysController: AppController = async (c) => {
|
|||
};
|
||||
|
||||
export const adminSetRelaysController: AppController = async (c) => {
|
||||
const store = await Storages.db();
|
||||
const { conf, store } = c.var;
|
||||
const relays = relaySchema.array().parse(await c.req.json());
|
||||
|
||||
const event = await new AdminSigner().signEvent({
|
||||
const event = await new AdminSigner(conf).signEvent({
|
||||
kind: 10002,
|
||||
tags: relays.map(({ url, marker }) => marker ? ['r', url, marker] : ['r', url]),
|
||||
content: '',
|
||||
|
|
@ -79,19 +75,18 @@ const nameRequestSchema = z.object({
|
|||
});
|
||||
|
||||
export const nameRequestController: AppController = async (c) => {
|
||||
const store = await Storages.db();
|
||||
const signer = c.get('signer')!;
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const { conf } = c.var;
|
||||
|
||||
const { conf, store, user } = c.var;
|
||||
const { name, reason } = nameRequestSchema.parse(await c.req.json());
|
||||
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
|
||||
const [existing] = await store.query([{ kinds: [3036], authors: [pubkey], '#r': [name], limit: 1 }]);
|
||||
|
||||
if (existing) {
|
||||
return c.json({ error: 'Name request already exists' }, 400);
|
||||
}
|
||||
|
||||
const event = await createEvent({
|
||||
const event = await createEvent(c.var, {
|
||||
kind: 3036,
|
||||
content: reason,
|
||||
tags: [
|
||||
|
|
@ -100,9 +95,9 @@ export const nameRequestController: AppController = async (c) => {
|
|||
['l', name.split('@')[1], 'nip05.domain'],
|
||||
['p', conf.pubkey],
|
||||
],
|
||||
}, c);
|
||||
});
|
||||
|
||||
await hydrateEvents({ events: [event], store: await Storages.db() });
|
||||
await hydrateEvents(c.var, [event]);
|
||||
|
||||
const nameRequest = await renderNameRequest(event);
|
||||
return c.json(nameRequest);
|
||||
|
|
@ -114,10 +109,9 @@ const nameRequestsSchema = z.object({
|
|||
});
|
||||
|
||||
export const nameRequestsController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const store = await Storages.db();
|
||||
const signer = c.get('signer')!;
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const { conf, store, user } = c.var;
|
||||
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
|
||||
const params = c.get('pagination');
|
||||
const { approved, rejected } = nameRequestsSchema.parse(c.req.query());
|
||||
|
|
@ -151,8 +145,9 @@ export const nameRequestsController: AppController = async (c) => {
|
|||
return c.json([]);
|
||||
}
|
||||
|
||||
const events = await store.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }])
|
||||
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }));
|
||||
const events = await store
|
||||
.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }])
|
||||
.then((events) => hydrateEvents(c.var, events));
|
||||
|
||||
const nameRequests = await Promise.all(
|
||||
events.map((event) => renderNameRequest(event)),
|
||||
|
|
@ -170,10 +165,10 @@ const zapSplitSchema = z.record(
|
|||
);
|
||||
|
||||
export const updateZapSplitsController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const { conf, store } = c.var;
|
||||
|
||||
const body = await parseBody(c.req.raw);
|
||||
const result = zapSplitSchema.safeParse(body);
|
||||
const store = c.get('store');
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: result.error }, 400);
|
||||
|
|
@ -192,15 +187,15 @@ export const updateZapSplitsController: AppController = async (c) => {
|
|||
}
|
||||
|
||||
await updateListAdminEvent(
|
||||
c.var,
|
||||
{ kinds: [30078], authors: [conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 },
|
||||
(tags) =>
|
||||
pubkeys.reduce((accumulator, pubkey) => {
|
||||
return addTag(accumulator, ['p', pubkey, data[pubkey].weight.toString(), data[pubkey].message]);
|
||||
}, tags),
|
||||
c,
|
||||
);
|
||||
|
||||
return c.json(200);
|
||||
return c.newResponse(null, 200);
|
||||
};
|
||||
|
||||
const deleteZapSplitSchema = z.array(n.id()).min(1);
|
||||
|
|
@ -216,6 +211,7 @@ export const deleteZapSplitsController: AppController = async (c) => {
|
|||
}
|
||||
|
||||
const dittoZapSplit = await getZapSplits(store, conf.pubkey);
|
||||
|
||||
if (!dittoZapSplit) {
|
||||
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
|
||||
}
|
||||
|
|
@ -223,32 +219,32 @@ export const deleteZapSplitsController: AppController = async (c) => {
|
|||
const { data } = result;
|
||||
|
||||
await updateListAdminEvent(
|
||||
c.var,
|
||||
{ kinds: [30078], authors: [conf.pubkey], '#d': ['pub.ditto.zapSplits'], limit: 1 },
|
||||
(tags) =>
|
||||
data.reduce((accumulator, currentValue) => {
|
||||
return deleteTag(accumulator, ['p', currentValue]);
|
||||
}, tags),
|
||||
c,
|
||||
);
|
||||
|
||||
return c.json(200);
|
||||
return c.newResponse(null, 204);
|
||||
};
|
||||
|
||||
export const getZapSplitsController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const store = c.get('store');
|
||||
const { conf, store } = c.var;
|
||||
|
||||
const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(store, conf.pubkey) ?? {};
|
||||
|
||||
if (!dittoZapSplit) {
|
||||
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
|
||||
}
|
||||
|
||||
const pubkeys = Object.keys(dittoZapSplit);
|
||||
const view = new AccountView(c.var);
|
||||
|
||||
const zapSplits = await Promise.all(pubkeys.map(async (pubkey) => {
|
||||
const author = await getAuthor(pubkey);
|
||||
|
||||
const account = author ? renderAccount(author) : accountFromPubkey(pubkey);
|
||||
const author = await getAuthor(c.var, pubkey);
|
||||
const account = view.render(author, pubkey);
|
||||
|
||||
return {
|
||||
account,
|
||||
|
|
@ -261,11 +257,12 @@ export const getZapSplitsController: AppController = async (c) => {
|
|||
};
|
||||
|
||||
export const statusZapSplitsController: AppController = async (c) => {
|
||||
const store = c.get('store');
|
||||
const { store, signal } = c.var;
|
||||
|
||||
const id = c.req.param('id');
|
||||
const { signal } = c.req.raw;
|
||||
|
||||
const [event] = await store.query([{ kinds: [1, 20], ids: [id], limit: 1 }], { signal });
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
|
@ -275,14 +272,15 @@ export const statusZapSplitsController: AppController = async (c) => {
|
|||
const pubkeys = zapsTag.map((name) => name[1]);
|
||||
|
||||
const users = await store.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal });
|
||||
await hydrateEvents({ events: users, store, signal });
|
||||
await hydrateEvents(c.var, users);
|
||||
|
||||
const zapSplits = (await Promise.all(pubkeys.map((pubkey) => {
|
||||
const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author;
|
||||
const account = author ? renderAccount(author) : accountFromPubkey(pubkey);
|
||||
const view = new AccountView(c.var);
|
||||
|
||||
const zapSplits = pubkeys.map((pubkey) => {
|
||||
const author = users.find((event) => event.pubkey === pubkey)?.author;
|
||||
const account = view.render(author, pubkey);
|
||||
|
||||
const weight = percentageSchema.catch(0).parse(zapsTag.find((name) => name[1] === pubkey)![3]) ?? 0;
|
||||
|
||||
const message = zapsTag.find((name) => name[1] === pubkey)![4] ?? '';
|
||||
|
||||
return {
|
||||
|
|
@ -290,7 +288,7 @@ export const statusZapSplitsController: AppController = async (c) => {
|
|||
message,
|
||||
weight,
|
||||
};
|
||||
}))).filter((zapSplit) => zapSplit.weight > 0);
|
||||
}).filter((zapSplit) => zapSplit.weight > 0);
|
||||
|
||||
return c.json(zapSplits, 200);
|
||||
};
|
||||
|
|
@ -317,9 +315,10 @@ export const updateInstanceController: AppController = async (c) => {
|
|||
return c.json(result.error, 422);
|
||||
}
|
||||
|
||||
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
|
||||
const meta = await getInstanceMetadata(c.var);
|
||||
|
||||
await updateAdminEvent(
|
||||
c.var,
|
||||
{ kinds: [0], authors: [pubkey], limit: 1 },
|
||||
(_) => {
|
||||
const {
|
||||
|
|
@ -343,8 +342,7 @@ export const updateInstanceController: AppController = async (c) => {
|
|||
tags: [],
|
||||
};
|
||||
},
|
||||
c,
|
||||
);
|
||||
|
||||
return c.json(204);
|
||||
return c.newResponse(null, 204);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import denoJson from 'deno.json' with { type: 'json' };
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||
import { getInstanceMetadata } from '../../../utils/instance.ts';
|
||||
|
||||
const version = `3.0.0 (compatible; Ditto ${denoJson.version})`;
|
||||
|
||||
|
|
@ -18,7 +17,7 @@ const features = [
|
|||
const instanceV1Controller: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const { host, protocol } = conf.url;
|
||||
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
|
||||
const meta = await getInstanceMetadata(c.var);
|
||||
|
||||
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
|
||||
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
|
||||
|
|
@ -78,7 +77,7 @@ const instanceV1Controller: AppController = async (c) => {
|
|||
const instanceV2Controller: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const { host, protocol } = conf.url;
|
||||
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
|
||||
const meta = await getInstanceMetadata(c.var);
|
||||
|
||||
/** Protocol to use for WebSocket URLs, depending on the protocol of the `LOCAL_DOMAIN`. */
|
||||
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
|
||||
|
|
@ -165,7 +164,7 @@ const instanceV2Controller: AppController = async (c) => {
|
|||
};
|
||||
|
||||
const instanceDescriptionController: AppController = async (c) => {
|
||||
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
|
||||
const meta = await getInstanceMetadata(c.var);
|
||||
|
||||
return c.json({
|
||||
content: meta.about,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { parseBody } from '@/utils/api.ts';
|
||||
import { parseBody } from '../../../utils/api.ts';
|
||||
|
||||
const kv = await Deno.openKv();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { z } from 'zod';
|
|||
import { AppController } from '@/app.ts';
|
||||
import { dittoUploads } from '@/DittoUploads.ts';
|
||||
import { fileSchema } from '@/schema.ts';
|
||||
import { parseBody } from '@/utils/api.ts';
|
||||
import { parseBody } from '../../../utils/api.ts';
|
||||
import { renderAttachment } from '@/views/mastodon/attachments.ts';
|
||||
import { errorJson } from '@/utils/log.ts';
|
||||
import { uploadFile } from '@/utils/upload.ts';
|
||||
import { errorJson } from '../../../utils/log.ts';
|
||||
import { uploadFile } from '../../../utils/upload.ts';
|
||||
|
||||
const mediaBodySchema = z.object({
|
||||
file: fileSchema,
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import { type AppController } from '@/app.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { getTagSet } from '@/utils/tags.ts';
|
||||
import { getTagSet } from '../../../utils/tags.ts';
|
||||
import { renderAccounts } from '@/views.ts';
|
||||
|
||||
/** https://docs.joinmastodon.org/methods/mutes/#get */
|
||||
const mutesController: AppController = async (c) => {
|
||||
const store = await Storages.db();
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { signal } = c.req.raw;
|
||||
const { store, user, signal } = c.var;
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
|
||||
const [event10000] = await store.query(
|
||||
[{ kinds: [10000], authors: [pubkey], limit: 1 }],
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { z } from 'zod';
|
|||
import { AppContext, AppController } from '@/app.ts';
|
||||
import { DittoPagination } from '@/interfaces/DittoPagination.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { paginated } from '@/utils/api.ts';
|
||||
import { paginated } from '../../../utils/api.ts';
|
||||
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||
|
||||
/** Set of known notification types across backends. */
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { NConnectSigner, NSchema as n, NSecSigner } from '@nostrify/nostrify';
|
||||
import { DittoConf } from '@ditto/conf';
|
||||
import { DittoTables } from '@ditto/db';
|
||||
import { NConnectSigner, NRelay, NSchema as n, NSecSigner } from '@nostrify/nostrify';
|
||||
import { escape } from 'entities';
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { nostrNow } from '@/utils.ts';
|
||||
import { parseBody } from '@/utils/api.ts';
|
||||
import { aesEncrypt } from '@/utils/aes.ts';
|
||||
import { generateToken, getTokenHash } from '@/utils/auth.ts';
|
||||
import { parseBody } from '../../../utils/api.ts';
|
||||
import { aesEncrypt } from '../../../utils/aes.ts';
|
||||
import { generateToken, getTokenHash } from '../../../utils/auth.ts';
|
||||
import { Kysely } from 'kysely';
|
||||
|
||||
const passwordGrantSchema = z.object({
|
||||
grant_type: z.literal('password'),
|
||||
|
|
@ -39,7 +41,6 @@ const createTokenSchema = z.discriminatedUnion('grant_type', [
|
|||
]);
|
||||
|
||||
const createTokenController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const body = await parseBody(c.req.raw);
|
||||
const result = createTokenSchema.safeParse(body);
|
||||
|
||||
|
|
@ -50,7 +51,7 @@ const createTokenController: AppController = async (c) => {
|
|||
switch (result.data.grant_type) {
|
||||
case 'nostr_bunker':
|
||||
return c.json({
|
||||
access_token: await getToken(result.data, conf.seckey),
|
||||
access_token: await getToken(c.var, result.data),
|
||||
token_type: 'Bearer',
|
||||
scope: 'read write follow push',
|
||||
created_at: nostrNow(),
|
||||
|
|
@ -90,6 +91,8 @@ const revokeTokenSchema = z.object({
|
|||
* https://docs.joinmastodon.org/methods/oauth/#revoke
|
||||
*/
|
||||
const revokeTokenController: AppController = async (c) => {
|
||||
const { kysely } = c.var;
|
||||
|
||||
const body = await parseBody(c.req.raw);
|
||||
const result = revokeTokenSchema.safeParse(body);
|
||||
|
||||
|
|
@ -99,7 +102,6 @@ const revokeTokenController: AppController = async (c) => {
|
|||
|
||||
const { token } = result.data;
|
||||
|
||||
const kysely = await Storages.kysely();
|
||||
const tokenHash = await getTokenHash(token as `token1${string}`);
|
||||
|
||||
await kysely
|
||||
|
|
@ -110,11 +112,17 @@ const revokeTokenController: AppController = async (c) => {
|
|||
return c.json({});
|
||||
};
|
||||
|
||||
interface GetTokenOpts {
|
||||
conf: DittoConf;
|
||||
kysely: Kysely<DittoTables>;
|
||||
pubsub: NRelay;
|
||||
}
|
||||
|
||||
async function getToken(
|
||||
opts: GetTokenOpts,
|
||||
{ pubkey: bunkerPubkey, secret, relays = [] }: { pubkey: string; secret?: string; relays?: string[] },
|
||||
dittoSeckey: Uint8Array,
|
||||
): Promise<`token1${string}`> {
|
||||
const kysely = await Storages.kysely();
|
||||
const { conf, kysely, pubsub } = opts;
|
||||
const { token, hash } = await generateToken();
|
||||
|
||||
const nip46Seckey = generateSecretKey();
|
||||
|
|
@ -123,7 +131,7 @@ async function getToken(
|
|||
encryption: 'nip44',
|
||||
pubkey: bunkerPubkey,
|
||||
signer: new NSecSigner(nip46Seckey),
|
||||
relay: await Storages.pubsub(), // TODO: Use the relays from the request.
|
||||
relay: pubsub, // TODO: Use the relays from the request.
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
|
|
@ -134,7 +142,7 @@ async function getToken(
|
|||
token_hash: hash,
|
||||
pubkey: userPubkey,
|
||||
bunker_pubkey: bunkerPubkey,
|
||||
nip46_sk_enc: await aesEncrypt(dittoSeckey, nip46Seckey),
|
||||
nip46_sk_enc: await aesEncrypt(conf.seckey, nip46Seckey),
|
||||
nip46_relays: relays,
|
||||
created_at: new Date(),
|
||||
}).execute();
|
||||
|
|
@ -222,8 +230,6 @@ const oauthAuthorizeSchema = z.object({
|
|||
|
||||
/** Controller the OAuth form is POSTed to. */
|
||||
const oauthAuthorizeController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
|
||||
/** FormData results in JSON. */
|
||||
const result = oauthAuthorizeSchema.safeParse(await parseBody(c.req.raw));
|
||||
|
||||
|
|
@ -236,11 +242,11 @@ const oauthAuthorizeController: AppController = async (c) => {
|
|||
|
||||
const bunker = new URL(bunker_uri);
|
||||
|
||||
const token = await getToken({
|
||||
const token = await getToken(c.var, {
|
||||
pubkey: bunker.hostname,
|
||||
secret: bunker.searchParams.get('secret') || undefined,
|
||||
relays: bunker.searchParams.getAll('relay'),
|
||||
}, conf.seckey);
|
||||
});
|
||||
|
||||
if (redirectUri === 'urn:ietf:wg:oauth:2.0:oob') {
|
||||
return c.text(token);
|
||||
|
|
|
|||
|
|
@ -2,15 +2,13 @@ import { z } from 'zod';
|
|||
|
||||
import { type AppController } from '@/app.ts';
|
||||
import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts';
|
||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts';
|
||||
import { lookupPubkey } from '@/utils/lookup.ts';
|
||||
import { getPleromaConfigs } from '@/utils/pleroma.ts';
|
||||
import { AdminSigner } from '../../../signers/AdminSigner.ts';
|
||||
import { createAdminEvent, updateAdminEvent, updateUser } from '../../../utils/api.ts';
|
||||
import { lookupPubkey } from '../../../utils/lookup.ts';
|
||||
import { getPleromaConfigs } from '../../../utils/pleroma.ts';
|
||||
|
||||
const frontendConfigController: AppController = async (c) => {
|
||||
const store = await Storages.db();
|
||||
const configDB = await getPleromaConfigs(store, c.req.raw.signal);
|
||||
const configDB = await getPleromaConfigs(c.var);
|
||||
const frontendConfig = configDB.get(':pleroma', ':frontend_configurations');
|
||||
|
||||
if (frontendConfig) {
|
||||
|
|
@ -26,8 +24,7 @@ const frontendConfigController: AppController = async (c) => {
|
|||
};
|
||||
|
||||
const configController: AppController = async (c) => {
|
||||
const store = await Storages.db();
|
||||
const configs = await getPleromaConfigs(store, c.req.raw.signal);
|
||||
const configs = await getPleromaConfigs(c.var);
|
||||
return c.json({ configs, need_reboot: false });
|
||||
};
|
||||
|
||||
|
|
@ -36,29 +33,28 @@ const updateConfigController: AppController = async (c) => {
|
|||
const { conf } = c.var;
|
||||
const { pubkey } = conf;
|
||||
|
||||
const store = await Storages.db();
|
||||
const configs = await getPleromaConfigs(store, c.req.raw.signal);
|
||||
const configs = await getPleromaConfigs(c.var);
|
||||
const { configs: newConfigs } = z.object({ configs: z.array(configSchema) }).parse(await c.req.json());
|
||||
|
||||
configs.merge(newConfigs);
|
||||
|
||||
await createAdminEvent({
|
||||
await createAdminEvent(c.var, {
|
||||
kind: 30078,
|
||||
content: await new AdminSigner().nip44.encrypt(pubkey, JSON.stringify(configs)),
|
||||
content: await new AdminSigner(conf).nip44.encrypt(pubkey, JSON.stringify(configs)),
|
||||
tags: [
|
||||
['d', 'pub.ditto.pleroma.config'],
|
||||
['encrypted', 'nip44'],
|
||||
],
|
||||
}, c);
|
||||
});
|
||||
|
||||
return c.json({ configs: newConfigs, need_reboot: false });
|
||||
};
|
||||
|
||||
const pleromaAdminDeleteStatusController: AppController = async (c) => {
|
||||
await createAdminEvent({
|
||||
await createAdminEvent(c.var, {
|
||||
kind: 5,
|
||||
tags: [['e', c.req.param('id')]],
|
||||
}, c);
|
||||
});
|
||||
|
||||
return c.json({});
|
||||
};
|
||||
|
|
@ -73,10 +69,11 @@ const pleromaAdminTagController: AppController = async (c) => {
|
|||
const params = pleromaAdminTagSchema.parse(await c.req.json());
|
||||
|
||||
for (const nickname of params.nicknames) {
|
||||
const pubkey = await lookupPubkey(nickname);
|
||||
const pubkey = await lookupPubkey(c.var, nickname);
|
||||
if (!pubkey) continue;
|
||||
|
||||
await updateAdminEvent(
|
||||
c,
|
||||
{ kinds: [30382], authors: [conf.pubkey], '#d': [pubkey], limit: 1 },
|
||||
(prev) => {
|
||||
const tags = prev?.tags ?? [['d', pubkey]];
|
||||
|
|
@ -94,11 +91,10 @@ const pleromaAdminTagController: AppController = async (c) => {
|
|||
tags,
|
||||
};
|
||||
},
|
||||
c,
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
return c.newResponse(null, { status: 204 });
|
||||
};
|
||||
|
||||
const pleromaAdminUntagController: AppController = async (c) => {
|
||||
|
|
@ -106,10 +102,11 @@ const pleromaAdminUntagController: AppController = async (c) => {
|
|||
const params = pleromaAdminTagSchema.parse(await c.req.json());
|
||||
|
||||
for (const nickname of params.nicknames) {
|
||||
const pubkey = await lookupPubkey(nickname);
|
||||
const pubkey = await lookupPubkey(c.var, nickname);
|
||||
if (!pubkey) continue;
|
||||
|
||||
await updateAdminEvent(
|
||||
c,
|
||||
{ kinds: [30382], authors: [conf.pubkey], '#d': [pubkey], limit: 1 },
|
||||
(prev) => ({
|
||||
kind: 30382,
|
||||
|
|
@ -117,11 +114,10 @@ const pleromaAdminUntagController: AppController = async (c) => {
|
|||
tags: (prev?.tags ?? [['d', pubkey]])
|
||||
.filter(([name, value]) => !(name === 't' && params.tags.includes(value))),
|
||||
}),
|
||||
c,
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
return c.newResponse(null, { status: 204 });
|
||||
};
|
||||
|
||||
const pleromaAdminSuggestSchema = z.object({
|
||||
|
|
@ -132,24 +128,26 @@ const pleromaAdminSuggestController: AppController = async (c) => {
|
|||
const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json());
|
||||
|
||||
for (const nickname of nicknames) {
|
||||
const pubkey = await lookupPubkey(nickname);
|
||||
if (!pubkey) continue;
|
||||
await updateUser(pubkey, { suggested: true }, c);
|
||||
const pubkey = await lookupPubkey(c.var, nickname);
|
||||
if (pubkey) {
|
||||
await updateUser(c.var, pubkey, { suggested: true });
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
return c.newResponse(null, { status: 204 });
|
||||
};
|
||||
|
||||
const pleromaAdminUnsuggestController: AppController = async (c) => {
|
||||
const { nicknames } = pleromaAdminSuggestSchema.parse(await c.req.json());
|
||||
|
||||
for (const nickname of nicknames) {
|
||||
const pubkey = await lookupPubkey(nickname);
|
||||
if (!pubkey) continue;
|
||||
await updateUser(pubkey, { suggested: false }, c);
|
||||
const pubkey = await lookupPubkey(c.var, nickname);
|
||||
if (pubkey) {
|
||||
await updateUser(c.var, pubkey, { suggested: false });
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
return c.newResponse(null, { status: 204 });
|
||||
};
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -3,9 +3,8 @@ import { nip19 } from 'nostr-tools';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { parseBody } from '@/utils/api.ts';
|
||||
import { getTokenHash } from '@/utils/auth.ts';
|
||||
import { parseBody } from '../../../utils/api.ts';
|
||||
import { getTokenHash } from '../../../utils/auth.ts';
|
||||
|
||||
/** https://docs.joinmastodon.org/entities/WebPushSubscription/ */
|
||||
interface MastodonPushSubscription {
|
||||
|
|
@ -42,7 +41,8 @@ const pushSubscribeSchema = z.object({
|
|||
});
|
||||
|
||||
export const pushSubscribeController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const { conf, kysely, user } = c.var;
|
||||
|
||||
const vapidPublicKey = await conf.vapidPublicKey;
|
||||
|
||||
if (!vapidPublicKey) {
|
||||
|
|
@ -50,10 +50,6 @@ export const pushSubscribeController: AppController = async (c) => {
|
|||
}
|
||||
|
||||
const accessToken = getAccessToken(c.req.raw);
|
||||
|
||||
const kysely = await Storages.kysely();
|
||||
const signer = c.get('signer')!;
|
||||
|
||||
const result = pushSubscribeSchema.safeParse(await parseBody(c.req.raw));
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -62,7 +58,7 @@ export const pushSubscribeController: AppController = async (c) => {
|
|||
|
||||
const { subscription, data } = result.data;
|
||||
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
const tokenHash = await getTokenHash(accessToken);
|
||||
|
||||
const { id } = await kysely.transaction().execute(async (trx) => {
|
||||
|
|
@ -97,7 +93,7 @@ export const pushSubscribeController: AppController = async (c) => {
|
|||
};
|
||||
|
||||
export const getSubscriptionController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const { conf, kysely } = c.var;
|
||||
const vapidPublicKey = await conf.vapidPublicKey;
|
||||
|
||||
if (!vapidPublicKey) {
|
||||
|
|
@ -106,7 +102,6 @@ export const getSubscriptionController: AppController = async (c) => {
|
|||
|
||||
const accessToken = getAccessToken(c.req.raw);
|
||||
|
||||
const kysely = await Storages.kysely();
|
||||
const tokenHash = await getTokenHash(accessToken);
|
||||
|
||||
const row = await kysely
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { AppController } from '@/app.ts';
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { createEvent } from '@/utils/api.ts';
|
||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { createEvent } from '../../../utils/api.ts';
|
||||
import { AccountView } from '@/views/mastodon/AccountView.ts';
|
||||
import { StatusView } from '@/views/mastodon/StatusView.ts';
|
||||
|
||||
/**
|
||||
* React to a status.
|
||||
|
|
@ -13,29 +12,30 @@ import { renderStatus } from '@/views/mastodon/statuses.ts';
|
|||
const reactionController: AppController = async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const emoji = c.req.param('emoji');
|
||||
const signer = c.get('signer')!;
|
||||
|
||||
const { store } = c.var;
|
||||
|
||||
if (!/^\p{RGI_Emoji}$/v.test(emoji)) {
|
||||
return c.json({ error: 'Invalid emoji' }, 400);
|
||||
}
|
||||
|
||||
const store = await Storages.db();
|
||||
const [event] = await store.query([{ kinds: [1, 20], ids: [id], limit: 1 }]);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Status not found' }, 404);
|
||||
}
|
||||
|
||||
await createEvent({
|
||||
await createEvent(c.var, {
|
||||
kind: 7,
|
||||
content: emoji,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['e', id], ['p', event.pubkey]],
|
||||
}, c);
|
||||
});
|
||||
|
||||
await hydrateEvents({ events: [event], store });
|
||||
await hydrateEvents(c.var, [event]);
|
||||
|
||||
const status = await renderStatus(event, { viewerPubkey: await signer.getPublicKey() });
|
||||
const view = new StatusView(c.var);
|
||||
const status = await view.render(event);
|
||||
|
||||
return c.json(status);
|
||||
};
|
||||
|
|
@ -47,9 +47,10 @@ const reactionController: AppController = async (c) => {
|
|||
const deleteReactionController: AppController = async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const emoji = c.req.param('emoji');
|
||||
const signer = c.get('signer')!;
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const store = await Storages.db();
|
||||
|
||||
const { store, user } = c.var;
|
||||
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
|
||||
if (!/^\p{RGI_Emoji}$/v.test(emoji)) {
|
||||
return c.json({ error: 'Invalid emoji' }, 400);
|
||||
|
|
@ -71,14 +72,15 @@ const deleteReactionController: AppController = async (c) => {
|
|||
.filter((event) => event.content === emoji)
|
||||
.map((event) => ['e', event.id]);
|
||||
|
||||
await createEvent({
|
||||
await createEvent(c.var, {
|
||||
kind: 5,
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
}, c);
|
||||
});
|
||||
|
||||
const status = renderStatus(event, { viewerPubkey: pubkey });
|
||||
const view = new StatusView(c.var);
|
||||
const status = await view.render(event);
|
||||
|
||||
return c.json(status);
|
||||
};
|
||||
|
|
@ -89,10 +91,12 @@ const deleteReactionController: AppController = async (c) => {
|
|||
*/
|
||||
const reactionsController: AppController = async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const store = await Storages.db();
|
||||
const pubkey = await c.get('signer')?.getPublicKey();
|
||||
const emoji = c.req.param('emoji') as string | undefined;
|
||||
|
||||
const { store, user } = c.var;
|
||||
|
||||
const pubkey = await user?.signer.getPublicKey();
|
||||
|
||||
if (typeof emoji === 'string' && !/^\p{RGI_Emoji}$/v.test(emoji)) {
|
||||
return c.json({ error: 'Invalid emoji' }, 400);
|
||||
}
|
||||
|
|
@ -100,7 +104,7 @@ const reactionsController: AppController = async (c) => {
|
|||
const events = await store.query([{ kinds: [7], '#e': [id], limit: 100 }])
|
||||
.then((events) => events.filter(({ content }) => /^\p{RGI_Emoji}$/v.test(content)))
|
||||
.then((events) => events.filter((event) => !emoji || event.content === emoji))
|
||||
.then((events) => hydrateEvents({ events, store }));
|
||||
.then((events) => hydrateEvents(c.var, events));
|
||||
|
||||
/** Events grouped by emoji. */
|
||||
const byEmoji = events.reduce((acc, event) => {
|
||||
|
|
@ -110,18 +114,16 @@ const reactionsController: AppController = async (c) => {
|
|||
return acc;
|
||||
}, {} as Record<string, DittoEvent[]>);
|
||||
|
||||
const results = await Promise.all(
|
||||
Object.entries(byEmoji).map(async ([name, events]) => {
|
||||
return {
|
||||
name,
|
||||
count: events.length,
|
||||
me: pubkey && events.some((event) => event.pubkey === pubkey),
|
||||
accounts: await Promise.all(
|
||||
events.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)),
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
const view = new AccountView(c.var);
|
||||
|
||||
const results = Object.entries(byEmoji).map(([name, events]) => {
|
||||
return {
|
||||
name,
|
||||
count: events.length,
|
||||
me: pubkey && events.some((event) => event.pubkey === pubkey),
|
||||
accounts: events.map((event) => view.render(event.author, event.pubkey)),
|
||||
};
|
||||
});
|
||||
|
||||
return c.json(results);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { type AppController } from '@/app.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 { renderAdminReport } from '@/views/mastodon/reports.ts';
|
||||
import { renderReport } from '@/views/mastodon/reports.ts';
|
||||
|
|
|
|||
|
|
@ -1,18 +1,20 @@
|
|||
import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
||||
import { DittoConf } from '@ditto/conf';
|
||||
import { DittoTables } from '@ditto/db';
|
||||
import { NostrEvent, NostrFilter, NSchema as n, NStore } from '@nostrify/nostrify';
|
||||
import { Kysely } from 'kysely';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { booleanParamSchema } from '@/schema.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { extractIdentifier, lookupPubkey } from '@/utils/lookup.ts';
|
||||
import { nip05Cache } from '@/utils/nip05.ts';
|
||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { extractIdentifier, lookupPubkey } from '../../../utils/lookup.ts';
|
||||
import { resolveNip05 } from '../../../utils/nip05.ts';
|
||||
import { AccountView } from '@/views/mastodon/AccountView.ts';
|
||||
import { StatusView } from '@/views/mastodon/StatusView.ts';
|
||||
import { getFollowedPubkeys } from '@/queries.ts';
|
||||
import { getPubkeysBySearch } from '@/utils/search.ts';
|
||||
import { paginated, paginatedList } from '@/utils/api.ts';
|
||||
import { getPubkeysBySearch } from '../../../utils/search.ts';
|
||||
import { paginated, paginatedList } from '../../../utils/api.ts';
|
||||
|
||||
const searchQuerySchema = z.object({
|
||||
q: z.string().transform(decodeURIComponent),
|
||||
|
|
@ -26,23 +28,26 @@ const searchQuerySchema = z.object({
|
|||
type SearchQuery = z.infer<typeof searchQuerySchema> & { since?: number; until?: number; limit: number };
|
||||
|
||||
const searchController: AppController = async (c) => {
|
||||
const { pagination } = c.var;
|
||||
|
||||
const result = searchQuerySchema.safeParse(c.req.query());
|
||||
const params = c.get('pagination');
|
||||
const { signal } = c.req.raw;
|
||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'Bad request', schema: result.error }, 422);
|
||||
}
|
||||
|
||||
const event = await lookupEvent({ ...result.data, ...params }, signal);
|
||||
const event = await lookupEvent(c.var, { ...result.data, ...pagination });
|
||||
const lookup = extractIdentifier(result.data.q);
|
||||
|
||||
const accountView = new AccountView(c.var);
|
||||
const statusView = new StatusView(c.var);
|
||||
|
||||
// Render account from pubkey.
|
||||
if (!event && lookup) {
|
||||
const pubkey = await lookupPubkey(lookup);
|
||||
const pubkey = await lookupPubkey(c.var, lookup);
|
||||
|
||||
return c.json({
|
||||
accounts: pubkey ? [accountFromPubkey(pubkey)] : [],
|
||||
accounts: pubkey ? [accountView.render(undefined, pubkey)] : [],
|
||||
statuses: [],
|
||||
hashtags: [],
|
||||
});
|
||||
|
|
@ -54,19 +59,19 @@ const searchController: AppController = async (c) => {
|
|||
events = [event];
|
||||
}
|
||||
|
||||
events.push(...(await searchEvents({ ...result.data, ...params, viewerPubkey }, signal)));
|
||||
events.push(...(await searchEvents(c.var, { ...result.data, ...pagination })));
|
||||
|
||||
const [accounts, statuses] = await Promise.all([
|
||||
Promise.all(
|
||||
events
|
||||
.filter((event) => event.kind === 0)
|
||||
.map((event) => renderAccount(event))
|
||||
.map((event) => accountView.render(event))
|
||||
.filter(Boolean),
|
||||
),
|
||||
Promise.all(
|
||||
events
|
||||
.filter((event) => event.kind === 1)
|
||||
.map((event) => renderStatus(event, { viewerPubkey }))
|
||||
.map((event) => statusView.render(event))
|
||||
.filter(Boolean),
|
||||
),
|
||||
]);
|
||||
|
|
@ -78,24 +83,31 @@ const searchController: AppController = async (c) => {
|
|||
};
|
||||
|
||||
if (result.data.type === 'accounts') {
|
||||
return paginatedList(c, { ...result.data, ...params }, body);
|
||||
return paginatedList(c, { ...result.data, ...pagination }, body);
|
||||
} else {
|
||||
return paginated(c, events, body);
|
||||
}
|
||||
};
|
||||
|
||||
interface SearchEventsOpts {
|
||||
conf: DittoConf;
|
||||
store: NStore;
|
||||
kysely: Kysely<DittoTables>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/** Get events for the search params. */
|
||||
async function searchEvents(
|
||||
opts: SearchEventsOpts,
|
||||
{ q, type, since, until, limit, offset, account_id, viewerPubkey }: SearchQuery & { viewerPubkey?: string },
|
||||
signal: AbortSignal,
|
||||
): Promise<NostrEvent[]> {
|
||||
const { store, kysely, signal } = opts;
|
||||
|
||||
// Hashtag search is not supported.
|
||||
if (type === 'hashtags') {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const store = await Storages.search();
|
||||
|
||||
const filter: NostrFilter = {
|
||||
kinds: typeToKinds(type),
|
||||
search: q,
|
||||
|
|
@ -104,11 +116,9 @@ async function searchEvents(
|
|||
limit,
|
||||
};
|
||||
|
||||
const kysely = await Storages.kysely();
|
||||
|
||||
// For account search, use a special index, and prioritize followed accounts.
|
||||
if (type === 'accounts') {
|
||||
const following = viewerPubkey ? await getFollowedPubkeys(viewerPubkey) : new Set<string>();
|
||||
const following = viewerPubkey ? await getFollowedPubkeys(store, viewerPubkey) : new Set<string>();
|
||||
const searchPubkeys = await getPubkeysBySearch(kysely, { q, limit, offset, following });
|
||||
|
||||
filter.authors = [...searchPubkeys];
|
||||
|
|
@ -123,7 +133,7 @@ async function searchEvents(
|
|||
// Query the events.
|
||||
let events = await store
|
||||
.query([filter], { signal })
|
||||
.then((events) => hydrateEvents({ events, store, signal }));
|
||||
.then((events) => hydrateEvents(opts, events));
|
||||
|
||||
// When using an authors filter, return the events in the same order as the filter.
|
||||
if (filter.authors) {
|
||||
|
|
@ -147,18 +157,27 @@ function typeToKinds(type: SearchQuery['type']): number[] {
|
|||
}
|
||||
}
|
||||
|
||||
/** Resolve a searched value into an event, if applicable. */
|
||||
async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<NostrEvent | undefined> {
|
||||
const filters = await getLookupFilters(query, signal);
|
||||
const store = await Storages.search();
|
||||
interface LookupEventOpts {
|
||||
conf: DittoConf;
|
||||
store: NStore;
|
||||
kysely: Kysely<DittoTables>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
return store.query(filters, { limit: 1, signal })
|
||||
.then((events) => hydrateEvents({ events, store, signal }))
|
||||
/** Resolve a searched value into an event, if applicable. */
|
||||
async function lookupEvent(opts: LookupEventOpts, query: SearchQuery): Promise<NostrEvent | undefined> {
|
||||
const { store, signal } = opts;
|
||||
const filters = await getLookupFilters(opts, query);
|
||||
|
||||
const _opts = { limit: 1, signal };
|
||||
|
||||
return store.query(filters, _opts)
|
||||
.then((events) => hydrateEvents(opts, events))
|
||||
.then(([event]) => event);
|
||||
}
|
||||
|
||||
/** Get filters to lookup the input value. */
|
||||
async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: AbortSignal): Promise<NostrFilter[]> {
|
||||
async function getLookupFilters(opts: LookupEventOpts, { q, type, resolve }: SearchQuery): Promise<NostrFilter[]> {
|
||||
const accounts = !type || type === 'accounts';
|
||||
const statuses = !type || type === 'statuses';
|
||||
|
||||
|
|
@ -168,8 +187,8 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
|
|||
|
||||
if (n.id().safeParse(q).success) {
|
||||
const filters: NostrFilter[] = [];
|
||||
if (accounts) filters.push({ kinds: [0], authors: [q] });
|
||||
if (statuses) filters.push({ kinds: [1, 20], ids: [q] });
|
||||
if (accounts) filters.push({ kinds: [0], authors: [q], limit: 1 });
|
||||
if (statuses) filters.push({ kinds: [1, 20], ids: [q], limit: 1 });
|
||||
return filters;
|
||||
}
|
||||
|
||||
|
|
@ -181,16 +200,16 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
|
|||
const filters: NostrFilter[] = [];
|
||||
switch (result.type) {
|
||||
case 'npub':
|
||||
if (accounts) filters.push({ kinds: [0], authors: [result.data] });
|
||||
if (accounts) filters.push({ kinds: [0], authors: [result.data], limit: 1 });
|
||||
break;
|
||||
case 'nprofile':
|
||||
if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey] });
|
||||
if (accounts) filters.push({ kinds: [0], authors: [result.data.pubkey], limit: 1 });
|
||||
break;
|
||||
case 'note':
|
||||
if (statuses) filters.push({ kinds: [1, 20], ids: [result.data] });
|
||||
if (statuses) filters.push({ kinds: [1, 20], ids: [result.data], limit: 1 });
|
||||
break;
|
||||
case 'nevent':
|
||||
if (statuses) filters.push({ kinds: [1, 20], ids: [result.data.id] });
|
||||
if (statuses) filters.push({ kinds: [1, 20], ids: [result.data.id], limit: 1 });
|
||||
break;
|
||||
}
|
||||
return filters;
|
||||
|
|
@ -199,9 +218,9 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort
|
|||
}
|
||||
|
||||
try {
|
||||
const { pubkey } = await nip05Cache.fetch(lookup, { signal });
|
||||
const { pubkey } = await resolveNip05(opts, lookup);
|
||||
if (pubkey) {
|
||||
return [{ kinds: [0], authors: [pubkey] }];
|
||||
return [{ kinds: [0], authors: [pubkey], limit: 1 }];
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
|
|
|
|||
|
|
@ -9,19 +9,25 @@ import { type AppController } from '@/app.ts';
|
|||
import { DittoUpload, dittoUploads } from '@/DittoUploads.ts';
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts';
|
||||
import { addTag, deleteTag } from '@/utils/tags.ts';
|
||||
import { asyncReplaceAll } from '@/utils/text.ts';
|
||||
import { lookupPubkey } from '@/utils/lookup.ts';
|
||||
import { addTag, deleteTag } from '../../../utils/tags.ts';
|
||||
import { asyncReplaceAll } from '../../../utils/text.ts';
|
||||
import { lookupPubkey } from '../../../utils/lookup.ts';
|
||||
import { languageSchema } from '@/schema.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { assertAuthenticated, createEvent, paginated, paginatedList, parseBody, updateListEvent } from '@/utils/api.ts';
|
||||
import { getInvoice, getLnurl } from '@/utils/lnurl.ts';
|
||||
import { purifyEvent } from '@/utils/purify.ts';
|
||||
import { getZapSplits } from '@/utils/zap-split.ts';
|
||||
import {
|
||||
assertAuthenticated,
|
||||
createEvent,
|
||||
paginated,
|
||||
paginatedList,
|
||||
parseBody,
|
||||
updateListEvent,
|
||||
} from '../../../utils/api.ts';
|
||||
import { getInvoice, getLnurl } from '../../../utils/lnurl.ts';
|
||||
import { purifyEvent } from '../../../utils/purify.ts';
|
||||
import { getZapSplits } from '../../../utils/zap-split.ts';
|
||||
import { renderEventAccounts } from '@/views.ts';
|
||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { AccountView } from '@/views/mastodon/AccountView.ts';
|
||||
import { StatusView } from '@/views/mastodon/StatusView.ts';
|
||||
|
||||
const createStatusSchema = z.object({
|
||||
in_reply_to_id: n.id().nullish(),
|
||||
|
|
@ -47,17 +53,15 @@ const createStatusSchema = z.object({
|
|||
|
||||
const statusController: AppController = async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(1500)]);
|
||||
|
||||
const event = await getEvent(id, { signal });
|
||||
const event = await getEvent(c.var, id);
|
||||
|
||||
if (event?.author) {
|
||||
assertAuthenticated(c, event.author);
|
||||
}
|
||||
|
||||
if (event) {
|
||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
||||
const status = await renderStatus(event, { viewerPubkey });
|
||||
const view = new StatusView(c.var);
|
||||
const status = await view.render(event);
|
||||
return c.json(status);
|
||||
}
|
||||
|
||||
|
|
@ -65,7 +69,7 @@ const statusController: AppController = async (c) => {
|
|||
};
|
||||
|
||||
const createStatusController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const { conf, user } = c.var;
|
||||
const body = await parseBody(c.req.raw);
|
||||
const result = createStatusSchema.safeParse(body);
|
||||
const store = c.get('store');
|
||||
|
|
@ -190,8 +194,8 @@ const createStatusController: AppController = async (c) => {
|
|||
}
|
||||
}
|
||||
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const author = pubkey ? await getAuthor(pubkey) : undefined;
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
const author = await getAuthor(c.var, pubkey);
|
||||
|
||||
if (conf.zapSplitsEnabled) {
|
||||
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
|
||||
|
|
@ -247,39 +251,38 @@ const createStatusController: AppController = async (c) => {
|
|||
content += mediaUrls.join('\n');
|
||||
}
|
||||
|
||||
const event = await createEvent({
|
||||
const event = await createEvent(c.var, {
|
||||
kind: 1,
|
||||
content,
|
||||
tags,
|
||||
}, c);
|
||||
});
|
||||
|
||||
if (data.quote_id) {
|
||||
await hydrateEvents({
|
||||
events: [event],
|
||||
store: await Storages.db(),
|
||||
signal: c.req.raw.signal,
|
||||
});
|
||||
await hydrateEvents(c.var, [event]);
|
||||
}
|
||||
|
||||
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: author?.pubkey }));
|
||||
const view = new StatusView(c.var);
|
||||
|
||||
return c.json(await view.render({ ...event, author }));
|
||||
};
|
||||
|
||||
const deleteStatusController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const { conf, user } = c.var;
|
||||
const id = c.req.param('id');
|
||||
const pubkey = await c.get('signer')?.getPublicKey();
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
|
||||
const event = await getEvent(id, { signal: c.req.raw.signal });
|
||||
const event = await getEvent(c.var, id);
|
||||
|
||||
if (event) {
|
||||
if (event.pubkey === pubkey) {
|
||||
await createEvent({
|
||||
await createEvent(c.var, {
|
||||
kind: 5,
|
||||
tags: [['e', id, conf.relay, '', pubkey]],
|
||||
}, c);
|
||||
});
|
||||
|
||||
const author = await getAuthor(event.pubkey);
|
||||
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: pubkey }));
|
||||
const author = await getAuthor(c.var, event.pubkey);
|
||||
const view = new StatusView(c.var);
|
||||
return c.json(await view.render({ ...event, author }));
|
||||
} else {
|
||||
return c.json({ error: 'Unauthorized' }, 403);
|
||||
}
|
||||
|
|
@ -289,14 +292,16 @@ const deleteStatusController: AppController = async (c) => {
|
|||
};
|
||||
|
||||
const contextController: AppController = async (c) => {
|
||||
const { store } = c.var;
|
||||
|
||||
const id = c.req.param('id');
|
||||
const store = c.get('store');
|
||||
const [event] = await store.query([{ kinds: [1, 20], ids: [id] }]);
|
||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
||||
|
||||
const view = new StatusView(c.var);
|
||||
|
||||
async function renderStatuses(events: NostrEvent[]) {
|
||||
const statuses = await Promise.all(
|
||||
events.map((event) => renderStatus(event, { viewerPubkey })),
|
||||
events.map((event) => view.render(event)),
|
||||
);
|
||||
return statuses.filter(Boolean);
|
||||
}
|
||||
|
|
@ -307,11 +312,7 @@ const contextController: AppController = async (c) => {
|
|||
getDescendants(store, event),
|
||||
]);
|
||||
|
||||
await hydrateEvents({
|
||||
events: [...ancestorEvents, ...descendantEvents],
|
||||
signal: c.req.raw.signal,
|
||||
store,
|
||||
});
|
||||
await hydrateEvents(c.var, [...ancestorEvents, ...descendantEvents]);
|
||||
|
||||
const [ancestors, descendants] = await Promise.all([
|
||||
renderStatuses(ancestorEvents),
|
||||
|
|
@ -325,24 +326,25 @@ const contextController: AppController = async (c) => {
|
|||
};
|
||||
|
||||
const favouriteController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const { conf, store } = c.var;
|
||||
const id = c.req.param('id');
|
||||
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] }], c.var);
|
||||
|
||||
if (target) {
|
||||
await createEvent({
|
||||
await createEvent(c.var, {
|
||||
kind: 7,
|
||||
content: '+',
|
||||
tags: [
|
||||
['e', target.id, conf.relay, '', target.pubkey],
|
||||
['p', target.pubkey, conf.relay],
|
||||
],
|
||||
}, c);
|
||||
});
|
||||
|
||||
await hydrateEvents({ events: [target], store });
|
||||
await hydrateEvents(c.var, [target]);
|
||||
|
||||
const status = await renderStatus(target, { viewerPubkey: await c.get('signer')?.getPublicKey() });
|
||||
const view = new StatusView(c.var);
|
||||
const status = await view.render(target);
|
||||
|
||||
if (status) {
|
||||
status.favourited = true;
|
||||
|
|
@ -368,43 +370,38 @@ const favouritedByController: AppController = (c) => {
|
|||
const reblogStatusController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const eventId = c.req.param('id');
|
||||
const { signal } = c.req.raw;
|
||||
|
||||
const event = await getEvent(eventId, {
|
||||
kind: 1,
|
||||
});
|
||||
const event = await getEvent(c.var, eventId);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found.' }, 404);
|
||||
}
|
||||
|
||||
const reblogEvent = await createEvent({
|
||||
const reblogEvent = await createEvent(c.var, {
|
||||
kind: 6,
|
||||
tags: [
|
||||
['e', event.id, conf.relay, '', event.pubkey],
|
||||
['p', event.pubkey, conf.relay],
|
||||
],
|
||||
}, c);
|
||||
|
||||
await hydrateEvents({
|
||||
events: [reblogEvent],
|
||||
store: await Storages.db(),
|
||||
signal: signal,
|
||||
});
|
||||
|
||||
const status = await renderReblog(reblogEvent, { viewerPubkey: await c.get('signer')?.getPublicKey() });
|
||||
await hydrateEvents(c.var, [reblogEvent]);
|
||||
|
||||
const view = new StatusView(c.var);
|
||||
const status = await view.renderReblog(reblogEvent);
|
||||
|
||||
return c.json(status);
|
||||
};
|
||||
|
||||
/** https://docs.joinmastodon.org/methods/statuses/#unreblog */
|
||||
const unreblogStatusController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const { conf, store, user } = c.var;
|
||||
|
||||
const eventId = c.req.param('id');
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const store = await Storages.db();
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
|
||||
const [event] = await store.query([{ ids: [eventId], kinds: [1, 20] }]);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Record not found' }, 404);
|
||||
}
|
||||
|
|
@ -417,38 +414,41 @@ const unreblogStatusController: AppController = async (c) => {
|
|||
return c.json({ error: 'Record not found' }, 404);
|
||||
}
|
||||
|
||||
await createEvent({
|
||||
await createEvent(c.var, {
|
||||
kind: 5,
|
||||
tags: [['e', repostEvent.id, conf.relay, '', repostEvent.pubkey]],
|
||||
}, c);
|
||||
});
|
||||
|
||||
return c.json(await renderStatus(event, { viewerPubkey: pubkey }));
|
||||
const view = new StatusView(c.var);
|
||||
const status = await view.render(event);
|
||||
|
||||
return c.json(status);
|
||||
};
|
||||
|
||||
const rebloggedByController: AppController = (c) => {
|
||||
const id = c.req.param('id');
|
||||
const params = c.get('pagination');
|
||||
return renderEventAccounts(c, [{ kinds: [6], '#e': [id], ...params }]);
|
||||
const { pagination } = c.var;
|
||||
return renderEventAccounts(c, [{ kinds: [6], '#e': [id], ...pagination }]);
|
||||
};
|
||||
|
||||
const quotesController: AppController = async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const params = c.get('pagination');
|
||||
const store = await Storages.db();
|
||||
const { store, pagination } = c.var;
|
||||
|
||||
const [event] = await store.query([{ ids: [id], kinds: [1, 20] }]);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found.' }, 404);
|
||||
}
|
||||
|
||||
const quotes = await store
|
||||
.query([{ kinds: [1, 20], '#q': [event.id], ...params }])
|
||||
.then((events) => hydrateEvents({ events, store }));
|
||||
.query([{ kinds: [1, 20], '#q': [event.id], ...pagination }])
|
||||
.then((events) => hydrateEvents(c.var, events));
|
||||
|
||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
||||
const view = new StatusView(c.var);
|
||||
|
||||
const statuses = await Promise.all(
|
||||
quotes.map((event) => renderStatus(event, { viewerPubkey })),
|
||||
quotes.map((event) => view.render(event)),
|
||||
);
|
||||
|
||||
if (!statuses.length) {
|
||||
|
|
@ -460,23 +460,22 @@ const quotesController: AppController = async (c) => {
|
|||
|
||||
/** https://docs.joinmastodon.org/methods/statuses/#bookmark */
|
||||
const bookmarkController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { conf, user } = c.var;
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
const eventId = c.req.param('id');
|
||||
|
||||
const event = await getEvent(eventId, {
|
||||
kind: 1,
|
||||
relations: ['author', 'event_stats', 'author_stats'],
|
||||
});
|
||||
const event = await getEvent(c.var, eventId);
|
||||
|
||||
if (event) {
|
||||
await updateListEvent(
|
||||
c.var,
|
||||
{ kinds: [10003], authors: [pubkey], limit: 1 },
|
||||
(tags) => addTag(tags, ['e', event.id, conf.relay, '', event.pubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
const status = await renderStatus(event, { viewerPubkey: pubkey });
|
||||
const view = new StatusView(c.var);
|
||||
const status = await view.render(event);
|
||||
|
||||
if (status) {
|
||||
status.bookmarked = true;
|
||||
}
|
||||
|
|
@ -488,23 +487,22 @@ const bookmarkController: AppController = async (c) => {
|
|||
|
||||
/** https://docs.joinmastodon.org/methods/statuses/#unbookmark */
|
||||
const unbookmarkController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { conf, user } = c.var;
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
const eventId = c.req.param('id');
|
||||
|
||||
const event = await getEvent(eventId, {
|
||||
kind: 1,
|
||||
relations: ['author', 'event_stats', 'author_stats'],
|
||||
});
|
||||
const event = await getEvent(c.var, eventId);
|
||||
|
||||
if (event) {
|
||||
await updateListEvent(
|
||||
c.var,
|
||||
{ kinds: [10003], authors: [pubkey], limit: 1 },
|
||||
(tags) => deleteTag(tags, ['e', event.id, conf.relay, '', event.pubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
const status = await renderStatus(event, { viewerPubkey: pubkey });
|
||||
const view = new StatusView(c.var);
|
||||
const status = await view.render(event);
|
||||
|
||||
if (status) {
|
||||
status.bookmarked = false;
|
||||
}
|
||||
|
|
@ -516,23 +514,22 @@ const unbookmarkController: AppController = async (c) => {
|
|||
|
||||
/** https://docs.joinmastodon.org/methods/statuses/#pin */
|
||||
const pinController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { conf, user } = c.var;
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
const eventId = c.req.param('id');
|
||||
|
||||
const event = await getEvent(eventId, {
|
||||
kind: 1,
|
||||
relations: ['author', 'event_stats', 'author_stats'],
|
||||
});
|
||||
const event = await getEvent(c.var, eventId);
|
||||
|
||||
if (event) {
|
||||
await updateListEvent(
|
||||
c.var,
|
||||
{ kinds: [10001], authors: [pubkey], limit: 1 },
|
||||
(tags) => addTag(tags, ['e', event.id, conf.relay, '', event.pubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
const status = await renderStatus(event, { viewerPubkey: pubkey });
|
||||
const view = new StatusView(c.var);
|
||||
const status = await view.render(event);
|
||||
|
||||
if (status) {
|
||||
status.pinned = true;
|
||||
}
|
||||
|
|
@ -544,25 +541,22 @@ const pinController: AppController = async (c) => {
|
|||
|
||||
/** https://docs.joinmastodon.org/methods/statuses/#unpin */
|
||||
const unpinController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
||||
const { conf, user } = c.var;
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
const eventId = c.req.param('id');
|
||||
const { signal } = c.req.raw;
|
||||
|
||||
const event = await getEvent(eventId, {
|
||||
kind: 1,
|
||||
relations: ['author', 'event_stats', 'author_stats'],
|
||||
signal,
|
||||
});
|
||||
const event = await getEvent(c.var, eventId);
|
||||
|
||||
if (event) {
|
||||
await updateListEvent(
|
||||
c.var,
|
||||
{ kinds: [10001], authors: [pubkey], limit: 1 },
|
||||
(tags) => deleteTag(tags, ['e', event.id, conf.relay, '', event.pubkey]),
|
||||
c,
|
||||
);
|
||||
|
||||
const status = await renderStatus(event, { viewerPubkey: pubkey });
|
||||
const view = new StatusView(c.var);
|
||||
const status = await view.render(event);
|
||||
|
||||
if (status) {
|
||||
status.pinned = false;
|
||||
}
|
||||
|
|
@ -597,7 +591,7 @@ const zapController: AppController = async (c) => {
|
|||
let lnurl: undefined | string;
|
||||
|
||||
if (status_id) {
|
||||
target = await getEvent(status_id, { kind: 1, relations: ['author'], signal });
|
||||
target = await getEvent(c.var, status_id);
|
||||
const author = target?.author;
|
||||
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
|
||||
lnurl = getLnurl(meta);
|
||||
|
|
@ -625,11 +619,11 @@ const zapController: AppController = async (c) => {
|
|||
}
|
||||
|
||||
if (target && lnurl) {
|
||||
const nostr = await createEvent({
|
||||
const nostr = await createEvent(c.var, {
|
||||
kind: 9734,
|
||||
content: comment ?? '',
|
||||
tags,
|
||||
}, c);
|
||||
});
|
||||
|
||||
return c.json({ invoice: await getInvoice({ amount, nostr: purifyEvent(nostr), lnurl }, signal) });
|
||||
} else {
|
||||
|
|
@ -640,8 +634,8 @@ const zapController: AppController = async (c) => {
|
|||
const zappedByController: AppController = async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const params = c.get('listPagination');
|
||||
const store = await Storages.db();
|
||||
const kysely = await Storages.kysely();
|
||||
|
||||
const { kysely, store } = c.var;
|
||||
|
||||
const zaps = await kysely.selectFrom('event_zaps')
|
||||
.selectAll()
|
||||
|
|
@ -651,22 +645,21 @@ const zappedByController: AppController = async (c) => {
|
|||
.offset(params.offset).execute();
|
||||
|
||||
const authors = await store.query([{ kinds: [0], authors: zaps.map((zap) => zap.sender_pubkey) }]);
|
||||
const view = new AccountView(c.var);
|
||||
|
||||
const results = (await Promise.all(
|
||||
zaps.map(async (zap) => {
|
||||
const amount = zap.amount_millisats;
|
||||
const comment = zap.comment;
|
||||
const results = zaps.map((zap) => {
|
||||
const amount = zap.amount_millisats;
|
||||
const comment = zap.comment;
|
||||
|
||||
const sender = authors.find((author) => author.pubkey === zap.sender_pubkey);
|
||||
const account = sender ? await renderAccount(sender) : await accountFromPubkey(zap.sender_pubkey);
|
||||
const sender = authors.find((author) => author.pubkey === zap.sender_pubkey);
|
||||
const account = view.render(sender, zap.sender_pubkey);
|
||||
|
||||
return {
|
||||
comment,
|
||||
amount,
|
||||
account,
|
||||
};
|
||||
}),
|
||||
)).filter(Boolean);
|
||||
return {
|
||||
comment,
|
||||
amount,
|
||||
account,
|
||||
};
|
||||
}).filter(Boolean);
|
||||
|
||||
return paginatedList(c, params, results);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { DittoTables } from '@ditto/db';
|
||||
import { MuteListPolicy } from '@ditto/policies';
|
||||
import {
|
||||
streamingClientMessagesCounter,
|
||||
|
|
@ -5,19 +6,20 @@ import {
|
|||
streamingServerMessagesCounter,
|
||||
} from '@ditto/metrics';
|
||||
import TTLCache from '@isaacs/ttlcache';
|
||||
import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||
import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
|
||||
import { logi } from '@soapbox/logi';
|
||||
import { Kysely } from 'kysely';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type AppController } from '@/app.ts';
|
||||
import { getFeedPubkeys } from '@/queries.ts';
|
||||
import { AdminStore } from '@/storages/AdminStore.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { getTokenHash } from '@/utils/auth.ts';
|
||||
import { errorJson } from '@/utils/log.ts';
|
||||
import { getTokenHash } from '../../../utils/auth.ts';
|
||||
import { errorJson } from '../../../utils/log.ts';
|
||||
import { bech32ToPubkey, Time } from '@/utils.ts';
|
||||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||
import { StatusView } from '@/views/mastodon/StatusView.ts';
|
||||
import { NotificationView } from '@/views/mastodon/NotificationView.ts';
|
||||
import { HTTPException } from '@hono/hono/http-exception';
|
||||
|
||||
/**
|
||||
|
|
@ -68,7 +70,8 @@ const limiter = new TTLCache<string, number>();
|
|||
const connections = new Set<WebSocket>();
|
||||
|
||||
const streamingController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const { conf, kysely, store, pubsub } = c.var;
|
||||
|
||||
const upgrade = c.req.header('upgrade');
|
||||
const token = c.req.header('sec-websocket-protocol');
|
||||
const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream'));
|
||||
|
|
@ -78,7 +81,7 @@ const streamingController: AppController = async (c) => {
|
|||
return c.text('Please use websocket protocol', 400);
|
||||
}
|
||||
|
||||
const pubkey = token ? await getTokenPubkey(token) : undefined;
|
||||
const pubkey = token ? await getTokenPubkey(kysely, token) : undefined;
|
||||
if (token && !pubkey) {
|
||||
return c.json({ error: 'Invalid access token' }, 401);
|
||||
}
|
||||
|
|
@ -93,10 +96,10 @@ const streamingController: AppController = async (c) => {
|
|||
|
||||
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 });
|
||||
|
||||
const store = await Storages.db();
|
||||
const pubsub = await Storages.pubsub();
|
||||
|
||||
const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined;
|
||||
const statusView = new StatusView(c.var);
|
||||
const adminStore = new AdminStore(conf, store);
|
||||
const notificationView = new NotificationView(c.var);
|
||||
const policy = pubkey ? new MuteListPolicy(pubkey, adminStore) : undefined;
|
||||
|
||||
function send(e: StreamingEvent) {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
|
|
@ -118,7 +121,7 @@ const streamingController: AppController = async (c) => {
|
|||
}
|
||||
}
|
||||
|
||||
await hydrateEvents({ events: [event], store, signal: AbortSignal.timeout(1000) });
|
||||
await hydrateEvents({ conf, kysely, store, signal: AbortSignal.timeout(1000) }, [event]);
|
||||
|
||||
const result = await render(event);
|
||||
|
||||
|
|
@ -137,17 +140,14 @@ const streamingController: AppController = async (c) => {
|
|||
streamingConnectionsGauge.set(connections.size);
|
||||
|
||||
if (!stream) return;
|
||||
const topicFilter = await topicToFilter(stream, c.req.query(), pubkey, conf.url.host);
|
||||
const topicFilter = await topicToFilter(store, stream, c.req.query(), pubkey, conf.url.host);
|
||||
|
||||
if (topicFilter) {
|
||||
sub([topicFilter], async (event) => {
|
||||
let payload: object | undefined;
|
||||
|
||||
if (event.kind === 1) {
|
||||
payload = await renderStatus(event, { viewerPubkey: pubkey });
|
||||
}
|
||||
if (event.kind === 6) {
|
||||
payload = await renderReblog(event, { viewerPubkey: pubkey });
|
||||
if ([1, 6, 20, 1111].includes(event.kind)) {
|
||||
payload = await statusView.render(event);
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
|
|
@ -163,7 +163,7 @@ const streamingController: AppController = async (c) => {
|
|||
if (['user', 'user:notification'].includes(stream) && pubkey) {
|
||||
sub([{ '#p': [pubkey] }], async (event) => {
|
||||
if (event.pubkey === pubkey) return; // skip own events
|
||||
const payload = await renderNotification(event, { viewerPubkey: pubkey });
|
||||
const payload = await notificationView.render(event);
|
||||
if (payload) {
|
||||
return {
|
||||
event: 'notification',
|
||||
|
|
@ -205,6 +205,7 @@ const streamingController: AppController = async (c) => {
|
|||
};
|
||||
|
||||
async function topicToFilter(
|
||||
store: NStore,
|
||||
topic: Stream,
|
||||
query: Record<string, string>,
|
||||
pubkey: string | undefined,
|
||||
|
|
@ -225,19 +226,19 @@ async function topicToFilter(
|
|||
// HACK: this puts the user's entire contacts list into RAM,
|
||||
// and then calls `matchFilters` over it. Refreshing the page
|
||||
// is required after following a new user.
|
||||
return pubkey ? { kinds: [1, 6, 20], authors: [...await getFeedPubkeys(pubkey)] } : undefined;
|
||||
return pubkey ? { kinds: [1, 6, 20], authors: [...await getFeedPubkeys(store, pubkey)] } : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTokenPubkey(token: string): Promise<string | undefined> {
|
||||
async function getTokenPubkey(kysely: Kysely<DittoTables>, token: string): Promise<string | undefined> {
|
||||
if (token.startsWith('token1')) {
|
||||
const kysely = await Storages.kysely();
|
||||
const tokenHash = await getTokenHash(token as `token1${string}`);
|
||||
|
||||
const row = await kysely
|
||||
.selectFrom('auth_tokens')
|
||||
.select('pubkey')
|
||||
.where('token_hash', '=', tokenHash)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { matchFilter } from 'nostr-tools';
|
|||
|
||||
import { AppContext, AppController } from '@/app.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { paginated, paginatedList } from '@/utils/api.ts';
|
||||
import { getTagSet } from '@/utils/tags.ts';
|
||||
import { paginated, paginatedList } from '../../../utils/api.ts';
|
||||
import { getTagSet } from '../../../utils/tags.ts';
|
||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
|
||||
export const suggestionsV1Controller: AppController = async (c) => {
|
||||
|
|
|
|||
|
|
@ -7,17 +7,19 @@ import { translationCache } from '@/caches/translationCache.ts';
|
|||
import { MastodonTranslation } from '@/entities/MastodonTranslation.ts';
|
||||
import { getEvent } from '@/queries.ts';
|
||||
import { localeSchema } from '@/schema.ts';
|
||||
import { parseBody } from '@/utils/api.ts';
|
||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { parseBody } from '../../../utils/api.ts';
|
||||
import { StatusView } from '@/views/mastodon/StatusView.ts';
|
||||
|
||||
const translateSchema = z.object({
|
||||
lang: localeSchema,
|
||||
});
|
||||
|
||||
const translateController: AppController = async (c) => {
|
||||
const result = translateSchema.safeParse(await parseBody(c.req.raw));
|
||||
const { store, user } = c.var;
|
||||
const { signal } = c.req.raw;
|
||||
|
||||
const result = translateSchema.safeParse(await parseBody(c.req.raw));
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'Bad request.', schema: result.error }, 422);
|
||||
}
|
||||
|
|
@ -31,18 +33,20 @@ const translateController: AppController = async (c) => {
|
|||
|
||||
const id = c.req.param('id');
|
||||
|
||||
const event = await getEvent(id, { signal });
|
||||
const event = await getEvent(store, id, { signal });
|
||||
if (!event) {
|
||||
return c.json({ error: 'Record not found' }, 400);
|
||||
}
|
||||
|
||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
||||
const viewerPubkey = await user?.signer.getPublicKey();
|
||||
|
||||
if (lang.toLowerCase() === event?.language?.toLowerCase()) {
|
||||
return c.json({ error: 'Source and target languages are the same. No translation needed.' }, 400);
|
||||
}
|
||||
|
||||
const status = await renderStatus(event, { viewerPubkey });
|
||||
const view = new StatusView(c.var);
|
||||
const status = await view.render(event, { viewerPubkey });
|
||||
|
||||
if (!status?.content) {
|
||||
return c.json({ error: 'Bad request.', schema: result.error }, 400);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,26 +4,39 @@ import { logi } from '@soapbox/logi';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { PreviewCard } from '@/entities/PreviewCard.ts';
|
||||
import { paginationSchema } from '@/schemas/pagination.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { generateDateRange, Time } from '@/utils/time.ts';
|
||||
import { unfurlCardCached } from '@/utils/unfurl.ts';
|
||||
import { paginated } from '@/utils/api.ts';
|
||||
import { errorJson } from '@/utils/log.ts';
|
||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { generateDateRange, Time } from '../../../utils/time.ts';
|
||||
import { unfurlCardCached } from '../../../utils/unfurl.ts';
|
||||
import { paginated } from '../../../utils/api.ts';
|
||||
import { errorJson } from '../../../utils/log.ts';
|
||||
import { StatusView } from '@/views/mastodon/StatusView.ts';
|
||||
|
||||
let trendingHashtagsCache = getTrendingHashtags(Conf).catch((e: unknown) => {
|
||||
logi({
|
||||
level: 'error',
|
||||
ns: 'ditto.trends.api',
|
||||
type: 'tags',
|
||||
msg: 'Failed to get trending hashtags',
|
||||
error: errorJson(e),
|
||||
interface MastodonTrendingHashtag {
|
||||
name: string;
|
||||
url: string;
|
||||
history: {
|
||||
day: string;
|
||||
accounts: string;
|
||||
uses: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
let trendingHashtagsCache: Promise<MastodonTrendingHashtag[]> | undefined;
|
||||
|
||||
function updateTrendingHashtagsCache(conf: DittoConf, store: NStore) {
|
||||
trendingHashtagsCache = getTrendingHashtags(conf, store).catch((e: unknown) => {
|
||||
logi({
|
||||
level: 'error',
|
||||
ns: 'ditto.trends.api',
|
||||
type: 'tags',
|
||||
msg: 'Failed to get trending hashtags',
|
||||
error: errorJson(e),
|
||||
});
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
}
|
||||
|
||||
Deno.cron('update trending hashtags cache', '35 * * * *', async () => {
|
||||
try {
|
||||
|
|
@ -51,8 +64,7 @@ const trendingTagsController: AppController = async (c) => {
|
|||
return c.json(trends.slice(offset, offset + limit));
|
||||
};
|
||||
|
||||
async function getTrendingHashtags(conf: DittoConf) {
|
||||
const store = await Storages.db();
|
||||
async function getTrendingHashtags(conf: DittoConf, store: NStore): Promise<MastodonTrendingHashtag[]> {
|
||||
const trends = await getTrendingTags(store, 't', conf.pubkey);
|
||||
|
||||
return trends.map((trend) => {
|
||||
|
|
@ -104,13 +116,20 @@ const trendingLinksController: AppController = async (c) => {
|
|||
return c.json(trends.slice(offset, offset + limit));
|
||||
};
|
||||
|
||||
async function getTrendingLinks(conf: DittoConf) {
|
||||
const store = await Storages.db();
|
||||
interface MastodonTrendingLink extends PreviewCard {
|
||||
history: {
|
||||
day: string;
|
||||
accounts: string;
|
||||
uses: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
async function getTrendingLinks(conf: DittoConf, store: NStore): Promise<MastodonTrendingLink[]> {
|
||||
const trends = await getTrendingTags(store, 'r', conf.pubkey);
|
||||
|
||||
return Promise.all(trends.map(async (trend) => {
|
||||
const link = trend.value;
|
||||
const card = await unfurlCardCached(link);
|
||||
const card = await unfurlCardCached(conf, link);
|
||||
|
||||
const history = trend.history.map(({ day, authors, uses }) => ({
|
||||
day: String(day),
|
||||
|
|
@ -140,8 +159,7 @@ async function getTrendingLinks(conf: DittoConf) {
|
|||
}
|
||||
|
||||
const trendingStatusesController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const store = await Storages.db();
|
||||
const { conf, store } = c.var;
|
||||
const { limit, offset, until } = paginationSchema.parse(c.req.query());
|
||||
|
||||
const [label] = await store.query([{
|
||||
|
|
@ -163,15 +181,17 @@ const trendingStatusesController: AppController = async (c) => {
|
|||
}
|
||||
|
||||
const results = await store.query([{ kinds: [1, 20], ids }])
|
||||
.then((events) => hydrateEvents({ events, store }));
|
||||
.then((events) => hydrateEvents(c.var, events));
|
||||
|
||||
// Sort events in the order they appear in the label.
|
||||
const events = ids
|
||||
.map((id) => results.find((event) => event.id === id))
|
||||
.filter((event): event is NostrEvent => !!event);
|
||||
|
||||
const view = new StatusView(c.var);
|
||||
|
||||
const statuses = await Promise.all(
|
||||
events.map((event) => renderStatus(event, {})),
|
||||
events.map((event) => view.render(event)),
|
||||
);
|
||||
|
||||
return paginated(c, results, statuses);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { ErrorHandler } from '@hono/hono';
|
|||
import { HTTPException } from '@hono/hono/http-exception';
|
||||
import { logi } from '@soapbox/logi';
|
||||
|
||||
import { errorJson } from '@/utils/log.ts';
|
||||
import { errorJson } from '../../utils/log.ts';
|
||||
|
||||
export const errorHandler: ErrorHandler = (err, c) => {
|
||||
c.header('Cache-Control', 'no-store');
|
||||
|
|
|
|||
|
|
@ -1,20 +1,25 @@
|
|||
import { DittoConf } from '@ditto/conf';
|
||||
import { DittoTables } from '@ditto/db';
|
||||
import { logi } from '@soapbox/logi';
|
||||
import { Kysely } from 'kysely';
|
||||
|
||||
import { AppMiddleware } from '@/app.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { getPathParams, MetadataEntities } from '@/utils/og-metadata.ts';
|
||||
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||
import { errorJson } from '@/utils/log.ts';
|
||||
import { lookupPubkey } from '@/utils/lookup.ts';
|
||||
import { getPathParams, MetadataEntities } from '../../utils/og-metadata.ts';
|
||||
import { getInstanceMetadata } from '../../utils/instance.ts';
|
||||
import { errorJson } from '../../utils/log.ts';
|
||||
import { lookupPubkey } from '../../utils/lookup.ts';
|
||||
import { renderMetadata } from '@/views/meta.ts';
|
||||
import { getAuthor, getEvent } from '@/queries.ts';
|
||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
import { StatusView } from '@/views/mastodon/StatusView.ts';
|
||||
import { AccountView } from '@/views/mastodon/AccountView.ts';
|
||||
import { NStore } from '@nostrify/types';
|
||||
|
||||
/** Placeholder to find & replace with metadata. */
|
||||
const META_PLACEHOLDER = '<!--server-generated-meta-->' as const;
|
||||
|
||||
export const frontendController: AppMiddleware = async (c) => {
|
||||
const { conf } = c.var;
|
||||
|
||||
c.header('Cache-Control', 'max-age=86400, s-maxage=30, public, stale-if-error=604800');
|
||||
|
||||
try {
|
||||
|
|
@ -23,8 +28,8 @@ export const frontendController: AppMiddleware = async (c) => {
|
|||
if (content.includes(META_PLACEHOLDER)) {
|
||||
const params = getPathParams(c.req.path);
|
||||
try {
|
||||
const entities = await getEntities(params ?? {});
|
||||
const meta = renderMetadata(c.req.url, entities);
|
||||
const entities = await getEntities(c.var, params ?? {});
|
||||
const meta = renderMetadata(conf, c.req.raw, entities);
|
||||
return c.html(content.replace(META_PLACEHOLDER, meta));
|
||||
} catch (e) {
|
||||
logi({ level: 'error', ns: 'ditto.frontend', msg: 'Error building meta tags', error: errorJson(e) });
|
||||
|
|
@ -37,27 +42,39 @@ export const frontendController: AppMiddleware = async (c) => {
|
|||
}
|
||||
};
|
||||
|
||||
async function getEntities(params: { acct?: string; statusId?: string }): Promise<MetadataEntities> {
|
||||
const store = await Storages.db();
|
||||
interface GetEntitiesOpts {
|
||||
conf: DittoConf;
|
||||
store: NStore;
|
||||
kysely: Kysely<DittoTables>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function getEntities(
|
||||
opts: GetEntitiesOpts,
|
||||
params: { acct?: string; statusId?: string },
|
||||
): Promise<MetadataEntities> {
|
||||
const entities: MetadataEntities = {
|
||||
instance: await getInstanceMetadata(store),
|
||||
instance: await getInstanceMetadata(opts),
|
||||
};
|
||||
|
||||
if (params.statusId) {
|
||||
const event = await getEvent(params.statusId, { kind: 1 });
|
||||
const event = await getEvent(opts, params.statusId);
|
||||
|
||||
if (event) {
|
||||
entities.status = await renderStatus(event, {});
|
||||
const view = new StatusView(opts);
|
||||
entities.status = await view.render(event);
|
||||
entities.account = entities.status?.account;
|
||||
}
|
||||
return entities;
|
||||
}
|
||||
|
||||
if (params.acct) {
|
||||
const pubkey = await lookupPubkey(params.acct.replace(/^@/, ''));
|
||||
const event = pubkey ? await getAuthor(pubkey) : undefined;
|
||||
const pubkey = await lookupPubkey(opts, params.acct.replace(/^@/, ''));
|
||||
const event = pubkey ? await getAuthor(opts, pubkey) : undefined;
|
||||
|
||||
if (event) {
|
||||
entities.account = await renderAccount(event);
|
||||
const view = new AccountView(opts);
|
||||
entities.account = view.render(event);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { AppController } from '@/app.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { WebManifestCombined } from '@/types/webmanifest.ts';
|
||||
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||
import { getInstanceMetadata } from '../../utils/instance.ts';
|
||||
|
||||
export const manifestController: AppController = async (c) => {
|
||||
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
|
||||
const meta = await getInstanceMetadata(c.var);
|
||||
|
||||
const manifest: WebManifestCombined = {
|
||||
description: meta.about,
|
||||
|
|
|
|||
|
|
@ -7,12 +7,10 @@ import {
|
|||
import { register } from 'prom-client';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
|
||||
/** Prometheus/OpenMetrics controller. */
|
||||
export const metricsController: AppController = async (c) => {
|
||||
const db = await Storages.database();
|
||||
const pool = await Storages.client();
|
||||
const { db, pool } = c.var;
|
||||
|
||||
// Update some metrics at request time.
|
||||
dbPoolSizeGauge.set(db.poolSize);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import denoJson from 'deno.json' with { type: 'json' };
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||
import { getInstanceMetadata } from '../../../utils/instance.ts';
|
||||
|
||||
const relayInfoController: AppController = async (c) => {
|
||||
const { conf } = c.var;
|
||||
const store = await Storages.db();
|
||||
const meta = await getInstanceMetadata(store, c.req.raw.signal);
|
||||
|
||||
const meta = await getInstanceMetadata(c.var);
|
||||
|
||||
c.res.headers.set('access-control-allow-origin', '*');
|
||||
|
||||
|
|
|
|||
|
|
@ -11,17 +11,18 @@ import {
|
|||
NostrClientMsg,
|
||||
NostrClientREQ,
|
||||
NostrRelayMsg,
|
||||
NRelay,
|
||||
NSchema as n,
|
||||
} from '@nostrify/nostrify';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { relayInfoController } from '@/controllers/nostr/relay-info.ts';
|
||||
import * as pipeline from '@/pipeline.ts';
|
||||
import { RelayError } from '@/RelayError.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { errorJson } from '@/utils/log.ts';
|
||||
import { purifyEvent } from '@/utils/purify.ts';
|
||||
import { Time } from '@/utils/time.ts';
|
||||
import { errorJson } from '../../../utils/log.ts';
|
||||
import { purifyEvent } from '../../../utils/purify.ts';
|
||||
import { Time } from '../../../utils/time.ts';
|
||||
import { DittoPipeline } from '@/DittoPipeline.ts';
|
||||
import { EventsDB } from '@/storages/EventsDB.ts';
|
||||
|
||||
/** Limit of initial events returned for a subscription. */
|
||||
const FILTER_LIMIT = 100;
|
||||
|
|
@ -44,8 +45,17 @@ const limiters = {
|
|||
/** Connections for metrics purposes. */
|
||||
const connections = new Set<WebSocket>();
|
||||
|
||||
interface ConnectStreamOpts {
|
||||
conf: DittoConf;
|
||||
store: EventsDB;
|
||||
pubsub: NRelay;
|
||||
pipeline: DittoPipeline;
|
||||
}
|
||||
|
||||
/** Set up the Websocket connection. */
|
||||
function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoConf) {
|
||||
function connectStream(opts: ConnectStreamOpts, socket: WebSocket, ip: string | undefined) {
|
||||
const { conf, store, pubsub, pipeline } = opts;
|
||||
|
||||
const controllers = new Map<string, AbortController>();
|
||||
|
||||
socket.onopen = () => {
|
||||
|
|
@ -125,9 +135,6 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon
|
|||
controllers.get(subId)?.abort();
|
||||
controllers.set(subId, controller);
|
||||
|
||||
const store = await Storages.db();
|
||||
const pubsub = await Storages.pubsub();
|
||||
|
||||
try {
|
||||
for (const event of await store.query(filters, { limit: FILTER_LIMIT, timeout: conf.db.timeouts.relay })) {
|
||||
send(['EVENT', subId, purifyEvent(event)]);
|
||||
|
|
@ -166,7 +173,7 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon
|
|||
|
||||
try {
|
||||
// This will store it (if eligible) and run other side-effects.
|
||||
await pipeline.handleEvent(purifyEvent(event), { source: 'relay', signal: AbortSignal.timeout(1000) });
|
||||
await pipeline.event(purifyEvent(event), { source: 'relay', signal: AbortSignal.timeout(1000) });
|
||||
send(['OK', event.id, true, '']);
|
||||
} catch (e) {
|
||||
if (e instanceof RelayError) {
|
||||
|
|
@ -190,7 +197,6 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon
|
|||
/** Handle COUNT. Return the number of events matching the filters. */
|
||||
async function handleCount([_, subId, ...filters]: NostrClientCOUNT): Promise<void> {
|
||||
if (rateLimited(limiters.req)) return;
|
||||
const store = await Storages.db();
|
||||
const { count } = await store.count(filters, { timeout: conf.db.timeouts.relay });
|
||||
send(['COUNT', subId, { count, approximate: false }]);
|
||||
}
|
||||
|
|
@ -233,7 +239,7 @@ const relayController: AppController = (c, next) => {
|
|||
}
|
||||
|
||||
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { idleTimeout: 30 });
|
||||
connectStream(socket, ip, conf);
|
||||
connectStream(c.var, socket, ip);
|
||||
|
||||
return response;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { NostrJson } from '@nostrify/nostrify';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { localNip05Lookup } from '@/utils/nip05.ts';
|
||||
import { localNip05Lookup } from '../../../utils/nip05.ts';
|
||||
|
||||
const nameSchema = z.string().min(1).regex(/^[\w.-]+$/);
|
||||
const emptyResult: NostrJson = { names: {}, relays: {} };
|
||||
|
|
@ -18,11 +18,9 @@ const nostrController: AppController = async (c) => {
|
|||
return c.json(emptyResult);
|
||||
}
|
||||
|
||||
const store = c.get('store');
|
||||
|
||||
const result = nameSchema.safeParse(c.req.query('name'));
|
||||
const name = result.success ? result.data : undefined;
|
||||
const pointer = name ? await localNip05Lookup(store, name) : undefined;
|
||||
const pointer = name ? await localNip05Lookup(c.var, name) : undefined;
|
||||
|
||||
if (!name || !pointer) {
|
||||
// Not found, cache for 5 minutes.
|
||||
|
|
|
|||
|
|
@ -1,24 +1,28 @@
|
|||
import { sql } from 'kysely';
|
||||
import { DittoConf } from '@ditto/conf';
|
||||
import { DittoTables } from '@ditto/db';
|
||||
import { NStore } from '@nostrify/nostrify';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
import { Storages } from '@/storages.ts';
|
||||
import {
|
||||
updateTrendingEvents,
|
||||
updateTrendingHashtags,
|
||||
updateTrendingLinks,
|
||||
updateTrendingPubkeys,
|
||||
updateTrendingZappedEvents,
|
||||
} from '@/trends.ts';
|
||||
import { DittoTrends } from '@/trends.ts';
|
||||
|
||||
interface CronOpts {
|
||||
conf: DittoConf;
|
||||
kysely: Kysely<DittoTables>;
|
||||
store: NStore;
|
||||
}
|
||||
|
||||
/** Start cron jobs for the application. */
|
||||
export function cron() {
|
||||
Deno.cron('update trending pubkeys', '0 * * * *', updateTrendingPubkeys);
|
||||
Deno.cron('update trending zapped events', '7 * * * *', updateTrendingZappedEvents);
|
||||
Deno.cron('update trending events', '15 * * * *', updateTrendingEvents);
|
||||
Deno.cron('update trending hashtags', '30 * * * *', updateTrendingHashtags);
|
||||
Deno.cron('update trending links', '45 * * * *', updateTrendingLinks);
|
||||
export function cron(opts: CronOpts) {
|
||||
const trends = new DittoTrends(opts);
|
||||
|
||||
Deno.cron('update trending pubkeys', '0 * * * *', () => trends.updateTrendingPubkeys());
|
||||
Deno.cron('update trending zapped events', '7 * * * *', () => trends.updateTrendingZappedEvents());
|
||||
Deno.cron('update trending events', '15 * * * *', () => trends.updateTrendingEvents());
|
||||
Deno.cron('update trending hashtags', '30 * * * *', () => trends.updateTrendingHashtags());
|
||||
Deno.cron('update trending links', '45 * * * *', () => trends.updateTrendingLinks());
|
||||
|
||||
Deno.cron('refresh top authors', '20 * * * *', async () => {
|
||||
const kysely = await Storages.kysely();
|
||||
const { kysely } = opts;
|
||||
await sql`refresh materialized view top_authors`.execute(kysely);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,39 @@
|
|||
import { firehoseEventsCounter } from '@ditto/metrics';
|
||||
import { Semaphore } from '@core/asyncutil';
|
||||
import { NostrEvent, NRelay } from '@nostrify/nostrify';
|
||||
import { logi } from '@soapbox/logi';
|
||||
|
||||
import { Conf } from '@/config.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { nostrNow } from '@/utils.ts';
|
||||
|
||||
import * as pipeline from '@/pipeline.ts';
|
||||
|
||||
const sem = new Semaphore(Conf.firehoseConcurrency);
|
||||
interface StartFirehoseOpts {
|
||||
store: Pick<NRelay, 'req'>;
|
||||
kinds?: number[];
|
||||
concurrency: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function watches events on all known relays and performs
|
||||
* side-effects based on them, such as trending hashtag tracking
|
||||
* and storing events for notifications and the home feed.
|
||||
*/
|
||||
export async function startFirehose(): Promise<void> {
|
||||
const store = await Storages.client();
|
||||
export async function startFirehose(
|
||||
opts: StartFirehoseOpts,
|
||||
onEvent: (event: NostrEvent) => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
const { store, kinds, concurrency } = opts;
|
||||
|
||||
for await (const msg of store.req([{ kinds: Conf.firehoseKinds, limit: 0, since: nostrNow() }])) {
|
||||
const sem = new Semaphore(concurrency);
|
||||
|
||||
for await (const msg of store.req([{ kinds, limit: 0, since: nostrNow() }])) {
|
||||
if (msg[0] === 'EVENT') {
|
||||
const event = msg[2];
|
||||
|
||||
logi({ level: 'debug', ns: 'ditto.event', source: 'firehose', id: event.id, kind: event.kind });
|
||||
firehoseEventsCounter.inc({ kind: event.kind });
|
||||
|
||||
sem.lock(async () => {
|
||||
try {
|
||||
await pipeline.handleEvent(event, { source: 'firehose', signal: AbortSignal.timeout(5000) });
|
||||
await onEvent(event);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
import { NostrEvent } from '@nostrify/nostrify';
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
|
||||
/** Additional properties that may be added by Ditto to events. */
|
||||
export type DittoRelation = Exclude<keyof DittoEvent, keyof NostrEvent>;
|
||||
|
|
@ -2,41 +2,15 @@ import { HTTPException } from '@hono/hono/http-exception';
|
|||
import { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
import { type AppContext, type AppMiddleware } from '@/app.ts';
|
||||
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { localRequest } from '@/utils/api.ts';
|
||||
import {
|
||||
buildAuthEventTemplate,
|
||||
parseAuthRequest,
|
||||
type ParseAuthRequestOpts,
|
||||
validateAuthEvent,
|
||||
} from '@/utils/nip98.ts';
|
||||
|
||||
/**
|
||||
* NIP-98 auth.
|
||||
* https://github.com/nostr-protocol/nips/blob/master/98.md
|
||||
*/
|
||||
function auth98Middleware(opts: ParseAuthRequestOpts = {}): AppMiddleware {
|
||||
return async (c, next) => {
|
||||
const req = localRequest(c);
|
||||
const result = await parseAuthRequest(req, opts);
|
||||
|
||||
if (result.success) {
|
||||
c.set('signer', new ReadOnlySigner(result.data.pubkey));
|
||||
c.set('proof', result.data);
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
}
|
||||
import { localRequest } from '../../utils/api.ts';
|
||||
import { buildAuthEventTemplate, type ParseAuthRequestOpts, validateAuthEvent } from '../../utils/nip98.ts';
|
||||
|
||||
type UserRole = 'user' | 'admin';
|
||||
|
||||
/** Require the user to prove their role before invoking the controller. */
|
||||
function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware {
|
||||
return withProof(async (c, proof, next) => {
|
||||
const { conf } = c.var;
|
||||
const store = await Storages.db();
|
||||
const { conf, store } = c.var;
|
||||
|
||||
const [user] = await store.query([{
|
||||
kinds: [30382],
|
||||
|
|
@ -71,22 +45,8 @@ function withProof(
|
|||
opts?: ParseAuthRequestOpts,
|
||||
): AppMiddleware {
|
||||
return async (c, next) => {
|
||||
const signer = c.get('signer');
|
||||
const pubkey = await signer?.getPublicKey();
|
||||
const proof = c.get('proof') || await obtainProof(c, opts);
|
||||
|
||||
// Prevent people from accidentally using the wrong account. This has no other security implications.
|
||||
if (proof && pubkey && pubkey !== proof.pubkey) {
|
||||
throw new HTTPException(401, { message: 'Pubkey mismatch' });
|
||||
}
|
||||
|
||||
const proof = await obtainProof(c, opts);
|
||||
if (proof) {
|
||||
c.set('proof', proof);
|
||||
|
||||
if (!signer) {
|
||||
c.set('signer', new ReadOnlySigner(proof.pubkey));
|
||||
}
|
||||
|
||||
await handler(c, proof, next);
|
||||
} else {
|
||||
throw new HTTPException(401, { message: 'No proof' });
|
||||
|
|
@ -96,8 +56,9 @@ function withProof(
|
|||
|
||||
/** Get the proof over Nostr Connect. */
|
||||
async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
|
||||
const signer = c.get('signer');
|
||||
if (!signer) {
|
||||
const { user } = c.var;
|
||||
|
||||
if (!user) {
|
||||
throw new HTTPException(401, {
|
||||
res: c.json({ error: 'No way to sign Nostr event' }, 401),
|
||||
});
|
||||
|
|
@ -105,7 +66,7 @@ async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
|
|||
|
||||
const req = localRequest(c);
|
||||
const reqEvent = await buildAuthEventTemplate(req, opts);
|
||||
const resEvent = await signer.signEvent(reqEvent);
|
||||
const resEvent = await user.signer.signEvent(reqEvent);
|
||||
const result = await validateAuthEvent(req, resEvent, opts);
|
||||
|
||||
if (result.success) {
|
||||
|
|
@ -113,4 +74,4 @@ async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
|
|||
}
|
||||
}
|
||||
|
||||
export { auth98Middleware, requireProof, requireRole };
|
||||
export { requireProof, requireRole };
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import { AppMiddleware } from '@/app.ts';
|
||||
import { PleromaConfigDB } from '@/utils/PleromaConfigDB.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { getPleromaConfigs } from '@/utils/pleroma.ts';
|
||||
import { PleromaConfigDB } from '../../utils/PleromaConfigDB.ts';
|
||||
import { getPleromaConfigs } from '../../utils/pleroma.ts';
|
||||
|
||||
let configDBCache: Promise<PleromaConfigDB> | undefined;
|
||||
|
||||
export const cspMiddleware = (): AppMiddleware => {
|
||||
return async (c, next) => {
|
||||
const { conf } = c.var;
|
||||
const store = await Storages.db();
|
||||
|
||||
if (!configDBCache) {
|
||||
configDBCache = getPleromaConfigs(store);
|
||||
configDBCache = getPleromaConfigs(c.var);
|
||||
}
|
||||
|
||||
const { host, protocol, origin } = conf.url;
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
import { AppMiddleware } from '@/app.ts';
|
||||
import { paginationSchema } from '@/schemas/pagination.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
|
||||
/** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */
|
||||
export const paginationMiddleware: AppMiddleware = async (c, next) => {
|
||||
const pagination = paginationSchema.parse(c.req.query());
|
||||
|
||||
const {
|
||||
max_id: maxId,
|
||||
min_id: minId,
|
||||
since,
|
||||
until,
|
||||
} = pagination;
|
||||
|
||||
if ((maxId && !until) || (minId && !since)) {
|
||||
const ids: string[] = [];
|
||||
|
||||
if (maxId) ids.push(maxId);
|
||||
if (minId) ids.push(minId);
|
||||
|
||||
if (ids.length) {
|
||||
const store = await Storages.db();
|
||||
|
||||
const events = await store.query(
|
||||
[{ ids, limit: ids.length }],
|
||||
{ signal: c.req.raw.signal },
|
||||
);
|
||||
|
||||
for (const event of events) {
|
||||
if (!until && maxId === event.id) pagination.until = event.created_at;
|
||||
if (!since && minId === event.id) pagination.since = event.created_at;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.set('pagination', {
|
||||
since: pagination.since,
|
||||
until: pagination.until,
|
||||
limit: pagination.limit,
|
||||
});
|
||||
|
||||
c.set('listPagination', {
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
});
|
||||
|
||||
await next();
|
||||
};
|
||||
|
|
@ -4,8 +4,8 @@ import { NostrSigner } from '@nostrify/nostrify';
|
|||
import { SetRequired } from 'type-fest';
|
||||
|
||||
/** Throw a 401 if a signer isn't set. */
|
||||
export const requireSigner: MiddlewareHandler<{ Variables: { signer: NostrSigner } }> = async (c, next) => {
|
||||
if (!c.get('signer')) {
|
||||
export const requireSigner: MiddlewareHandler<{ Variables: { user: { signer: NostrSigner } } }> = async (c, next) => {
|
||||
if (!c.var.user) {
|
||||
throw new HTTPException(401, { message: 'No pubkey provided' });
|
||||
}
|
||||
|
||||
|
|
@ -13,17 +13,18 @@ export const requireSigner: MiddlewareHandler<{ Variables: { signer: NostrSigner
|
|||
};
|
||||
|
||||
/** Throw a 401 if a NIP-44 signer isn't set. */
|
||||
export const requireNip44Signer: MiddlewareHandler<{ Variables: { signer: SetRequired<NostrSigner, 'nip44'> } }> =
|
||||
async (c, next) => {
|
||||
const signer = c.get('signer');
|
||||
export const requireNip44Signer: MiddlewareHandler<
|
||||
{ Variables: { user: { signer: SetRequired<NostrSigner, 'nip44'> } } }
|
||||
> = async (c, next) => {
|
||||
const { user } = c.var;
|
||||
|
||||
if (!signer) {
|
||||
throw new HTTPException(401, { message: 'No pubkey provided' });
|
||||
}
|
||||
if (!user) {
|
||||
throw new HTTPException(401, { message: 'No pubkey provided' });
|
||||
}
|
||||
|
||||
if (!signer.nip44) {
|
||||
throw new HTTPException(401, { message: 'No NIP-44 signer provided' });
|
||||
}
|
||||
if (!user.signer.nip44) {
|
||||
throw new HTTPException(401, { message: 'No NIP-44 signer provided' });
|
||||
}
|
||||
|
||||
await next();
|
||||
};
|
||||
await next();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,24 +1,27 @@
|
|||
import { type DittoConf } from '@ditto/conf';
|
||||
import { DittoTables } from '@ditto/db';
|
||||
import { MiddlewareHandler } from '@hono/hono';
|
||||
import { HTTPException } from '@hono/hono/http-exception';
|
||||
import { NostrSigner, NSecSigner } from '@nostrify/nostrify';
|
||||
import { NostrSigner, NRelay, NSecSigner } from '@nostrify/nostrify';
|
||||
import { Kysely } from 'kysely';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
|
||||
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { aesDecrypt } from '@/utils/aes.ts';
|
||||
import { getTokenHash } from '@/utils/auth.ts';
|
||||
import { ConnectSigner } from '../../signers/ConnectSigner.ts';
|
||||
import { ReadOnlySigner } from '../../signers/ReadOnlySigner.ts';
|
||||
import { aesDecrypt } from '../../utils/aes.ts';
|
||||
import { getTokenHash } from '../../utils/auth.ts';
|
||||
|
||||
/** We only accept "Bearer" type. */
|
||||
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. */
|
||||
export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSigner; conf: DittoConf } }> = async (
|
||||
export const signerMiddleware: MiddlewareHandler<
|
||||
{ Variables: { signer: NostrSigner; conf: DittoConf; kysely: Kysely<DittoTables>; pubsub: NRelay } }
|
||||
> = async (
|
||||
c,
|
||||
next,
|
||||
) => {
|
||||
const { conf } = c.var;
|
||||
const { conf, kysely } = c.var;
|
||||
const header = c.req.header('authorization');
|
||||
const match = header?.match(BEARER_REGEX);
|
||||
|
||||
|
|
@ -27,7 +30,6 @@ export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSig
|
|||
|
||||
if (bech32.startsWith('token1')) {
|
||||
try {
|
||||
const kysely = await Storages.kysely();
|
||||
const tokenHash = await getTokenHash(bech32 as `token1${string}`);
|
||||
|
||||
const { pubkey: userPubkey, bunker_pubkey: bunkerPubkey, nip46_sk_enc, nip46_relays } = await kysely
|
||||
|
|
@ -45,6 +47,7 @@ export const signerMiddleware: MiddlewareHandler<{ Variables: { signer: NostrSig
|
|||
userPubkey,
|
||||
signer: new NSecSigner(nep46Seckey),
|
||||
relays: nip46_relays,
|
||||
relay: c.var.pubsub,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
import { MiddlewareHandler } from '@hono/hono';
|
||||
import { NostrSigner, NStore } from '@nostrify/nostrify';
|
||||
|
||||
import { UserStore } from '@/storages/UserStore.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
|
||||
export const requireStore: MiddlewareHandler<{ Variables: { store: NStore } }> = async (c, next) => {
|
||||
if (!c.get('store')) {
|
||||
throw new Error('Store is required');
|
||||
}
|
||||
await next();
|
||||
};
|
||||
|
||||
/** Store middleware. */
|
||||
export const storeMiddleware: MiddlewareHandler<{ Variables: { signer?: NostrSigner; store: NStore } }> = async (
|
||||
c,
|
||||
next,
|
||||
) => {
|
||||
const pubkey = await c.get('signer')?.getPublicKey();
|
||||
|
||||
if (pubkey) {
|
||||
const store = new UserStore(pubkey, await Storages.admin());
|
||||
c.set('store', store);
|
||||
} else {
|
||||
c.set('store', await Storages.admin());
|
||||
}
|
||||
await next();
|
||||
};
|
||||
|
|
@ -1,49 +1,39 @@
|
|||
import { AppEnv } from '@/app.ts';
|
||||
import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts';
|
||||
import { type DittoConf } from '@ditto/conf';
|
||||
import { MiddlewareHandler } from '@hono/hono';
|
||||
import { HTTPException } from '@hono/hono/http-exception';
|
||||
import { getPublicKey } from 'nostr-tools';
|
||||
import { NostrFilter, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify';
|
||||
import { SetRequired } from 'type-fest';
|
||||
import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
||||
import { stringToBytes } from '@scure/base';
|
||||
import { logi } from '@soapbox/logi';
|
||||
|
||||
import { isNostrId } from '@/utils.ts';
|
||||
import { errorJson } from '@/utils/log.ts';
|
||||
import { createEvent } from '@/utils/api.ts';
|
||||
import { errorJson } from '../../utils/log.ts';
|
||||
import { createEvent } from '../../utils/api.ts';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Swap nutzaps into wallet (create new events) if the user has a wallet, otheriwse, just fallthrough.
|
||||
* Errors are only thrown if 'signer' and 'store' middlewares are not set.
|
||||
*/
|
||||
export const swapNutzapsMiddleware: MiddlewareHandler<
|
||||
{ Variables: { signer: SetRequired<NostrSigner, 'nip44'>; store: NStore; conf: DittoConf } }
|
||||
> = async (c, next) => {
|
||||
const { conf } = c.var;
|
||||
const signer = c.get('signer');
|
||||
const store = c.get('store');
|
||||
export const swapNutzapsMiddleware: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||||
const { conf, store, user, signal } = c.var;
|
||||
|
||||
if (!signer) {
|
||||
if (!user) {
|
||||
throw new HTTPException(401, { message: 'No pubkey provided' });
|
||||
}
|
||||
|
||||
if (!signer.nip44) {
|
||||
if (!user.signer.nip44) {
|
||||
throw new HTTPException(401, { message: 'No NIP-44 signer provided' });
|
||||
}
|
||||
|
||||
if (!store) {
|
||||
throw new HTTPException(401, { message: 'No store provided' });
|
||||
}
|
||||
|
||||
const { signal } = c.req.raw;
|
||||
const pubkey = await signer.getPublicKey();
|
||||
const pubkey = await user.signer.getPublicKey();
|
||||
const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
||||
|
||||
if (wallet) {
|
||||
let decryptedContent: string;
|
||||
try {
|
||||
decryptedContent = await signer.nip44.decrypt(pubkey, wallet.content);
|
||||
decryptedContent = await user.signer.nip44.decrypt(pubkey, wallet.content);
|
||||
} catch (e) {
|
||||
logi({
|
||||
level: 'error',
|
||||
|
|
@ -152,24 +142,24 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
|
|||
const cashuWallet = new CashuWallet(new CashuMint(mint));
|
||||
const receiveProofs = await cashuWallet.receive(token, { privkey });
|
||||
|
||||
const unspentProofs = await createEvent({
|
||||
const unspentProofs = await createEvent({ ...c.var, user }, {
|
||||
kind: 7375,
|
||||
content: await signer.nip44.encrypt(
|
||||
content: await user.signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify({
|
||||
mint,
|
||||
proofs: receiveProofs,
|
||||
}),
|
||||
),
|
||||
}, c);
|
||||
});
|
||||
|
||||
const amount = receiveProofs.reduce((accumulator, current) => {
|
||||
return accumulator + current.amount;
|
||||
}, 0);
|
||||
|
||||
await createEvent({
|
||||
await createEvent({ ...c.var, user }, {
|
||||
kind: 7376,
|
||||
content: await signer.nip44.encrypt(
|
||||
content: await user.signer.nip44.encrypt(
|
||||
pubkey,
|
||||
JSON.stringify([
|
||||
['direction', 'in'],
|
||||
|
|
@ -178,7 +168,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
|
|||
]),
|
||||
),
|
||||
tags: mintsToProofs[mint].redeemed,
|
||||
}, c);
|
||||
});
|
||||
} catch (e) {
|
||||
logi({ level: 'error', ns: 'ditto.api.cashu.wallet.swap', error: errorJson(e) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { AppMiddleware } from '@/app.ts';
|
|||
|
||||
/** Set an uploader for the user. */
|
||||
export const uploaderMiddleware: AppMiddleware = async (c, next) => {
|
||||
const { signer, conf } = c.var;
|
||||
const { conf, user } = c.var;
|
||||
|
||||
switch (conf.uploader) {
|
||||
case 's3':
|
||||
|
|
@ -33,11 +33,14 @@ export const uploaderMiddleware: AppMiddleware = async (c, next) => {
|
|||
c.set('uploader', new DenoUploader({ baseUrl: conf.mediaDomain, dir: conf.uploadsDir }));
|
||||
break;
|
||||
case 'nostrbuild':
|
||||
c.set('uploader', new NostrBuildUploader({ endpoint: conf.nostrbuildEndpoint, signer, fetch: safeFetch }));
|
||||
c.set(
|
||||
'uploader',
|
||||
new NostrBuildUploader({ endpoint: conf.nostrbuildEndpoint, signer: user?.signer, fetch: safeFetch }),
|
||||
);
|
||||
break;
|
||||
case 'blossom':
|
||||
if (signer) {
|
||||
c.set('uploader', new BlossomUploader({ servers: conf.blossomServers, signer, fetch: safeFetch }));
|
||||
if (user) {
|
||||
c.set('uploader', new BlossomUploader({ servers: conf.blossomServers, signer: user.signer, fetch: safeFetch }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,26 @@
|
|||
import { Semaphore } from '@core/asyncutil';
|
||||
|
||||
import { pipelineEncounters } from '@/caches/pipelineEncounters.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import * as pipeline from '@/pipeline.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { DittoDatabase } from '@ditto/db';
|
||||
import { NStore } from '@nostrify/nostrify';
|
||||
import { logi } from '@soapbox/logi';
|
||||
|
||||
const sem = new Semaphore(1);
|
||||
import type { DittoConf } from '@ditto/conf';
|
||||
import type { DittoPipeline } from '@/DittoPipeline.ts';
|
||||
|
||||
export async function startNotify(): Promise<void> {
|
||||
const { listen } = await Storages.database();
|
||||
const store = await Storages.db();
|
||||
interface StartNotifyOpts {
|
||||
db: DittoDatabase;
|
||||
store: NStore;
|
||||
conf: DittoConf;
|
||||
pipeline: DittoPipeline;
|
||||
concurrency?: number;
|
||||
}
|
||||
|
||||
listen('nostr_event', (id) => {
|
||||
if (pipelineEncounters.has(id)) {
|
||||
export function startNotify(opts: StartNotifyOpts): void {
|
||||
const { conf, db, store, pipeline, concurrency = 1 } = opts;
|
||||
|
||||
const sem = new Semaphore(concurrency);
|
||||
|
||||
db.listen('nostr_event', (id) => {
|
||||
if (pipeline.encounters.has(id)) {
|
||||
logi({ level: 'debug', ns: 'ditto.notify', id, skipped: true });
|
||||
return;
|
||||
}
|
||||
|
|
@ -22,13 +29,13 @@ export async function startNotify(): Promise<void> {
|
|||
|
||||
sem.lock(async () => {
|
||||
try {
|
||||
const signal = AbortSignal.timeout(Conf.db.timeouts.default);
|
||||
const signal = AbortSignal.timeout(conf.db.timeouts.default);
|
||||
|
||||
const [event] = await store.query([{ ids: [id], limit: 1 }], { signal });
|
||||
|
||||
if (event) {
|
||||
logi({ level: 'debug', ns: 'ditto.event', source: 'notify', id: event.id, kind: event.kind });
|
||||
await pipeline.handleEvent(event, { source: 'notify', signal });
|
||||
await pipeline.event(event, { source: 'notify', signal });
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
|
|
|
|||
|
|
@ -1,401 +0,0 @@
|
|||
import { DittoTables } from '@ditto/db';
|
||||
import { pipelineEventsCounter, policyEventsCounter, webPushNotificationsCounter } from '@ditto/metrics';
|
||||
import { NKinds, NostrEvent, NSchema as n } from '@nostrify/nostrify';
|
||||
import { logi } from '@soapbox/logi';
|
||||
import { Kysely, UpdateObject } from 'kysely';
|
||||
import tldts from 'tldts';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { pipelineEncounters } from '@/caches/pipelineEncounters.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { DittoPush } from '@/DittoPush.ts';
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { RelayError } from '@/RelayError.ts';
|
||||
import { AdminSigner } from '@/signers/AdminSigner.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { eventAge, Time } from '@/utils.ts';
|
||||
import { getAmount } from '@/utils/bolt11.ts';
|
||||
import { faviconCache } from '@/utils/favicon.ts';
|
||||
import { errorJson } from '@/utils/log.ts';
|
||||
import { nip05Cache } from '@/utils/nip05.ts';
|
||||
import { parseNoteContent, stripimeta } from '@/utils/note.ts';
|
||||
import { purifyEvent } from '@/utils/purify.ts';
|
||||
import { updateStats } from '@/utils/stats.ts';
|
||||
import { getTagSet } from '@/utils/tags.ts';
|
||||
import { unfurlCardCached } from '@/utils/unfurl.ts';
|
||||
import { renderWebPushNotification } from '@/views/mastodon/push.ts';
|
||||
import { policyWorker } from '@/workers/policy.ts';
|
||||
import { verifyEventWorker } from '@/workers/verify.ts';
|
||||
|
||||
interface PipelineOpts {
|
||||
signal: AbortSignal;
|
||||
source: 'relay' | 'api' | 'firehose' | 'pipeline' | 'notify' | 'internal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Common pipeline function to process (and maybe store) events.
|
||||
* It is idempotent, so it can be called multiple times for the same event.
|
||||
*/
|
||||
async function handleEvent(event: DittoEvent, opts: PipelineOpts): Promise<void> {
|
||||
// Skip events that have already been encountered.
|
||||
if (pipelineEncounters.get(event.id)) {
|
||||
throw new RelayError('duplicate', 'already have this event');
|
||||
}
|
||||
// Reject events that are too far in the future.
|
||||
if (eventAge(event) < -Time.minutes(1)) {
|
||||
throw new RelayError('invalid', 'event too far in the future');
|
||||
}
|
||||
// Integer max value for Postgres.
|
||||
if (event.kind >= 2_147_483_647) {
|
||||
throw new RelayError('invalid', 'event kind too large');
|
||||
}
|
||||
// The only point of ephemeral events is to stream them,
|
||||
// so throw an error if we're not even going to do that.
|
||||
if (NKinds.ephemeral(event.kind) && !isFresh(event)) {
|
||||
throw new RelayError('invalid', 'event too old');
|
||||
}
|
||||
// Block NIP-70 events, because we have no way to `AUTH`.
|
||||
if (isProtectedEvent(event)) {
|
||||
throw new RelayError('invalid', 'protected event');
|
||||
}
|
||||
// Validate the event's signature.
|
||||
if (!(await verifyEventWorker(event))) {
|
||||
throw new RelayError('invalid', 'invalid signature');
|
||||
}
|
||||
// Recheck encountered after async ops.
|
||||
if (pipelineEncounters.has(event.id)) {
|
||||
throw new RelayError('duplicate', 'already have this event');
|
||||
}
|
||||
// Set the event as encountered after verifying the signature.
|
||||
pipelineEncounters.set(event.id, true);
|
||||
|
||||
// Log the event.
|
||||
logi({ level: 'debug', ns: 'ditto.event', source: 'pipeline', id: event.id, kind: event.kind });
|
||||
pipelineEventsCounter.inc({ kind: event.kind });
|
||||
|
||||
// NIP-46 events get special treatment.
|
||||
// They are exempt from policies and other side-effects, and should be streamed out immediately.
|
||||
// If streaming fails, an error should be returned.
|
||||
if (event.kind === 24133) {
|
||||
await streamOut(event);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the event doesn't violate the policy.
|
||||
if (event.pubkey !== Conf.pubkey) {
|
||||
await policyFilter(event, opts.signal);
|
||||
}
|
||||
|
||||
// Prepare the event for additional checks.
|
||||
// FIXME: This is kind of hacky. Should be reorganized to fetch only what's needed for each stage.
|
||||
await hydrateEvent(event, opts.signal);
|
||||
|
||||
// Ensure that the author is not banned.
|
||||
const n = getTagSet(event.user?.tags ?? [], 'n');
|
||||
if (n.has('disabled')) {
|
||||
throw new RelayError('blocked', 'author is blocked');
|
||||
}
|
||||
|
||||
// Ephemeral events must throw if they are not streamed out.
|
||||
if (NKinds.ephemeral(event.kind)) {
|
||||
await Promise.all([
|
||||
streamOut(event),
|
||||
webPush(event),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Events received through notify are thought to already be in the database, so they only need to be streamed.
|
||||
if (opts.source === 'notify') {
|
||||
await Promise.all([
|
||||
streamOut(event),
|
||||
webPush(event),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
const kysely = await Storages.kysely();
|
||||
|
||||
try {
|
||||
await storeEvent(purifyEvent(event), opts.signal);
|
||||
} finally {
|
||||
// This needs to run in steps, and should not block the API from responding.
|
||||
Promise.allSettled([
|
||||
handleZaps(kysely, event),
|
||||
updateAuthorData(event, opts.signal),
|
||||
prewarmLinkPreview(event, opts.signal),
|
||||
generateSetEvents(event),
|
||||
])
|
||||
.then(() =>
|
||||
Promise.allSettled([
|
||||
streamOut(event),
|
||||
webPush(event),
|
||||
])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function policyFilter(event: NostrEvent, signal: AbortSignal): Promise<void> {
|
||||
try {
|
||||
const result = await policyWorker.call(event, signal);
|
||||
const [, , ok, reason] = result;
|
||||
logi({ level: 'debug', ns: 'ditto.policy', id: event.id, kind: event.kind, ok, reason });
|
||||
policyEventsCounter.inc({ ok: String(ok) });
|
||||
RelayError.assert(result);
|
||||
} catch (e) {
|
||||
if (e instanceof RelayError) {
|
||||
throw e;
|
||||
} else {
|
||||
logi({ level: 'error', ns: 'ditto.policy', id: event.id, kind: event.kind, error: errorJson(e) });
|
||||
throw new RelayError('blocked', 'policy error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check whether the event has a NIP-70 `-` tag. */
|
||||
function isProtectedEvent(event: NostrEvent): boolean {
|
||||
return event.tags.some(([name]) => name === '-');
|
||||
}
|
||||
|
||||
/** Hydrate the event with the user, if applicable. */
|
||||
async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<void> {
|
||||
await hydrateEvents({ events: [event], store: await Storages.db(), signal });
|
||||
}
|
||||
|
||||
/** Maybe store the event, if eligible. */
|
||||
async function storeEvent(event: NostrEvent, signal?: AbortSignal): Promise<undefined> {
|
||||
if (NKinds.ephemeral(event.kind)) return;
|
||||
const store = await Storages.db();
|
||||
|
||||
try {
|
||||
await store.transaction(async (store, kysely) => {
|
||||
await updateStats({ event, store, kysely });
|
||||
await store.event(event, { signal });
|
||||
});
|
||||
} catch (e) {
|
||||
// If the failure is only because of updateStats (which runs first), insert the event anyway.
|
||||
// We can't catch this in the transaction because the error aborts the transaction on the Postgres side.
|
||||
if (e instanceof Error && e.message.includes('event_stats' satisfies keyof DittoTables)) {
|
||||
await store.event(event, { signal });
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse kind 0 metadata and track indexes in the database. */
|
||||
async function updateAuthorData(event: NostrEvent, signal: AbortSignal): Promise<void> {
|
||||
if (event.kind !== 0) return;
|
||||
|
||||
// Parse metadata.
|
||||
const metadata = n.json().pipe(n.metadata()).catch({}).safeParse(event.content);
|
||||
if (!metadata.success) return;
|
||||
|
||||
const { name, nip05 } = metadata.data;
|
||||
|
||||
const kysely = await Storages.kysely();
|
||||
|
||||
const updates: UpdateObject<DittoTables, 'author_stats'> = {};
|
||||
|
||||
const authorStats = await kysely
|
||||
.selectFrom('author_stats')
|
||||
.selectAll()
|
||||
.where('pubkey', '=', event.pubkey)
|
||||
.executeTakeFirst();
|
||||
|
||||
const lastVerified = authorStats?.nip05_last_verified_at;
|
||||
const eventNewer = !lastVerified || event.created_at > lastVerified;
|
||||
|
||||
try {
|
||||
if (nip05 !== authorStats?.nip05 && eventNewer || !lastVerified) {
|
||||
if (nip05) {
|
||||
const tld = tldts.parse(nip05);
|
||||
if (tld.isIcann && !tld.isIp && !tld.isPrivate) {
|
||||
const pointer = await nip05Cache.fetch(nip05.toLowerCase(), { signal });
|
||||
if (pointer.pubkey === event.pubkey) {
|
||||
updates.nip05 = nip05;
|
||||
updates.nip05_domain = tld.domain;
|
||||
updates.nip05_hostname = tld.hostname;
|
||||
updates.nip05_last_verified_at = event.created_at;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updates.nip05 = null;
|
||||
updates.nip05_domain = null;
|
||||
updates.nip05_hostname = null;
|
||||
updates.nip05_last_verified_at = event.created_at;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallthrough.
|
||||
}
|
||||
|
||||
// Fetch favicon.
|
||||
const domain = nip05?.split('@')[1].toLowerCase();
|
||||
if (domain) {
|
||||
try {
|
||||
await faviconCache.fetch(domain, { signal });
|
||||
} catch {
|
||||
// Fallthrough.
|
||||
}
|
||||
}
|
||||
|
||||
const search = [name, nip05].filter(Boolean).join(' ').trim();
|
||||
|
||||
if (search !== authorStats?.search) {
|
||||
updates.search = search;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length) {
|
||||
await kysely.insertInto('author_stats')
|
||||
.values({
|
||||
pubkey: event.pubkey,
|
||||
followers_count: 0,
|
||||
following_count: 0,
|
||||
notes_count: 0,
|
||||
search,
|
||||
...updates,
|
||||
})
|
||||
.onConflict((oc) => oc.column('pubkey').doUpdateSet(updates))
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
async function prewarmLinkPreview(event: NostrEvent, signal: AbortSignal): Promise<void> {
|
||||
const { firstUrl } = parseNoteContent(stripimeta(event.content, event.tags), []);
|
||||
if (firstUrl) {
|
||||
await unfurlCardCached(firstUrl, signal);
|
||||
}
|
||||
}
|
||||
|
||||
/** Determine if the event is being received in a timely manner. */
|
||||
function isFresh(event: NostrEvent): boolean {
|
||||
return eventAge(event) < Time.minutes(1);
|
||||
}
|
||||
|
||||
/** Distribute the event through active subscriptions. */
|
||||
async function streamOut(event: NostrEvent): Promise<void> {
|
||||
if (!isFresh(event)) {
|
||||
throw new RelayError('invalid', 'event too old');
|
||||
}
|
||||
|
||||
const pubsub = await Storages.pubsub();
|
||||
await pubsub.event(event);
|
||||
}
|
||||
|
||||
async function webPush(event: NostrEvent): Promise<void> {
|
||||
if (!isFresh(event)) {
|
||||
throw new RelayError('invalid', 'event too old');
|
||||
}
|
||||
|
||||
const kysely = await Storages.kysely();
|
||||
const pubkeys = getTagSet(event.tags, 'p');
|
||||
|
||||
if (!pubkeys.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = await kysely
|
||||
.selectFrom('push_subscriptions')
|
||||
.selectAll()
|
||||
.where('pubkey', 'in', [...pubkeys])
|
||||
.execute();
|
||||
|
||||
for (const row of rows) {
|
||||
const viewerPubkey = row.pubkey;
|
||||
|
||||
if (viewerPubkey === event.pubkey) {
|
||||
continue; // Don't notify authors about their own events.
|
||||
}
|
||||
|
||||
const message = await renderWebPushNotification(event, viewerPubkey);
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const subscription = {
|
||||
endpoint: row.endpoint,
|
||||
keys: {
|
||||
auth: row.auth,
|
||||
p256dh: row.p256dh,
|
||||
},
|
||||
};
|
||||
|
||||
await DittoPush.push(subscription, message);
|
||||
webPushNotificationsCounter.inc({ type: message.notification_type });
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSetEvents(event: NostrEvent): Promise<void> {
|
||||
const tagsAdmin = event.tags.some(([name, value]) => ['p', 'P'].includes(name) && value === Conf.pubkey);
|
||||
|
||||
if (event.kind === 1984 && tagsAdmin) {
|
||||
const signer = new AdminSigner();
|
||||
|
||||
const rel = await signer.signEvent({
|
||||
kind: 30383,
|
||||
content: '',
|
||||
tags: [
|
||||
['d', event.id],
|
||||
['p', event.pubkey],
|
||||
['k', '1984'],
|
||||
['n', 'open'],
|
||||
...[...getTagSet(event.tags, 'p')].map((pubkey) => ['P', pubkey]),
|
||||
...[...getTagSet(event.tags, 'e')].map((pubkey) => ['e', pubkey]),
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
await handleEvent(rel, { source: 'pipeline', signal: AbortSignal.timeout(1000) });
|
||||
}
|
||||
|
||||
if (event.kind === 3036 && tagsAdmin) {
|
||||
const signer = new AdminSigner();
|
||||
|
||||
const rel = await signer.signEvent({
|
||||
kind: 30383,
|
||||
content: '',
|
||||
tags: [
|
||||
['d', event.id],
|
||||
['p', event.pubkey],
|
||||
['k', '3036'],
|
||||
['n', 'pending'],
|
||||
],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
await handleEvent(rel, { source: 'pipeline', signal: AbortSignal.timeout(1000) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Stores the event in the 'event_zaps' table */
|
||||
async function handleZaps(kysely: Kysely<DittoTables>, event: NostrEvent) {
|
||||
if (event.kind !== 9735) return;
|
||||
|
||||
const zapRequestString = event?.tags?.find(([name]) => name === 'description')?.[1];
|
||||
if (!zapRequestString) return;
|
||||
const zapRequest = n.json().pipe(n.event()).optional().catch(undefined).parse(zapRequestString);
|
||||
if (!zapRequest) return;
|
||||
|
||||
const amountSchema = z.coerce.number().int().nonnegative().catch(0);
|
||||
const amount_millisats = amountSchema.parse(getAmount(event?.tags.find(([name]) => name === 'bolt11')?.[1]));
|
||||
if (!amount_millisats || amount_millisats < 1) return;
|
||||
|
||||
const zappedEventId = zapRequest.tags.find(([name]) => name === 'e')?.[1];
|
||||
if (!zappedEventId) return;
|
||||
|
||||
try {
|
||||
await kysely.insertInto('event_zaps').values({
|
||||
receipt_id: event.id,
|
||||
target_event_id: zappedEventId,
|
||||
sender_pubkey: zapRequest.pubkey,
|
||||
amount_millisats,
|
||||
comment: zapRequest.content,
|
||||
}).execute();
|
||||
} catch {
|
||||
// receipt_id is unique, do nothing
|
||||
}
|
||||
}
|
||||
|
||||
export { handleEvent, handleZaps, updateAuthorData };
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { Conf } from '@/config.ts';
|
||||
|
||||
/** Ensure the media URL is not on the same host as the local domain. */
|
||||
function checkMediaHost() {
|
||||
const { url, mediaDomain } = Conf;
|
||||
const mediaUrl = new URL(mediaDomain);
|
||||
|
||||
if (url.host === mediaUrl.host) {
|
||||
throw new PrecheckError('For security reasons, MEDIA_DOMAIN cannot be on the same host as LOCAL_DOMAIN.');
|
||||
}
|
||||
}
|
||||
|
||||
/** Error class for precheck errors. */
|
||||
class PrecheckError extends Error {
|
||||
constructor(message: string) {
|
||||
super(`${message}\nTo disable this check, set DITTO_PRECHECK="false"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Deno.env.get('DITTO_PRECHECK') !== 'false') {
|
||||
checkMediaHost();
|
||||
}
|
||||
|
|
@ -1,76 +1,60 @@
|
|||
import { DittoConf } from '@ditto/conf';
|
||||
import { DittoTables } from '@ditto/db';
|
||||
import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
|
||||
import { Kysely } from 'kysely';
|
||||
|
||||
import { Conf } from '@/config.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { type DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { type DittoRelation } from '@/interfaces/DittoFilter.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { fallbackAuthor } from '@/utils.ts';
|
||||
import { findReplyTag, getTagSet } from '@/utils/tags.ts';
|
||||
import { findReplyTag, getTagSet } from '../utils/tags.ts';
|
||||
|
||||
interface GetEventOpts {
|
||||
/** Signal to abort the request. */
|
||||
conf: DittoConf;
|
||||
store: NStore;
|
||||
kysely: Kysely<DittoTables>;
|
||||
signal?: AbortSignal;
|
||||
/** Event kind. */
|
||||
kind?: number;
|
||||
/** @deprecated Relations to include on the event. */
|
||||
relations?: DittoRelation[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Nostr event by its ID.
|
||||
* @deprecated Use `store.query` directly.
|
||||
*/
|
||||
const getEvent = async (
|
||||
id: string,
|
||||
opts: GetEventOpts = {},
|
||||
): Promise<DittoEvent | undefined> => {
|
||||
const store = await Storages.db();
|
||||
const { kind, signal = AbortSignal.timeout(1000) } = opts;
|
||||
/** Get a Nostr event by its ID. */
|
||||
async function getEvent(opts: GetEventOpts, id: string): Promise<DittoEvent | undefined> {
|
||||
const { store, signal } = opts;
|
||||
|
||||
const filter: NostrFilter = { ids: [id], limit: 1 };
|
||||
if (kind) {
|
||||
filter.kinds = [kind];
|
||||
}
|
||||
|
||||
return await store.query([filter], { limit: 1, signal })
|
||||
.then((events) => hydrateEvents({ events, store, signal }))
|
||||
return await store.query([{ ...filter, limit: 1 }], { signal })
|
||||
.then((events) => hydrateEvents(opts, events))
|
||||
.then(([event]) => event);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Nostr `set_medatadata` event for a user's pubkey.
|
||||
* @deprecated Use `store.query` directly.
|
||||
*/
|
||||
async function getAuthor(pubkey: string, opts: GetEventOpts = {}): Promise<NostrEvent | undefined> {
|
||||
const store = await Storages.db();
|
||||
const { signal = AbortSignal.timeout(1000) } = opts;
|
||||
/** Get a Nostr `set_medatadata` event for a user's pubkey. */
|
||||
async function getAuthor(opts: GetEventOpts, pubkey: string): Promise<NostrEvent | undefined> {
|
||||
const { store, signal } = opts;
|
||||
|
||||
const events = await store.query([{ authors: [pubkey], kinds: [0], limit: 1 }], { limit: 1, signal });
|
||||
const events = await store.query([{ authors: [pubkey], kinds: [0], limit: 1 }], { signal });
|
||||
const event = events[0] ?? fallbackAuthor(pubkey);
|
||||
|
||||
await hydrateEvents({ events: [event], store, signal });
|
||||
await hydrateEvents(opts, [event]);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
/** Get users the given pubkey follows. */
|
||||
const getFollows = async (pubkey: string, signal?: AbortSignal): Promise<NostrEvent | undefined> => {
|
||||
const store = await Storages.db();
|
||||
const [event] = await store.query([{ authors: [pubkey], kinds: [3], limit: 1 }], { limit: 1, signal });
|
||||
async function getFollows(opts: GetEventOpts, pubkey: string): Promise<NostrEvent | undefined> {
|
||||
const { store, signal } = opts;
|
||||
const [event] = await store.query([{ authors: [pubkey], kinds: [3], limit: 1 }], { signal });
|
||||
return event;
|
||||
};
|
||||
}
|
||||
|
||||
/** Get pubkeys the user follows. */
|
||||
async function getFollowedPubkeys(pubkey: string, signal?: AbortSignal): Promise<Set<string>> {
|
||||
const event = await getFollows(pubkey, signal);
|
||||
async function getFollowedPubkeys(opts: GetEventOpts, pubkey: string): Promise<Set<string>> {
|
||||
const event = await getFollows(opts, pubkey);
|
||||
if (!event) return new Set();
|
||||
return getTagSet(event.tags, 'p');
|
||||
}
|
||||
|
||||
/** Get pubkeys the user follows, including the user's own pubkey. */
|
||||
async function getFeedPubkeys(pubkey: string): Promise<Set<string>> {
|
||||
const authors = await getFollowedPubkeys(pubkey);
|
||||
async function getFeedPubkeys(opts: GetEventOpts, pubkey: string): Promise<Set<string>> {
|
||||
const authors = await getFollowedPubkeys(opts, pubkey);
|
||||
return authors.add(pubkey);
|
||||
}
|
||||
|
||||
|
|
@ -103,14 +87,11 @@ async function getDescendants(
|
|||
}
|
||||
|
||||
/** Returns whether the pubkey is followed by a local user. */
|
||||
async function isLocallyFollowed(pubkey: string): Promise<boolean> {
|
||||
const { host } = Conf.url;
|
||||
|
||||
const store = await Storages.db();
|
||||
async function isLocallyFollowed(conf: DittoConf, store: NStore, pubkey: string): Promise<boolean> {
|
||||
const { host } = conf.url;
|
||||
|
||||
const [event] = await store.query(
|
||||
[{ kinds: [3], '#p': [pubkey], search: `domain:${host}`, limit: 1 }],
|
||||
{ limit: 1 },
|
||||
);
|
||||
|
||||
return Boolean(event);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import ISO6391, { LanguageCode } from 'iso-639-1';
|
||||
import { NSchema as n } from '@nostrify/nostrify';
|
||||
import ISO6391, { LanguageCode } from 'iso-639-1';
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Validates individual items in an array, dropping any that aren't valid. */
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue