mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 03:19:46 +00:00
Merge branch 'router' into 'main'
Switch to @ditto/router See merge request soapbox-pub/ditto!683
This commit is contained in:
commit
045eb4e1d6
92 changed files with 1770 additions and 1233 deletions
|
|
@ -1,12 +1,13 @@
|
||||||
{
|
{
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"workspace": [
|
"workspace": [
|
||||||
"./packages/api",
|
|
||||||
"./packages/conf",
|
"./packages/conf",
|
||||||
"./packages/db",
|
"./packages/db",
|
||||||
"./packages/ditto",
|
"./packages/ditto",
|
||||||
"./packages/lang",
|
"./packages/lang",
|
||||||
|
"./packages/mastoapi",
|
||||||
"./packages/metrics",
|
"./packages/metrics",
|
||||||
|
"./packages/nip98",
|
||||||
"./packages/policies",
|
"./packages/policies",
|
||||||
"./packages/ratelimiter",
|
"./packages/ratelimiter",
|
||||||
"./packages/translators",
|
"./packages/translators",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
{
|
|
||||||
"name": "@ditto/api",
|
|
||||||
"version": "1.1.0",
|
|
||||||
"exports": {
|
|
||||||
"./middleware": "./middleware/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), async (c) => c.text(await c.var.conf.signer.getPublicKey()));
|
|
||||||
|
|
||||||
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 +0,0 @@
|
||||||
export { confMw } from './confMw.ts';
|
|
||||||
export { confRequiredMw } from './confRequiredMw.ts';
|
|
||||||
9
packages/db/adapters/DummyDB.test.ts
Normal file
9
packages/db/adapters/DummyDB.test.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { assertEquals } from '@std/assert';
|
||||||
|
import { DummyDB } from './DummyDB.ts';
|
||||||
|
|
||||||
|
Deno.test('DummyDB', async () => {
|
||||||
|
const db = new DummyDB();
|
||||||
|
const rows = await db.kysely.selectFrom('nostr_events').selectAll().execute();
|
||||||
|
|
||||||
|
assertEquals(rows, []);
|
||||||
|
});
|
||||||
29
packages/db/adapters/DummyDB.ts
Normal file
29
packages/db/adapters/DummyDB.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { DummyDriver, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from 'kysely';
|
||||||
|
|
||||||
|
import type { DittoDB } from '../DittoDB.ts';
|
||||||
|
import type { DittoTables } from '../DittoTables.ts';
|
||||||
|
|
||||||
|
export class DummyDB implements DittoDB {
|
||||||
|
readonly kysely: Kysely<DittoTables>;
|
||||||
|
readonly poolSize = 0;
|
||||||
|
readonly availableConnections = 0;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.kysely = new Kysely<DittoTables>({
|
||||||
|
dialect: {
|
||||||
|
createAdapter: () => new PostgresAdapter(),
|
||||||
|
createDriver: () => new DummyDriver(),
|
||||||
|
createIntrospector: (db) => new PostgresIntrospector(db),
|
||||||
|
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
listen(): void {
|
||||||
|
// noop
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.asyncDispose](): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
|
export { DittoPglite } from './adapters/DittoPglite.ts';
|
||||||
export { DittoPolyPg } from './adapters/DittoPolyPg.ts';
|
export { DittoPolyPg } from './adapters/DittoPolyPg.ts';
|
||||||
|
export { DittoPostgres } from './adapters/DittoPostgres.ts';
|
||||||
|
export { DummyDB } from './adapters/DummyDB.ts';
|
||||||
|
|
||||||
export type { DittoDB } from './DittoDB.ts';
|
export type { DittoDB } from './DittoDB.ts';
|
||||||
export type { DittoTables } from './DittoTables.ts';
|
export type { DittoTables } from './DittoTables.ts';
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,18 @@
|
||||||
import { confMw } from '@ditto/api/middleware';
|
import { DittoConf } from '@ditto/conf';
|
||||||
import { type DittoConf } from '@ditto/conf';
|
import { DittoDB } from '@ditto/db';
|
||||||
import { DittoTables } from '@ditto/db';
|
import { paginationMiddleware, tokenMiddleware, userMiddleware } from '@ditto/mastoapi/middleware';
|
||||||
|
import { DittoApp, type DittoEnv } from '@ditto/mastoapi/router';
|
||||||
import { type DittoTranslator } from '@ditto/translators';
|
import { type DittoTranslator } from '@ditto/translators';
|
||||||
import { type Context, Env as HonoEnv, Handler, Hono, Input as HonoInput, MiddlewareHandler } from '@hono/hono';
|
import { type Context, Handler, Input as HonoInput, MiddlewareHandler } from '@hono/hono';
|
||||||
import { every } from '@hono/hono/combine';
|
import { every } from '@hono/hono/combine';
|
||||||
import { cors } from '@hono/hono/cors';
|
import { cors } from '@hono/hono/cors';
|
||||||
import { serveStatic } from '@hono/hono/deno';
|
import { serveStatic } from '@hono/hono/deno';
|
||||||
import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify';
|
import { NostrEvent, NostrSigner, NRelay, NUploader } from '@nostrify/nostrify';
|
||||||
import { Kysely } from 'kysely';
|
|
||||||
|
|
||||||
import '@/startup.ts';
|
import '@/startup.ts';
|
||||||
|
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
import { Storages } from '@/storages.ts';
|
||||||
import { Time } from '@/utils/time.ts';
|
import { Time } from '@/utils/time.ts';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -135,39 +137,37 @@ import { metricsController } from '@/controllers/metrics.ts';
|
||||||
import { manifestController } from '@/controllers/manifest.ts';
|
import { manifestController } from '@/controllers/manifest.ts';
|
||||||
import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts';
|
import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts';
|
||||||
import { nostrController } from '@/controllers/well-known/nostr.ts';
|
import { nostrController } from '@/controllers/well-known/nostr.ts';
|
||||||
import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts';
|
|
||||||
import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts';
|
import { cacheControlMiddleware } from '@/middleware/cacheControlMiddleware.ts';
|
||||||
import { cspMiddleware } from '@/middleware/cspMiddleware.ts';
|
import { cspMiddleware } from '@/middleware/cspMiddleware.ts';
|
||||||
import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts';
|
import { metricsMiddleware } from '@/middleware/metricsMiddleware.ts';
|
||||||
import { notActivitypubMiddleware } from '@/middleware/notActivitypubMiddleware.ts';
|
import { notActivitypubMiddleware } from '@/middleware/notActivitypubMiddleware.ts';
|
||||||
import { paginationMiddleware } from '@/middleware/paginationMiddleware.ts';
|
|
||||||
import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.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 { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
|
||||||
import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts';
|
import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts';
|
||||||
import { logiMiddleware } from '@/middleware/logiMiddleware.ts';
|
import { logiMiddleware } from '@/middleware/logiMiddleware.ts';
|
||||||
|
|
||||||
export interface AppEnv extends HonoEnv {
|
export interface AppEnv extends DittoEnv {
|
||||||
Variables: {
|
Variables: {
|
||||||
conf: DittoConf;
|
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;
|
|
||||||
/** Uploader for the user to upload files. */
|
/** Uploader for the user to upload files. */
|
||||||
uploader?: NUploader;
|
uploader?: NUploader;
|
||||||
/** NIP-98 signed event proving the pubkey is owned by the user. */
|
/** NIP-98 signed event proving the pubkey is owned by the user. */
|
||||||
proof?: NostrEvent;
|
proof?: NostrEvent;
|
||||||
/** Kysely instance for the database. */
|
/** Kysely instance for the database. */
|
||||||
kysely: Kysely<DittoTables>;
|
db: DittoDB;
|
||||||
/** Storage for the user, might filter out unwanted content. */
|
/** Base database store. No content filtering. */
|
||||||
store: NStore;
|
relay: NRelay;
|
||||||
/** Normalized pagination params. */
|
/** Normalized pagination params. */
|
||||||
pagination: { since?: number; until?: number; limit: number };
|
pagination: { since?: number; until?: number; limit: number };
|
||||||
/** Normalized list pagination params. */
|
|
||||||
listPagination: { offset: number; limit: number };
|
|
||||||
/** Translation service. */
|
/** Translation service. */
|
||||||
translator?: DittoTranslator;
|
translator?: DittoTranslator;
|
||||||
|
signal: AbortSignal;
|
||||||
|
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;
|
||||||
|
/** User's relay. Might filter out unwanted content. */
|
||||||
|
relay: NRelay;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -176,35 +176,46 @@ type AppMiddleware = MiddlewareHandler<AppEnv>;
|
||||||
// deno-lint-ignore no-explicit-any
|
// deno-lint-ignore no-explicit-any
|
||||||
type AppController<P extends string = any> = Handler<AppEnv, P, HonoInput, Response | Promise<Response>>;
|
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: Conf,
|
||||||
|
db: await Storages.database(),
|
||||||
|
relay: await Storages.db(),
|
||||||
|
}, {
|
||||||
|
strict: false,
|
||||||
|
});
|
||||||
|
|
||||||
/** User-provided files in the gitignored `public/` directory. */
|
/** User-provided files in the gitignored `public/` directory. */
|
||||||
const publicFiles = serveStatic({ root: './public/' });
|
const publicFiles = serveStatic({ root: './public/' });
|
||||||
/** Static files provided by the Ditto repo, checked into git. */
|
/** Static files provided by the Ditto repo, checked into git. */
|
||||||
const staticFiles = serveStatic({ root: new URL('./static/', import.meta.url).pathname });
|
const staticFiles = serveStatic({ root: new URL('./static/', import.meta.url).pathname });
|
||||||
|
|
||||||
app.use(confMw(Deno.env), cacheControlMiddleware({ noStore: true }));
|
app.use(cacheControlMiddleware({ noStore: true }));
|
||||||
|
|
||||||
const ratelimit = every(
|
const ratelimit = every(
|
||||||
rateLimitMiddleware(30, Time.seconds(5), false),
|
rateLimitMiddleware(30, Time.seconds(5), false),
|
||||||
rateLimitMiddleware(300, Time.minutes(5), false),
|
rateLimitMiddleware(300, Time.minutes(5), false),
|
||||||
);
|
);
|
||||||
|
|
||||||
app.use('/api/*', metricsMiddleware, ratelimit, paginationMiddleware, logiMiddleware);
|
const socketTokenMiddleware = tokenMiddleware((c) => {
|
||||||
|
const token = c.req.header('sec-websocket-protocol');
|
||||||
|
if (token) {
|
||||||
|
return `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/*', metricsMiddleware, ratelimit, paginationMiddleware(), logiMiddleware);
|
||||||
app.use('/.well-known/*', metricsMiddleware, ratelimit, logiMiddleware);
|
app.use('/.well-known/*', metricsMiddleware, ratelimit, logiMiddleware);
|
||||||
app.use('/nodeinfo/*', metricsMiddleware, ratelimit, logiMiddleware);
|
app.use('/nodeinfo/*', metricsMiddleware, ratelimit, logiMiddleware);
|
||||||
app.use('/oauth/*', metricsMiddleware, ratelimit, logiMiddleware);
|
app.use('/oauth/*', metricsMiddleware, ratelimit, logiMiddleware);
|
||||||
|
|
||||||
app.get('/api/v1/streaming', metricsMiddleware, ratelimit, streamingController);
|
app.get('/api/v1/streaming', socketTokenMiddleware, metricsMiddleware, ratelimit, streamingController);
|
||||||
app.get('/relay', metricsMiddleware, ratelimit, relayController);
|
app.get('/relay', metricsMiddleware, ratelimit, relayController);
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
cspMiddleware(),
|
cspMiddleware(),
|
||||||
cors({ origin: '*', exposeHeaders: ['link'] }),
|
cors({ origin: '*', exposeHeaders: ['link'] }),
|
||||||
signerMiddleware,
|
tokenMiddleware(),
|
||||||
uploaderMiddleware,
|
uploaderMiddleware,
|
||||||
auth98Middleware(),
|
|
||||||
storeMiddleware,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get('/metrics', metricsController);
|
app.get('/metrics', metricsController);
|
||||||
|
|
@ -251,27 +262,27 @@ app.post('/oauth/revoke', revokeTokenController);
|
||||||
app.post('/oauth/authorize', oauthAuthorizeController);
|
app.post('/oauth/authorize', oauthAuthorizeController);
|
||||||
app.get('/oauth/authorize', oauthController);
|
app.get('/oauth/authorize', oauthController);
|
||||||
|
|
||||||
app.post('/api/v1/accounts', requireProof({ pow: 20 }), createAccountController);
|
app.post('/api/v1/accounts', userMiddleware({ verify: true }), createAccountController);
|
||||||
app.get('/api/v1/accounts/verify_credentials', requireSigner, verifyCredentialsController);
|
app.get('/api/v1/accounts/verify_credentials', userMiddleware(), verifyCredentialsController);
|
||||||
app.patch('/api/v1/accounts/update_credentials', requireSigner, updateCredentialsController);
|
app.patch('/api/v1/accounts/update_credentials', userMiddleware(), updateCredentialsController);
|
||||||
app.get('/api/v1/accounts/search', accountSearchController);
|
app.get('/api/v1/accounts/search', accountSearchController);
|
||||||
app.get('/api/v1/accounts/lookup', accountLookupController);
|
app.get('/api/v1/accounts/lookup', accountLookupController);
|
||||||
app.get('/api/v1/accounts/relationships', requireSigner, relationshipsController);
|
app.get('/api/v1/accounts/relationships', userMiddleware(), relationshipsController);
|
||||||
app.get('/api/v1/accounts/familiar_followers', requireSigner, familiarFollowersController);
|
app.get('/api/v1/accounts/familiar_followers', userMiddleware(), familiarFollowersController);
|
||||||
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/block', requireSigner, blockController);
|
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/block', userMiddleware(), blockController);
|
||||||
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unblock', requireSigner, unblockController);
|
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unblock', userMiddleware(), unblockController);
|
||||||
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/mute', requireSigner, muteController);
|
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/mute', userMiddleware(), muteController);
|
||||||
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unmute', requireSigner, unmuteController);
|
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unmute', userMiddleware(), unmuteController);
|
||||||
app.post(
|
app.post(
|
||||||
'/api/v1/accounts/:pubkey{[0-9a-f]{64}}/follow',
|
'/api/v1/accounts/:pubkey{[0-9a-f]{64}}/follow',
|
||||||
rateLimitMiddleware(2, Time.seconds(1)),
|
rateLimitMiddleware(2, Time.seconds(1)),
|
||||||
requireSigner,
|
userMiddleware(),
|
||||||
followController,
|
followController,
|
||||||
);
|
);
|
||||||
app.post(
|
app.post(
|
||||||
'/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unfollow',
|
'/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unfollow',
|
||||||
rateLimitMiddleware(2, Time.seconds(1)),
|
rateLimitMiddleware(2, Time.seconds(1)),
|
||||||
requireSigner,
|
userMiddleware(),
|
||||||
unfollowController,
|
unfollowController,
|
||||||
);
|
);
|
||||||
app.get(
|
app.get(
|
||||||
|
|
@ -295,22 +306,22 @@ app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/favourited_by', favouritedByControll
|
||||||
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/reblogged_by', rebloggedByController);
|
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/reblogged_by', rebloggedByController);
|
||||||
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/context', contextController);
|
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}/context', contextController);
|
||||||
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}', statusController);
|
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}', statusController);
|
||||||
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/favourite', requireSigner, favouriteController);
|
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/favourite', userMiddleware(), favouriteController);
|
||||||
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', requireSigner, bookmarkController);
|
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', userMiddleware(), bookmarkController);
|
||||||
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', requireSigner, unbookmarkController);
|
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', userMiddleware(), unbookmarkController);
|
||||||
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', requireSigner, pinController);
|
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', userMiddleware(), pinController);
|
||||||
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', requireSigner, unpinController);
|
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', userMiddleware(), unpinController);
|
||||||
app.post(
|
app.post(
|
||||||
'/api/v1/statuses/:id{[0-9a-f]{64}}/translate',
|
'/api/v1/statuses/:id{[0-9a-f]{64}}/translate',
|
||||||
requireSigner,
|
userMiddleware(),
|
||||||
rateLimitMiddleware(15, Time.minutes(1)),
|
rateLimitMiddleware(15, Time.minutes(1)),
|
||||||
translatorMiddleware,
|
translatorMiddleware,
|
||||||
translateController,
|
translateController,
|
||||||
);
|
);
|
||||||
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/reblog', requireSigner, reblogStatusController);
|
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/reblog', userMiddleware(), reblogStatusController);
|
||||||
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unreblog', requireSigner, unreblogStatusController);
|
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unreblog', userMiddleware(), unreblogStatusController);
|
||||||
app.post('/api/v1/statuses', requireSigner, createStatusController);
|
app.post('/api/v1/statuses', userMiddleware(), createStatusController);
|
||||||
app.delete('/api/v1/statuses/:id{[0-9a-f]{64}}', requireSigner, deleteStatusController);
|
app.delete('/api/v1/statuses/:id{[0-9a-f]{64}}', userMiddleware(), deleteStatusController);
|
||||||
|
|
||||||
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/quotes', quotesController);
|
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/quotes', quotesController);
|
||||||
|
|
||||||
|
|
@ -321,7 +332,7 @@ app.put(
|
||||||
);
|
);
|
||||||
app.post('/api/v2/media', mediaController);
|
app.post('/api/v2/media', mediaController);
|
||||||
|
|
||||||
app.get('/api/v1/timelines/home', rateLimitMiddleware(8, Time.seconds(30)), requireSigner, homeTimelineController);
|
app.get('/api/v1/timelines/home', rateLimitMiddleware(8, Time.seconds(30)), userMiddleware(), homeTimelineController);
|
||||||
app.get('/api/v1/timelines/public', rateLimitMiddleware(8, Time.seconds(30)), publicTimelineController);
|
app.get('/api/v1/timelines/public', rateLimitMiddleware(8, Time.seconds(30)), publicTimelineController);
|
||||||
app.get('/api/v1/timelines/tag/:hashtag', rateLimitMiddleware(8, Time.seconds(30)), hashtagTimelineController);
|
app.get('/api/v1/timelines/tag/:hashtag', rateLimitMiddleware(8, Time.seconds(30)), hashtagTimelineController);
|
||||||
app.get('/api/v1/timelines/suggested', rateLimitMiddleware(8, Time.seconds(30)), suggestedTimelineController);
|
app.get('/api/v1/timelines/suggested', rateLimitMiddleware(8, Time.seconds(30)), suggestedTimelineController);
|
||||||
|
|
@ -357,42 +368,42 @@ app.get('/api/v1/suggestions', suggestionsV1Controller);
|
||||||
app.get('/api/v2/suggestions', suggestionsV2Controller);
|
app.get('/api/v2/suggestions', suggestionsV2Controller);
|
||||||
app.get('/api/v2/ditto/suggestions/local', localSuggestionsController);
|
app.get('/api/v2/ditto/suggestions/local', localSuggestionsController);
|
||||||
|
|
||||||
app.get('/api/v1/notifications', rateLimitMiddleware(8, Time.seconds(30)), requireSigner, notificationsController);
|
app.get('/api/v1/notifications', rateLimitMiddleware(8, Time.seconds(30)), userMiddleware(), notificationsController);
|
||||||
app.get('/api/v1/notifications/:id', requireSigner, notificationController);
|
app.get('/api/v1/notifications/:id', userMiddleware(), notificationController);
|
||||||
|
|
||||||
app.get('/api/v1/favourites', requireSigner, favouritesController);
|
app.get('/api/v1/favourites', userMiddleware(), favouritesController);
|
||||||
app.get('/api/v1/bookmarks', requireSigner, bookmarksController);
|
app.get('/api/v1/bookmarks', userMiddleware(), bookmarksController);
|
||||||
app.get('/api/v1/blocks', requireSigner, blocksController);
|
app.get('/api/v1/blocks', userMiddleware(), blocksController);
|
||||||
app.get('/api/v1/mutes', requireSigner, mutesController);
|
app.get('/api/v1/mutes', userMiddleware(), mutesController);
|
||||||
|
|
||||||
app.get('/api/v1/markers', requireProof(), markersController);
|
app.get('/api/v1/markers', userMiddleware({ verify: true }), markersController);
|
||||||
app.post('/api/v1/markers', requireProof(), updateMarkersController);
|
app.post('/api/v1/markers', userMiddleware({ verify: true }), updateMarkersController);
|
||||||
|
|
||||||
app.get('/api/v1/push/subscription', requireSigner, getSubscriptionController);
|
app.get('/api/v1/push/subscription', userMiddleware(), getSubscriptionController);
|
||||||
app.post('/api/v1/push/subscription', requireProof(), pushSubscribeController);
|
app.post('/api/v1/push/subscription', userMiddleware({ verify: true }), pushSubscribeController);
|
||||||
|
|
||||||
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions', reactionsController);
|
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions', reactionsController);
|
||||||
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', reactionsController);
|
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', reactionsController);
|
||||||
app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, reactionController);
|
app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), reactionController);
|
||||||
app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', requireSigner, deleteReactionController);
|
app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), deleteReactionController);
|
||||||
|
|
||||||
app.get('/api/v1/pleroma/admin/config', requireRole('admin'), configController);
|
app.get('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), configController);
|
||||||
app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController);
|
app.post('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), updateConfigController);
|
||||||
app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAdminDeleteStatusController);
|
app.delete('/api/v1/pleroma/admin/statuses/:id', userMiddleware({ role: 'admin' }), pleromaAdminDeleteStatusController);
|
||||||
|
|
||||||
app.get('/api/v1/admin/ditto/relays', requireRole('admin'), adminRelaysController);
|
app.get('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminRelaysController);
|
||||||
app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController);
|
app.put('/api/v1/admin/ditto/relays', userMiddleware({ role: 'admin' }), adminSetRelaysController);
|
||||||
|
|
||||||
app.put('/api/v1/admin/ditto/instance', requireRole('admin'), updateInstanceController);
|
app.put('/api/v1/admin/ditto/instance', userMiddleware({ role: 'admin' }), updateInstanceController);
|
||||||
|
|
||||||
app.post('/api/v1/ditto/names', requireSigner, nameRequestController);
|
app.post('/api/v1/ditto/names', userMiddleware(), nameRequestController);
|
||||||
app.get('/api/v1/ditto/names', requireSigner, nameRequestsController);
|
app.get('/api/v1/ditto/names', userMiddleware(), nameRequestsController);
|
||||||
|
|
||||||
app.get('/api/v1/ditto/captcha', rateLimitMiddleware(3, Time.minutes(1)), captchaController);
|
app.get('/api/v1/ditto/captcha', rateLimitMiddleware(3, Time.minutes(1)), captchaController);
|
||||||
app.post(
|
app.post(
|
||||||
'/api/v1/ditto/captcha/:id/verify',
|
'/api/v1/ditto/captcha/:id/verify',
|
||||||
rateLimitMiddleware(8, Time.minutes(1)),
|
rateLimitMiddleware(8, Time.minutes(1)),
|
||||||
requireProof(),
|
userMiddleware({ verify: true }),
|
||||||
captchaVerifyController,
|
captchaVerifyController,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -403,44 +414,59 @@ app.get(
|
||||||
);
|
);
|
||||||
app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController);
|
app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController);
|
||||||
|
|
||||||
app.put('/api/v1/admin/ditto/zap_splits', requireRole('admin'), updateZapSplitsController);
|
app.put('/api/v1/admin/ditto/zap_splits', userMiddleware({ role: 'admin' }), updateZapSplitsController);
|
||||||
app.delete('/api/v1/admin/ditto/zap_splits', requireRole('admin'), deleteZapSplitsController);
|
app.delete('/api/v1/admin/ditto/zap_splits', userMiddleware({ role: 'admin' }), deleteZapSplitsController);
|
||||||
|
|
||||||
app.post('/api/v1/ditto/zap', requireSigner, zapController);
|
app.post('/api/v1/ditto/zap', userMiddleware(), zapController);
|
||||||
app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController);
|
app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController);
|
||||||
|
|
||||||
app.route('/api/v1/ditto/cashu', cashuApp);
|
app.route('/api/v1/ditto/cashu', cashuApp);
|
||||||
|
|
||||||
app.post('/api/v1/reports', requireSigner, reportController);
|
app.post('/api/v1/reports', userMiddleware(), reportController);
|
||||||
app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController);
|
app.get('/api/v1/admin/reports', userMiddleware(), userMiddleware({ role: 'admin' }), adminReportsController);
|
||||||
app.get('/api/v1/admin/reports/:id{[0-9a-f]{64}}', requireSigner, requireRole('admin'), adminReportController);
|
app.get(
|
||||||
|
'/api/v1/admin/reports/:id{[0-9a-f]{64}}',
|
||||||
|
userMiddleware(),
|
||||||
|
userMiddleware({ role: 'admin' }),
|
||||||
|
adminReportController,
|
||||||
|
);
|
||||||
app.post(
|
app.post(
|
||||||
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/resolve',
|
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/resolve',
|
||||||
requireSigner,
|
userMiddleware(),
|
||||||
requireRole('admin'),
|
userMiddleware({ role: 'admin' }),
|
||||||
adminReportResolveController,
|
adminReportResolveController,
|
||||||
);
|
);
|
||||||
app.post(
|
app.post(
|
||||||
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/reopen',
|
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/reopen',
|
||||||
requireSigner,
|
userMiddleware(),
|
||||||
requireRole('admin'),
|
userMiddleware({ role: 'admin' }),
|
||||||
adminReportReopenController,
|
adminReportReopenController,
|
||||||
);
|
);
|
||||||
|
|
||||||
app.get('/api/v1/admin/accounts', requireRole('admin'), adminAccountsController);
|
app.get('/api/v1/admin/accounts', userMiddleware({ role: 'admin' }), adminAccountsController);
|
||||||
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminActionController);
|
app.post(
|
||||||
|
'/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action',
|
||||||
|
userMiddleware(),
|
||||||
|
userMiddleware({ role: 'admin' }),
|
||||||
|
adminActionController,
|
||||||
|
);
|
||||||
app.post(
|
app.post(
|
||||||
'/api/v1/admin/accounts/:id{[0-9a-f]{64}}/approve',
|
'/api/v1/admin/accounts/:id{[0-9a-f]{64}}/approve',
|
||||||
requireSigner,
|
userMiddleware(),
|
||||||
requireRole('admin'),
|
userMiddleware({ role: 'admin' }),
|
||||||
adminApproveController,
|
adminApproveController,
|
||||||
);
|
);
|
||||||
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/reject', requireSigner, requireRole('admin'), adminRejectController);
|
app.post(
|
||||||
|
'/api/v1/admin/accounts/:id{[0-9a-f]{64}}/reject',
|
||||||
|
userMiddleware(),
|
||||||
|
userMiddleware({ role: 'admin' }),
|
||||||
|
adminRejectController,
|
||||||
|
);
|
||||||
|
|
||||||
app.put('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminTagController);
|
app.put('/api/v1/pleroma/admin/users/tag', userMiddleware({ role: 'admin' }), pleromaAdminTagController);
|
||||||
app.delete('/api/v1/pleroma/admin/users/tag', requireRole('admin'), pleromaAdminUntagController);
|
app.delete('/api/v1/pleroma/admin/users/tag', userMiddleware({ role: 'admin' }), pleromaAdminUntagController);
|
||||||
app.patch('/api/v1/pleroma/admin/users/suggest', requireRole('admin'), pleromaAdminSuggestController);
|
app.patch('/api/v1/pleroma/admin/users/suggest', userMiddleware({ role: 'admin' }), pleromaAdminSuggestController);
|
||||||
app.patch('/api/v1/pleroma/admin/users/unsuggest', requireRole('admin'), pleromaAdminUnsuggestController);
|
app.patch('/api/v1/pleroma/admin/users/unsuggest', userMiddleware({ role: 'admin' }), pleromaAdminUnsuggestController);
|
||||||
|
|
||||||
// Not (yet) implemented.
|
// Not (yet) implemented.
|
||||||
app.get('/api/v1/custom_emojis', emptyArrayController);
|
app.get('/api/v1/custom_emojis', emptyArrayController);
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,9 @@ const createAccountSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const createAccountController: AppController = async (c) => {
|
const createAccountController: AppController = async (c) => {
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
const { user } = c.var;
|
||||||
|
|
||||||
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const result = createAccountSchema.safeParse(await c.req.json());
|
const result = createAccountSchema.safeParse(await c.req.json());
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
@ -46,15 +48,15 @@ const createAccountController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const verifyCredentialsController: AppController = async (c) => {
|
const verifyCredentialsController: AppController = async (c) => {
|
||||||
const signer = c.get('signer')!;
|
const { relay, user } = c.var;
|
||||||
const pubkey = await signer.getPublicKey();
|
|
||||||
|
|
||||||
const store = await Storages.db();
|
const signer = user!.signer;
|
||||||
|
const pubkey = await signer.getPublicKey();
|
||||||
|
|
||||||
const [author, [settingsEvent]] = await Promise.all([
|
const [author, [settingsEvent]] = await Promise.all([
|
||||||
getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }),
|
getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }),
|
||||||
|
|
||||||
store.query([{
|
relay.query([{
|
||||||
kinds: [30078],
|
kinds: [30078],
|
||||||
authors: [pubkey],
|
authors: [pubkey],
|
||||||
'#d': ['pub.ditto.pleroma_settings_store'],
|
'#d': ['pub.ditto.pleroma_settings_store'],
|
||||||
|
|
@ -115,12 +117,10 @@ const accountSearchQuerySchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const accountSearchController: AppController = async (c) => {
|
const accountSearchController: AppController = async (c) => {
|
||||||
const { store } = c.var;
|
const { db, relay, user, pagination, signal } = c.var;
|
||||||
const { signal } = c.req.raw;
|
const { limit } = pagination;
|
||||||
const { limit } = c.get('pagination');
|
|
||||||
|
|
||||||
const kysely = await Storages.kysely();
|
const viewerPubkey = await user?.signer.getPublicKey();
|
||||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
|
||||||
|
|
||||||
const result = accountSearchQuerySchema.safeParse(c.req.query());
|
const result = accountSearchQuerySchema.safeParse(c.req.query());
|
||||||
|
|
||||||
|
|
@ -144,8 +144,8 @@ const accountSearchController: AppController = async (c) => {
|
||||||
events.push(event);
|
events.push(event);
|
||||||
} else {
|
} else {
|
||||||
const following = viewerPubkey ? await getFollowedPubkeys(viewerPubkey) : new Set<string>();
|
const following = viewerPubkey ? await getFollowedPubkeys(viewerPubkey) : new Set<string>();
|
||||||
const authors = [...await getPubkeysBySearch(kysely, { q: query, limit, offset: 0, following })];
|
const authors = [...await getPubkeysBySearch(db.kysely, { q: query, limit, offset: 0, following })];
|
||||||
const profiles = await store.query([{ kinds: [0], authors, limit }], { signal });
|
const profiles = await relay.query([{ kinds: [0], authors, limit }], { signal });
|
||||||
|
|
||||||
for (const pubkey of authors) {
|
for (const pubkey of authors) {
|
||||||
const profile = profiles.find((event) => event.pubkey === pubkey);
|
const profile = profiles.find((event) => event.pubkey === pubkey);
|
||||||
|
|
@ -155,14 +155,16 @@ const accountSearchController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = await hydrateEvents({ events, store, signal })
|
const accounts = await hydrateEvents({ events, relay, signal })
|
||||||
.then((events) => events.map((event) => renderAccount(event)));
|
.then((events) => events.map((event) => renderAccount(event)));
|
||||||
|
|
||||||
return c.json(accounts);
|
return c.json(accounts);
|
||||||
};
|
};
|
||||||
|
|
||||||
const relationshipsController: AppController = async (c) => {
|
const relationshipsController: AppController = async (c) => {
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
const { user } = c.var;
|
||||||
|
|
||||||
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const ids = z.array(z.string()).safeParse(c.req.queries('id[]'));
|
const ids = z.array(z.string()).safeParse(c.req.queries('id[]'));
|
||||||
|
|
||||||
if (!ids.success) {
|
if (!ids.success) {
|
||||||
|
|
@ -201,17 +203,17 @@ const accountStatusesQuerySchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const accountStatusesController: AppController = async (c) => {
|
const accountStatusesController: AppController = async (c) => {
|
||||||
|
const { conf, user, signal } = c.var;
|
||||||
|
|
||||||
const pubkey = c.req.param('pubkey');
|
const pubkey = c.req.param('pubkey');
|
||||||
const { conf } = c.var;
|
|
||||||
const { since, until } = c.var.pagination;
|
const { since, until } = c.var.pagination;
|
||||||
const { pinned, limit, exclude_replies, tagged, only_media } = accountStatusesQuerySchema.parse(c.req.query());
|
const { pinned, limit, exclude_replies, tagged, only_media } = accountStatusesQuerySchema.parse(c.req.query());
|
||||||
const { signal } = c.req.raw;
|
|
||||||
|
|
||||||
const store = await Storages.db();
|
const { relay } = c.var;
|
||||||
|
|
||||||
const [[author], [user]] = await Promise.all([
|
const [[author], [userEvent]] = await Promise.all([
|
||||||
store.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal }),
|
relay.query([{ kinds: [0], authors: [pubkey], limit: 1 }], { signal }),
|
||||||
store.query([{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#d': [pubkey], limit: 1 }], {
|
relay.query([{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#d': [pubkey], limit: 1 }], {
|
||||||
signal,
|
signal,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
@ -220,14 +222,14 @@ const accountStatusesController: AppController = async (c) => {
|
||||||
assertAuthenticated(c, author);
|
assertAuthenticated(c, author);
|
||||||
}
|
}
|
||||||
|
|
||||||
const names = getTagSet(user?.tags ?? [], 'n');
|
const names = getTagSet(userEvent?.tags ?? [], 'n');
|
||||||
|
|
||||||
if (names.has('disabled')) {
|
if (names.has('disabled')) {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pinned) {
|
if (pinned) {
|
||||||
const [pinEvent] = await store.query([{ kinds: [10001], authors: [pubkey], limit: 1 }], { signal });
|
const [pinEvent] = await relay.query([{ kinds: [10001], authors: [pubkey], limit: 1 }], { signal });
|
||||||
if (pinEvent) {
|
if (pinEvent) {
|
||||||
const pinnedEventIds = getTagSet(pinEvent.tags, 'e');
|
const pinnedEventIds = getTagSet(pinEvent.tags, 'e');
|
||||||
return renderStatuses(c, [...pinnedEventIds].reverse());
|
return renderStatuses(c, [...pinnedEventIds].reverse());
|
||||||
|
|
@ -264,8 +266,8 @@ const accountStatusesController: AppController = async (c) => {
|
||||||
|
|
||||||
const opts = { signal, limit, timeout: conf.db.timeouts.timelines };
|
const opts = { signal, limit, timeout: conf.db.timeouts.timelines };
|
||||||
|
|
||||||
const events = await store.query([filter], opts)
|
const events = await relay.query([filter], opts)
|
||||||
.then((events) => hydrateEvents({ events, store, signal }))
|
.then((events) => hydrateEvents({ events, relay, signal }))
|
||||||
.then((events) => {
|
.then((events) => {
|
||||||
if (exclude_replies) {
|
if (exclude_replies) {
|
||||||
return events.filter((event) => {
|
return events.filter((event) => {
|
||||||
|
|
@ -276,7 +278,7 @@ const accountStatusesController: AppController = async (c) => {
|
||||||
return events;
|
return events;
|
||||||
});
|
});
|
||||||
|
|
||||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
const viewerPubkey = await user?.signer.getPublicKey();
|
||||||
|
|
||||||
const statuses = await Promise.all(
|
const statuses = await Promise.all(
|
||||||
events.map((event) => {
|
events.map((event) => {
|
||||||
|
|
@ -303,12 +305,11 @@ const updateCredentialsSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateCredentialsController: AppController = async (c) => {
|
const updateCredentialsController: AppController = async (c) => {
|
||||||
const signer = c.get('signer')!;
|
const { relay, user, signal } = c.var;
|
||||||
const pubkey = await signer.getPublicKey();
|
|
||||||
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const body = await parseBody(c.req.raw);
|
const body = await parseBody(c.req.raw);
|
||||||
const result = updateCredentialsSchema.safeParse(body);
|
const result = updateCredentialsSchema.safeParse(body);
|
||||||
const store = await Storages.db();
|
|
||||||
const signal = c.req.raw.signal;
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return c.json(result.error, 422);
|
return c.json(result.error, 422);
|
||||||
|
|
@ -318,7 +319,7 @@ const updateCredentialsController: AppController = async (c) => {
|
||||||
let event: NostrEvent | undefined;
|
let event: NostrEvent | undefined;
|
||||||
|
|
||||||
if (keys.length === 1 && keys[0] === 'pleroma_settings_store') {
|
if (keys.length === 1 && keys[0] === 'pleroma_settings_store') {
|
||||||
event = (await store.query([{ kinds: [0], authors: [pubkey] }]))[0];
|
event = (await relay.query([{ kinds: [0], authors: [pubkey] }]))[0];
|
||||||
} else {
|
} else {
|
||||||
event = await updateEvent(
|
event = await updateEvent(
|
||||||
{ kinds: [0], authors: [pubkey], limit: 1 },
|
{ kinds: [0], authors: [pubkey], limit: 1 },
|
||||||
|
|
@ -374,7 +375,7 @@ const updateCredentialsController: AppController = async (c) => {
|
||||||
|
|
||||||
let account: MastodonAccount;
|
let account: MastodonAccount;
|
||||||
if (event) {
|
if (event) {
|
||||||
await hydrateEvents({ events: [event], store, signal });
|
await hydrateEvents({ events: [event], relay, signal });
|
||||||
account = await renderAccount(event, { withSource: true, settingsStore });
|
account = await renderAccount(event, { withSource: true, settingsStore });
|
||||||
} else {
|
} else {
|
||||||
account = await accountFromPubkey(pubkey, { withSource: true, settingsStore });
|
account = await accountFromPubkey(pubkey, { withSource: true, settingsStore });
|
||||||
|
|
@ -393,7 +394,9 @@ const updateCredentialsController: AppController = async (c) => {
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/accounts/#follow */
|
/** https://docs.joinmastodon.org/methods/accounts/#follow */
|
||||||
const followController: AppController = async (c) => {
|
const followController: AppController = async (c) => {
|
||||||
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
|
const { user } = c.var;
|
||||||
|
|
||||||
|
const sourcePubkey = await user!.signer.getPublicKey();
|
||||||
const targetPubkey = c.req.param('pubkey');
|
const targetPubkey = c.req.param('pubkey');
|
||||||
|
|
||||||
await updateListEvent(
|
await updateListEvent(
|
||||||
|
|
@ -410,7 +413,9 @@ const followController: AppController = async (c) => {
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/accounts/#unfollow */
|
/** https://docs.joinmastodon.org/methods/accounts/#unfollow */
|
||||||
const unfollowController: AppController = async (c) => {
|
const unfollowController: AppController = async (c) => {
|
||||||
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
|
const { user } = c.var;
|
||||||
|
|
||||||
|
const sourcePubkey = await user!.signer.getPublicKey();
|
||||||
const targetPubkey = c.req.param('pubkey');
|
const targetPubkey = c.req.param('pubkey');
|
||||||
|
|
||||||
await updateListEvent(
|
await updateListEvent(
|
||||||
|
|
@ -447,7 +452,9 @@ const unblockController: AppController = (c) => {
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/accounts/#mute */
|
/** https://docs.joinmastodon.org/methods/accounts/#mute */
|
||||||
const muteController: AppController = async (c) => {
|
const muteController: AppController = async (c) => {
|
||||||
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
|
const { user } = c.var;
|
||||||
|
|
||||||
|
const sourcePubkey = await user!.signer.getPublicKey();
|
||||||
const targetPubkey = c.req.param('pubkey');
|
const targetPubkey = c.req.param('pubkey');
|
||||||
|
|
||||||
await updateListEvent(
|
await updateListEvent(
|
||||||
|
|
@ -462,7 +469,9 @@ const muteController: AppController = async (c) => {
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/accounts/#unmute */
|
/** https://docs.joinmastodon.org/methods/accounts/#unmute */
|
||||||
const unmuteController: AppController = async (c) => {
|
const unmuteController: AppController = async (c) => {
|
||||||
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
|
const { user } = c.var;
|
||||||
|
|
||||||
|
const sourcePubkey = await user!.signer.getPublicKey();
|
||||||
const targetPubkey = c.req.param('pubkey');
|
const targetPubkey = c.req.param('pubkey');
|
||||||
|
|
||||||
await updateListEvent(
|
await updateListEvent(
|
||||||
|
|
@ -476,14 +485,12 @@ const unmuteController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const favouritesController: AppController = async (c) => {
|
const favouritesController: AppController = async (c) => {
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
const { relay, user, pagination, signal } = c.var;
|
||||||
const params = c.get('pagination');
|
|
||||||
const { signal } = c.req.raw;
|
|
||||||
|
|
||||||
const store = await Storages.db();
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
|
|
||||||
const events7 = await store.query(
|
const events7 = await relay.query(
|
||||||
[{ kinds: [7], authors: [pubkey], ...params }],
|
[{ kinds: [7], authors: [pubkey], ...pagination }],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -491,10 +498,10 @@ const favouritesController: AppController = async (c) => {
|
||||||
.map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1])
|
.map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1])
|
||||||
.filter((id): id is string => !!id);
|
.filter((id): id is string => !!id);
|
||||||
|
|
||||||
const events1 = await store.query([{ kinds: [1, 20], ids }], { signal })
|
const events1 = await relay.query([{ kinds: [1, 20], ids }], { signal })
|
||||||
.then((events) => hydrateEvents({ events, store, signal }));
|
.then((events) => hydrateEvents({ events, relay, signal }));
|
||||||
|
|
||||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
const viewerPubkey = await user?.signer.getPublicKey();
|
||||||
|
|
||||||
const statuses = await Promise.all(
|
const statuses = await Promise.all(
|
||||||
events1.map((event) => renderStatus(event, { viewerPubkey })),
|
events1.map((event) => renderStatus(event, { viewerPubkey })),
|
||||||
|
|
@ -503,16 +510,15 @@ const favouritesController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const familiarFollowersController: AppController = async (c) => {
|
const familiarFollowersController: AppController = async (c) => {
|
||||||
const store = await Storages.db();
|
const { relay, user } = c.var;
|
||||||
const signer = c.get('signer')!;
|
|
||||||
const pubkey = await signer.getPublicKey();
|
|
||||||
|
|
||||||
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const ids = z.array(z.string()).parse(c.req.queries('id[]'));
|
const ids = z.array(z.string()).parse(c.req.queries('id[]'));
|
||||||
const follows = await getFollowedPubkeys(pubkey);
|
const follows = await getFollowedPubkeys(pubkey);
|
||||||
|
|
||||||
const results = await Promise.all(ids.map(async (id) => {
|
const results = await Promise.all(ids.map(async (id) => {
|
||||||
const followLists = await store.query([{ kinds: [3], authors: [...follows], '#p': [id] }])
|
const followLists = await relay.query([{ kinds: [3], authors: [...follows], '#p': [id] }])
|
||||||
.then((events) => hydrateEvents({ events, store }));
|
.then((events) => hydrateEvents({ events, relay }));
|
||||||
|
|
||||||
const accounts = await Promise.all(
|
const accounts = await Promise.all(
|
||||||
followLists.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)),
|
followLists.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)),
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { type AppController } from '@/app.ts';
|
import { type AppController } from '@/app.ts';
|
||||||
import { booleanParamSchema } from '@/schema.ts';
|
import { booleanParamSchema } from '@/schema.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
|
||||||
import { hydrateEvents } from '@/storages/hydrate.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 { renderNameRequest } from '@/views/ditto.ts';
|
||||||
|
|
@ -29,10 +28,8 @@ const adminAccountQuerySchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const adminAccountsController: AppController = async (c) => {
|
const adminAccountsController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay, signal, pagination } = c.var;
|
||||||
const store = await Storages.db();
|
|
||||||
const params = c.get('pagination');
|
|
||||||
const { signal } = c.req.raw;
|
|
||||||
const {
|
const {
|
||||||
local,
|
local,
|
||||||
pending,
|
pending,
|
||||||
|
|
@ -50,8 +47,8 @@ const adminAccountsController: AppController = async (c) => {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const orig = await store.query(
|
const orig = await relay.query(
|
||||||
[{ kinds: [30383], authors: [adminPubkey], '#k': ['3036'], '#n': ['pending'], ...params }],
|
[{ kinds: [30383], authors: [adminPubkey], '#k': ['3036'], '#n': ['pending'], ...pagination }],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -61,8 +58,8 @@ const adminAccountsController: AppController = async (c) => {
|
||||||
.filter((id): id is string => !!id),
|
.filter((id): id is string => !!id),
|
||||||
);
|
);
|
||||||
|
|
||||||
const events = await store.query([{ kinds: [3036], ids: [...ids] }])
|
const events = await relay.query([{ kinds: [3036], ids: [...ids] }])
|
||||||
.then((events) => hydrateEvents({ store, events, signal }));
|
.then((events) => hydrateEvents({ relay, events, signal }));
|
||||||
|
|
||||||
const nameRequests = await Promise.all(events.map(renderNameRequest));
|
const nameRequests = await Promise.all(events.map(renderNameRequest));
|
||||||
return paginated(c, orig, nameRequests);
|
return paginated(c, orig, nameRequests);
|
||||||
|
|
@ -88,8 +85,8 @@ const adminAccountsController: AppController = async (c) => {
|
||||||
n.push('moderator');
|
n.push('moderator');
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await store.query(
|
const events = await relay.query(
|
||||||
[{ kinds: [30382], authors: [adminPubkey], '#n': n, ...params }],
|
[{ kinds: [30382], authors: [adminPubkey], '#n': n, ...pagination }],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -99,8 +96,8 @@ const adminAccountsController: AppController = async (c) => {
|
||||||
.filter((pubkey): pubkey is string => !!pubkey),
|
.filter((pubkey): pubkey is string => !!pubkey),
|
||||||
);
|
);
|
||||||
|
|
||||||
const authors = await store.query([{ kinds: [0], authors: [...pubkeys] }])
|
const authors = await relay.query([{ kinds: [0], authors: [...pubkeys] }])
|
||||||
.then((events) => hydrateEvents({ store, events, signal }));
|
.then((events) => hydrateEvents({ relay, events, signal }));
|
||||||
|
|
||||||
const accounts = await Promise.all(
|
const accounts = await Promise.all(
|
||||||
[...pubkeys].map((pubkey) => {
|
[...pubkeys].map((pubkey) => {
|
||||||
|
|
@ -112,14 +109,14 @@ const adminAccountsController: AppController = async (c) => {
|
||||||
return paginated(c, events, accounts);
|
return paginated(c, events, accounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filter: NostrFilter = { kinds: [0], ...params };
|
const filter: NostrFilter = { kinds: [0], ...pagination };
|
||||||
|
|
||||||
if (local) {
|
if (local) {
|
||||||
filter.search = `domain:${conf.url.host}`;
|
filter.search = `domain:${conf.url.host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await store.query([filter], { signal })
|
const events = await relay.query([filter], { signal })
|
||||||
.then((events) => hydrateEvents({ store, events, signal }));
|
.then((events) => hydrateEvents({ relay, events, signal }));
|
||||||
|
|
||||||
const accounts = await Promise.all(events.map(renderAdminAccount));
|
const accounts = await Promise.all(events.map(renderAdminAccount));
|
||||||
return paginated(c, events, accounts);
|
return paginated(c, events, accounts);
|
||||||
|
|
@ -130,9 +127,9 @@ const adminAccountActionSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const adminActionController: AppController = async (c) => {
|
const adminActionController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay } = c.var;
|
||||||
|
|
||||||
const body = await parseBody(c.req.raw);
|
const body = await parseBody(c.req.raw);
|
||||||
const store = await Storages.db();
|
|
||||||
const result = adminAccountActionSchema.safeParse(body);
|
const result = adminAccountActionSchema.safeParse(body);
|
||||||
const authorId = c.req.param('id');
|
const authorId = c.req.param('id');
|
||||||
|
|
||||||
|
|
@ -156,13 +153,13 @@ const adminActionController: AppController = async (c) => {
|
||||||
if (data.type === 'suspend') {
|
if (data.type === 'suspend') {
|
||||||
n.disabled = true;
|
n.disabled = true;
|
||||||
n.suspended = true;
|
n.suspended = true;
|
||||||
store.remove([{ authors: [authorId] }]).catch((e: unknown) => {
|
relay.remove!([{ authors: [authorId] }]).catch((e: unknown) => {
|
||||||
logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) });
|
logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (data.type === 'revoke_name') {
|
if (data.type === 'revoke_name') {
|
||||||
n.revoke_name = true;
|
n.revoke_name = true;
|
||||||
store.remove([{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#p': [authorId] }]).catch(
|
relay.remove!([{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#p': [authorId] }]).catch(
|
||||||
(e: unknown) => {
|
(e: unknown) => {
|
||||||
logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) });
|
logi({ level: 'error', ns: 'ditto.api.admin.account.action', type: data.type, error: errorJson(e) });
|
||||||
},
|
},
|
||||||
|
|
@ -177,9 +174,9 @@ const adminActionController: AppController = async (c) => {
|
||||||
const adminApproveController: AppController = async (c) => {
|
const adminApproveController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf } = c.var;
|
||||||
const eventId = c.req.param('id');
|
const eventId = c.req.param('id');
|
||||||
const store = await Storages.db();
|
const { relay } = c.var;
|
||||||
|
|
||||||
const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]);
|
const [event] = await relay.query([{ kinds: [3036], ids: [eventId] }]);
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
@ -192,7 +189,7 @@ const adminApproveController: AppController = async (c) => {
|
||||||
return c.json({ error: 'Invalid NIP-05' }, 400);
|
return c.json({ error: 'Invalid NIP-05' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [existing] = await store.query([
|
const [existing] = await relay.query([
|
||||||
{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#d': [r.toLowerCase()], limit: 1 },
|
{ kinds: [30360], authors: [await conf.signer.getPublicKey()], '#d': [r.toLowerCase()], limit: 1 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -213,7 +210,7 @@ const adminApproveController: AppController = async (c) => {
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c);
|
await updateEventInfo(eventId, { pending: false, approved: true, rejected: false }, c);
|
||||||
await hydrateEvents({ events: [event], store });
|
await hydrateEvents({ events: [event], relay });
|
||||||
|
|
||||||
const nameRequest = await renderNameRequest(event);
|
const nameRequest = await renderNameRequest(event);
|
||||||
return c.json(nameRequest);
|
return c.json(nameRequest);
|
||||||
|
|
@ -221,15 +218,15 @@ const adminApproveController: AppController = async (c) => {
|
||||||
|
|
||||||
const adminRejectController: AppController = async (c) => {
|
const adminRejectController: AppController = async (c) => {
|
||||||
const eventId = c.req.param('id');
|
const eventId = c.req.param('id');
|
||||||
const store = await Storages.db();
|
const { relay } = c.var;
|
||||||
|
|
||||||
const [event] = await store.query([{ kinds: [3036], ids: [eventId] }]);
|
const [event] = await relay.query([{ kinds: [3036], ids: [eventId] }]);
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateEventInfo(eventId, { pending: false, approved: false, rejected: true }, c);
|
await updateEventInfo(eventId, { pending: false, approved: false, rejected: true }, c);
|
||||||
await hydrateEvents({ events: [event], store });
|
await hydrateEvents({ events: [event], relay });
|
||||||
|
|
||||||
const nameRequest = await renderNameRequest(event);
|
const nameRequest = await renderNameRequest(event);
|
||||||
return c.json(nameRequest);
|
return c.json(nameRequest);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
import { type AppController } from '@/app.ts';
|
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';
|
import { renderStatuses } from '@/views.ts';
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/bookmarks/#get */
|
/** https://docs.joinmastodon.org/methods/bookmarks/#get */
|
||||||
const bookmarksController: AppController = async (c) => {
|
const bookmarksController: AppController = async (c) => {
|
||||||
const store = await Storages.db();
|
const { relay, user, signal } = c.var;
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
|
||||||
const { signal } = c.req.raw;
|
|
||||||
|
|
||||||
const [event10003] = await store.query(
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
|
|
||||||
|
const [event10003] = await relay.query(
|
||||||
[{ kinds: [10003], authors: [pubkey], limit: 1 }],
|
[{ kinds: [10003], authors: [pubkey], limit: 1 }],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -152,9 +152,11 @@ const pointSchema = z.object({
|
||||||
|
|
||||||
/** Verify the captcha solution and sign an event in the database. */
|
/** Verify the captcha solution and sign an event in the database. */
|
||||||
export const captchaVerifyController: AppController = async (c) => {
|
export const captchaVerifyController: AppController = async (c) => {
|
||||||
|
const { user } = c.var;
|
||||||
|
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const result = pointSchema.safeParse(await c.req.json());
|
const result = pointSchema.safeParse(await c.req.json());
|
||||||
const pubkey = await c.get('signer')!.getPublicKey();
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return c.json({ error: 'Invalid input' }, { status: 422 });
|
return c.json({ error: 'Invalid input' }, { status: 422 });
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,32 @@
|
||||||
import { confMw } from '@ditto/api/middleware';
|
import { DittoConf } from '@ditto/conf';
|
||||||
import { Env as HonoEnv, Hono } from '@hono/hono';
|
import { type User } from '@ditto/mastoapi/middleware';
|
||||||
import { NostrSigner, NSecSigner, NStore } from '@nostrify/nostrify';
|
import { DittoApp, DittoMiddleware } from '@ditto/mastoapi/router';
|
||||||
|
import { NSecSigner } from '@nostrify/nostrify';
|
||||||
import { genEvent } from '@nostrify/nostrify/test';
|
import { genEvent } from '@nostrify/nostrify/test';
|
||||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
|
||||||
import { bytesToString, stringToBytes } from '@scure/base';
|
import { bytesToString, stringToBytes } from '@scure/base';
|
||||||
import { stub } from '@std/testing/mock';
|
import { stub } from '@std/testing/mock';
|
||||||
import { assertEquals, assertExists, assertObjectMatch } from '@std/assert';
|
import { assertEquals, assertExists, assertObjectMatch } from '@std/assert';
|
||||||
|
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
import { createTestDB } from '@/test.ts';
|
import { createTestDB } from '@/test.ts';
|
||||||
|
|
||||||
import cashuApp from '@/controllers/api/cashu.ts';
|
import cashuRoute from './cashu.ts';
|
||||||
import { walletSchema } from '@/schema.ts';
|
import { walletSchema } from '@/schema.ts';
|
||||||
|
|
||||||
interface AppEnv extends HonoEnv {
|
|
||||||
Variables: {
|
|
||||||
/** Signer to get the logged-in user's pubkey, relays, and to sign events. */
|
|
||||||
signer: NostrSigner;
|
|
||||||
/** Storage for the user, might filter out unwanted content. */
|
|
||||||
store: NStore;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Deno.test('PUT /wallet must be successful', {
|
Deno.test('PUT /wallet must be successful', {
|
||||||
sanitizeOps: false,
|
sanitizeOps: false,
|
||||||
sanitizeResources: false,
|
sanitizeResources: false,
|
||||||
}, async () => {
|
}, async () => {
|
||||||
using _mock = mockFetch();
|
await using test = await createTestRoute();
|
||||||
await using db = await createTestDB();
|
|
||||||
const store = db.store;
|
|
||||||
|
|
||||||
const sk = generateSecretKey();
|
const { route, signer, sk, relay } = test;
|
||||||
const signer = new NSecSigner(sk);
|
|
||||||
const nostrPrivateKey = bytesToString('hex', sk);
|
const nostrPrivateKey = bytesToString('hex', sk);
|
||||||
|
|
||||||
const app = new Hono<AppEnv>().use(
|
const response = await route.request('/wallet', {
|
||||||
async (c, next) => {
|
|
||||||
c.set('signer', signer);
|
|
||||||
await next();
|
|
||||||
},
|
|
||||||
async (c, next) => {
|
|
||||||
c.set('store', store);
|
|
||||||
await next();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.use(confMw(new Map()));
|
|
||||||
app.route('/', cashuApp);
|
|
||||||
|
|
||||||
const response = await app.request('/wallet', {
|
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: [['content-type', 'application/json']],
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
mints: [
|
mints: [
|
||||||
'https://houston.mint.com',
|
'https://houston.mint.com',
|
||||||
|
|
@ -63,7 +40,7 @@ Deno.test('PUT /wallet must be successful', {
|
||||||
|
|
||||||
const pubkey = await signer.getPublicKey();
|
const pubkey = await signer.getPublicKey();
|
||||||
|
|
||||||
const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }]);
|
const [wallet] = await relay.query([{ authors: [pubkey], kinds: [17375] }]);
|
||||||
|
|
||||||
assertExists(wallet);
|
assertExists(wallet);
|
||||||
assertEquals(wallet.kind, 17375);
|
assertEquals(wallet.kind, 17375);
|
||||||
|
|
@ -90,7 +67,7 @@ Deno.test('PUT /wallet must be successful', {
|
||||||
]);
|
]);
|
||||||
assertEquals(data.balance, 0);
|
assertEquals(data.balance, 0);
|
||||||
|
|
||||||
const [nutzap_info] = await store.query([{ authors: [pubkey], kinds: [10019] }]);
|
const [nutzap_info] = await relay.query([{ authors: [pubkey], kinds: [10019] }]);
|
||||||
|
|
||||||
assertExists(nutzap_info);
|
assertExists(nutzap_info);
|
||||||
assertEquals(nutzap_info.kind, 10019);
|
assertEquals(nutzap_info.kind, 10019);
|
||||||
|
|
@ -105,30 +82,14 @@ Deno.test('PUT /wallet must be successful', {
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async () => {
|
Deno.test('PUT /wallet must NOT be successful: wrong request body/schema', async () => {
|
||||||
using _mock = mockFetch();
|
await using test = await createTestRoute();
|
||||||
await using db = await createTestDB();
|
const { route } = test;
|
||||||
const store = db.store;
|
|
||||||
|
|
||||||
const sk = generateSecretKey();
|
const response = await route.request('/wallet', {
|
||||||
const signer = new NSecSigner(sk);
|
|
||||||
|
|
||||||
const app = new Hono<AppEnv>().use(
|
|
||||||
async (c, next) => {
|
|
||||||
c.set('signer', signer);
|
|
||||||
await next();
|
|
||||||
},
|
|
||||||
async (c, next) => {
|
|
||||||
c.set('store', store);
|
|
||||||
await next();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.use(confMw(new Map()));
|
|
||||||
app.route('/', cashuApp);
|
|
||||||
|
|
||||||
const response = await app.request('/wallet', {
|
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: [['content-type', 'application/json']],
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
mints: [], // no mints should throw an error
|
mints: [], // no mints should throw an error
|
||||||
}),
|
}),
|
||||||
|
|
@ -144,32 +105,17 @@ Deno.test('PUT /wallet must NOT be successful: wallet already exists', {
|
||||||
sanitizeOps: false,
|
sanitizeOps: false,
|
||||||
sanitizeResources: false,
|
sanitizeResources: false,
|
||||||
}, async () => {
|
}, async () => {
|
||||||
using _mock = mockFetch();
|
await using test = await createTestRoute();
|
||||||
await using db = await createTestDB();
|
const { route, sk, relay } = test;
|
||||||
const store = db.store;
|
|
||||||
|
|
||||||
const sk = generateSecretKey();
|
await relay.event(genEvent({ kind: 17375 }, sk));
|
||||||
const signer = new NSecSigner(sk);
|
|
||||||
|
|
||||||
const app = new Hono<AppEnv>().use(
|
const response = await route.request('/wallet', {
|
||||||
async (c, next) => {
|
|
||||||
c.set('signer', signer);
|
|
||||||
await next();
|
|
||||||
},
|
|
||||||
async (c, next) => {
|
|
||||||
c.set('store', store);
|
|
||||||
await next();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.use(confMw(new Map()));
|
|
||||||
app.route('/', cashuApp);
|
|
||||||
|
|
||||||
await db.store.event(genEvent({ kind: 17375 }, sk));
|
|
||||||
|
|
||||||
const response = await app.request('/wallet', {
|
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: [['content-type', 'application/json']],
|
headers: {
|
||||||
|
'authorization': `Bearer ${nip19.nsecEncode(sk)}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
mints: ['https://mint.heart.com'],
|
mints: ['https://mint.heart.com'],
|
||||||
}),
|
}),
|
||||||
|
|
@ -185,32 +131,15 @@ Deno.test('GET /wallet must be successful', {
|
||||||
sanitizeOps: false,
|
sanitizeOps: false,
|
||||||
sanitizeResources: false,
|
sanitizeResources: false,
|
||||||
}, async () => {
|
}, async () => {
|
||||||
using _mock = mockFetch();
|
await using test = await createTestRoute();
|
||||||
await using db = await createTestDB();
|
const { route, sk, relay, signer } = test;
|
||||||
const store = db.store;
|
|
||||||
|
|
||||||
const sk = generateSecretKey();
|
|
||||||
const signer = new NSecSigner(sk);
|
|
||||||
const pubkey = await signer.getPublicKey();
|
const pubkey = await signer.getPublicKey();
|
||||||
const privkey = bytesToString('hex', sk);
|
const privkey = bytesToString('hex', sk);
|
||||||
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
||||||
|
|
||||||
const app = new Hono<AppEnv>().use(
|
|
||||||
async (c, next) => {
|
|
||||||
c.set('signer', signer);
|
|
||||||
await next();
|
|
||||||
},
|
|
||||||
async (c, next) => {
|
|
||||||
c.set('store', store);
|
|
||||||
await next();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.use(confMw(new Map()));
|
|
||||||
app.route('/', cashuApp);
|
|
||||||
|
|
||||||
// Wallet
|
// Wallet
|
||||||
await db.store.event(genEvent({
|
await relay.event(genEvent({
|
||||||
kind: 17375,
|
kind: 17375,
|
||||||
content: await signer.nip44.encrypt(
|
content: await signer.nip44.encrypt(
|
||||||
pubkey,
|
pubkey,
|
||||||
|
|
@ -222,7 +151,7 @@ Deno.test('GET /wallet must be successful', {
|
||||||
}, sk));
|
}, sk));
|
||||||
|
|
||||||
// Nutzap information
|
// Nutzap information
|
||||||
await db.store.event(genEvent({
|
await relay.event(genEvent({
|
||||||
kind: 10019,
|
kind: 10019,
|
||||||
tags: [
|
tags: [
|
||||||
['pubkey', p2pk],
|
['pubkey', p2pk],
|
||||||
|
|
@ -231,7 +160,7 @@ Deno.test('GET /wallet must be successful', {
|
||||||
}, sk));
|
}, sk));
|
||||||
|
|
||||||
// Unspent proofs
|
// Unspent proofs
|
||||||
await db.store.event(genEvent({
|
await relay.event(genEvent({
|
||||||
kind: 7375,
|
kind: 7375,
|
||||||
content: await signer.nip44.encrypt(
|
content: await signer.nip44.encrypt(
|
||||||
pubkey,
|
pubkey,
|
||||||
|
|
@ -272,7 +201,7 @@ Deno.test('GET /wallet must be successful', {
|
||||||
// Nutzap
|
// Nutzap
|
||||||
const senderSk = generateSecretKey();
|
const senderSk = generateSecretKey();
|
||||||
|
|
||||||
await db.store.event(genEvent({
|
await relay.event(genEvent({
|
||||||
kind: 9321,
|
kind: 9321,
|
||||||
content: 'Nice post!',
|
content: 'Nice post!',
|
||||||
tags: [
|
tags: [
|
||||||
|
|
@ -285,7 +214,7 @@ Deno.test('GET /wallet must be successful', {
|
||||||
],
|
],
|
||||||
}, senderSk));
|
}, senderSk));
|
||||||
|
|
||||||
const response = await app.request('/wallet', {
|
const response = await route.request('/wallet', {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -301,21 +230,10 @@ Deno.test('GET /wallet must be successful', {
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('GET /mints must be successful', async () => {
|
Deno.test('GET /mints must be successful', async () => {
|
||||||
using _mock = mockFetch();
|
await using test = await createTestRoute();
|
||||||
await using db = await createTestDB();
|
const { route } = test;
|
||||||
const store = db.store;
|
|
||||||
|
|
||||||
const app = new Hono<AppEnv>().use(
|
const response = await route.request('/mints', {
|
||||||
async (c, next) => {
|
|
||||||
c.set('store', store);
|
|
||||||
await next();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
app.use(confMw(new Map()));
|
|
||||||
app.route('/', cashuApp);
|
|
||||||
|
|
||||||
const response = await app.request('/mints', {
|
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -325,13 +243,42 @@ Deno.test('GET /mints must be successful', async () => {
|
||||||
assertEquals(body, { mints: [] });
|
assertEquals(body, { mints: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockFetch() {
|
async function createTestRoute() {
|
||||||
|
const conf = new DittoConf(new Map());
|
||||||
|
|
||||||
|
const db = await createTestDB();
|
||||||
|
const relay = db.store;
|
||||||
|
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const signer = new NSecSigner(sk);
|
||||||
|
|
||||||
|
const route = new DittoApp({ db, relay, conf });
|
||||||
|
|
||||||
|
route.use(testUserMiddleware({ signer, relay }));
|
||||||
|
route.route('/', cashuRoute);
|
||||||
|
|
||||||
const mock = stub(globalThis, 'fetch', () => {
|
const mock = stub(globalThis, 'fetch', () => {
|
||||||
return Promise.resolve(new Response());
|
return Promise.resolve(new Response());
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[Symbol.dispose]: () => {
|
route,
|
||||||
|
db,
|
||||||
|
conf,
|
||||||
|
sk,
|
||||||
|
signer,
|
||||||
|
relay,
|
||||||
|
[Symbol.asyncDispose]: async () => {
|
||||||
mock.restore();
|
mock.restore();
|
||||||
|
await db[Symbol.asyncDispose]();
|
||||||
|
await relay[Symbol.asyncDispose]();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function testUserMiddleware(user: User<NSecSigner>): DittoMiddleware<{ user: User<NSecSigner> }> {
|
||||||
|
return async (c, next) => {
|
||||||
|
c.set('user', user);
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,11 @@
|
||||||
import { Proof } from '@cashu/cashu-ts';
|
import { Proof } from '@cashu/cashu-ts';
|
||||||
import { confRequiredMw } from '@ditto/api/middleware';
|
import { userMiddleware } from '@ditto/mastoapi/middleware';
|
||||||
import { Hono } from '@hono/hono';
|
import { DittoRoute } from '@ditto/mastoapi/router';
|
||||||
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
import { generateSecretKey, getPublicKey } from 'nostr-tools';
|
||||||
import { bytesToString, stringToBytes } from '@scure/base';
|
import { bytesToString, stringToBytes } from '@scure/base';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { 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 { walletSchema } from '@/schema.ts';
|
||||||
import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts';
|
import { swapNutzapsMiddleware } from '@/middleware/swapNutzapsMiddleware.ts';
|
||||||
import { isNostrId } from '@/utils.ts';
|
import { isNostrId } from '@/utils.ts';
|
||||||
|
|
@ -16,7 +14,7 @@ import { errorJson } from '@/utils/log.ts';
|
||||||
|
|
||||||
type Wallet = z.infer<typeof walletSchema>;
|
type Wallet = z.infer<typeof walletSchema>;
|
||||||
|
|
||||||
const app = new Hono().use('*', confRequiredMw, requireStore);
|
const route = new DittoRoute();
|
||||||
|
|
||||||
// app.delete('/wallet') -> 204
|
// app.delete('/wallet') -> 204
|
||||||
|
|
||||||
|
|
@ -44,12 +42,11 @@ const createCashuWalletAndNutzapInfoSchema = z.object({
|
||||||
* https://github.com/nostr-protocol/nips/blob/master/60.md
|
* https://github.com/nostr-protocol/nips/blob/master/60.md
|
||||||
* https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event
|
* https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-informational-event
|
||||||
*/
|
*/
|
||||||
app.put('/wallet', requireNip44Signer, async (c) => {
|
route.put('/wallet', userMiddleware({ enc: 'nip44' }), async (c) => {
|
||||||
const { conf, signer } = c.var;
|
const { conf, user, relay, signal } = c.var;
|
||||||
const store = c.get('store');
|
|
||||||
const pubkey = await signer.getPublicKey();
|
const pubkey = await user.signer.getPublicKey();
|
||||||
const body = await parseBody(c.req.raw);
|
const body = await parseBody(c.req.raw);
|
||||||
const { signal } = c.req.raw;
|
|
||||||
const result = createCashuWalletAndNutzapInfoSchema.safeParse(body);
|
const result = createCashuWalletAndNutzapInfoSchema.safeParse(body);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
@ -58,7 +55,7 @@ app.put('/wallet', requireNip44Signer, async (c) => {
|
||||||
|
|
||||||
const { mints } = result.data;
|
const { mints } = result.data;
|
||||||
|
|
||||||
const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
const [event] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
||||||
if (event) {
|
if (event) {
|
||||||
return c.json({ error: 'You already have a wallet 😏' }, 400);
|
return c.json({ error: 'You already have a wallet 😏' }, 400);
|
||||||
}
|
}
|
||||||
|
|
@ -75,12 +72,13 @@ app.put('/wallet', requireNip44Signer, async (c) => {
|
||||||
walletContentTags.push(['mint', mint]);
|
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
|
// Wallet
|
||||||
await createEvent({
|
await createEvent({
|
||||||
kind: 17375,
|
kind: 17375,
|
||||||
content: encryptedWalletContentTags,
|
content: encryptedWalletContentTags,
|
||||||
|
// @ts-ignore kill me
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
// Nutzap information
|
// Nutzap information
|
||||||
|
|
@ -91,6 +89,7 @@ app.put('/wallet', requireNip44Signer, async (c) => {
|
||||||
['relay', conf.relay], // TODO: add more relays once things get more stable
|
['relay', conf.relay], // TODO: add more relays once things get more stable
|
||||||
['pubkey', p2pk],
|
['pubkey', p2pk],
|
||||||
],
|
],
|
||||||
|
// @ts-ignore kill me
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
// TODO: hydrate wallet and add a 'balance' field when a 'renderWallet' view function is created
|
// TODO: hydrate wallet and add a 'balance' field when a 'renderWallet' view function is created
|
||||||
|
|
@ -105,18 +104,17 @@ app.put('/wallet', requireNip44Signer, async (c) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Gets a wallet, if it exists. */
|
/** Gets a wallet, if it exists. */
|
||||||
app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => {
|
route.get('/wallet', userMiddleware({ enc: 'nip44' }), swapNutzapsMiddleware, async (c) => {
|
||||||
const { conf, signer } = c.var;
|
const { conf, relay, user, signal } = c.var;
|
||||||
const store = c.get('store');
|
|
||||||
const pubkey = await signer.getPublicKey();
|
|
||||||
const { signal } = c.req.raw;
|
|
||||||
|
|
||||||
const [event] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
const pubkey = await user.signer.getPublicKey();
|
||||||
|
|
||||||
|
const [event] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'Wallet not found' }, 404);
|
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];
|
const privkey = decryptedContent.find(([value]) => value === 'privkey')?.[1];
|
||||||
if (!privkey || !isNostrId(privkey)) {
|
if (!privkey || !isNostrId(privkey)) {
|
||||||
|
|
@ -128,11 +126,11 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => {
|
||||||
let balance = 0;
|
let balance = 0;
|
||||||
const mints: string[] = [];
|
const mints: string[] = [];
|
||||||
|
|
||||||
const tokens = await store.query([{ authors: [pubkey], kinds: [7375] }], { signal });
|
const tokens = await relay.query([{ authors: [pubkey], kinds: [7375] }], { signal });
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
try {
|
try {
|
||||||
const decryptedContent: { mint: string; proofs: Proof[] } = JSON.parse(
|
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)) {
|
if (!mints.includes(decryptedContent.mint)) {
|
||||||
|
|
@ -159,7 +157,7 @@ app.get('/wallet', requireNip44Signer, swapNutzapsMiddleware, async (c) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Get mints set by the CASHU_MINTS environment variable. */
|
/** Get mints set by the CASHU_MINTS environment variable. */
|
||||||
app.get('/mints', (c) => {
|
route.get('/mints', (c) => {
|
||||||
const { conf } = c.var;
|
const { conf } = c.var;
|
||||||
|
|
||||||
// TODO: Return full Mint information: https://github.com/cashubtc/nuts/blob/main/06.md
|
// TODO: Return full Mint information: https://github.com/cashubtc/nuts/blob/main/06.md
|
||||||
|
|
@ -168,4 +166,4 @@ app.get('/mints', (c) => {
|
||||||
return c.json({ mints }, 200);
|
return c.json({ mints }, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default app;
|
export default route;
|
||||||
|
|
|
||||||
|
|
@ -28,10 +28,9 @@ const relaySchema = z.object({
|
||||||
type RelayEntity = z.infer<typeof relaySchema>;
|
type RelayEntity = z.infer<typeof relaySchema>;
|
||||||
|
|
||||||
export const adminRelaysController: AppController = async (c) => {
|
export const adminRelaysController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay } = c.var;
|
||||||
const store = await Storages.db();
|
|
||||||
|
|
||||||
const [event] = await store.query([
|
const [event] = await relay.query([
|
||||||
{ kinds: [10002], authors: [await conf.signer.getPublicKey()], limit: 1 },
|
{ kinds: [10002], authors: [await conf.signer.getPublicKey()], limit: 1 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -43,8 +42,7 @@ export const adminRelaysController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminSetRelaysController: AppController = async (c) => {
|
export const adminSetRelaysController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay } = c.var;
|
||||||
const store = await Storages.db();
|
|
||||||
const relays = relaySchema.array().parse(await c.req.json());
|
const relays = relaySchema.array().parse(await c.req.json());
|
||||||
|
|
||||||
const event = await conf.signer.signEvent({
|
const event = await conf.signer.signEvent({
|
||||||
|
|
@ -54,7 +52,7 @@ export const adminSetRelaysController: AppController = async (c) => {
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
});
|
});
|
||||||
|
|
||||||
await store.event(event);
|
await relay.event(event);
|
||||||
|
|
||||||
return c.json(renderRelays(event));
|
return c.json(renderRelays(event));
|
||||||
};
|
};
|
||||||
|
|
@ -79,11 +77,9 @@ const nameRequestSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const nameRequestController: AppController = async (c) => {
|
export const nameRequestController: AppController = async (c) => {
|
||||||
const store = await Storages.db();
|
const { conf, relay, user } = c.var;
|
||||||
const signer = c.get('signer')!;
|
|
||||||
const pubkey = await signer.getPublicKey();
|
|
||||||
const { conf } = c.var;
|
|
||||||
|
|
||||||
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const result = nameRequestSchema.safeParse(await c.req.json());
|
const result = nameRequestSchema.safeParse(await c.req.json());
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
@ -92,7 +88,7 @@ export const nameRequestController: AppController = async (c) => {
|
||||||
|
|
||||||
const { name, reason } = result.data;
|
const { name, reason } = result.data;
|
||||||
|
|
||||||
const [existing] = await store.query([{ kinds: [3036], authors: [pubkey], '#r': [name.toLowerCase()], limit: 1 }]);
|
const [existing] = await relay.query([{ kinds: [3036], authors: [pubkey], '#r': [name.toLowerCase()], limit: 1 }]);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return c.json({ error: 'Name request already exists' }, 400);
|
return c.json({ error: 'Name request already exists' }, 400);
|
||||||
}
|
}
|
||||||
|
|
@ -114,7 +110,7 @@ export const nameRequestController: AppController = async (c) => {
|
||||||
],
|
],
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
await hydrateEvents({ events: [event], store: await Storages.db() });
|
await hydrateEvents({ events: [event], relay });
|
||||||
|
|
||||||
const nameRequest = await renderNameRequest(event);
|
const nameRequest = await renderNameRequest(event);
|
||||||
return c.json(nameRequest);
|
return c.json(nameRequest);
|
||||||
|
|
@ -126,10 +122,8 @@ const nameRequestsSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const nameRequestsController: AppController = async (c) => {
|
export const nameRequestsController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay, user, signal } = c.var;
|
||||||
const store = await Storages.db();
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const signer = c.get('signer')!;
|
|
||||||
const pubkey = await signer.getPublicKey();
|
|
||||||
|
|
||||||
const params = c.get('pagination');
|
const params = c.get('pagination');
|
||||||
const { approved, rejected } = nameRequestsSchema.parse(c.req.query());
|
const { approved, rejected } = nameRequestsSchema.parse(c.req.query());
|
||||||
|
|
@ -149,7 +143,7 @@ export const nameRequestsController: AppController = async (c) => {
|
||||||
filter['#n'] = ['rejected'];
|
filter['#n'] = ['rejected'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const orig = await store.query([filter]);
|
const orig = await relay.query([filter]);
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|
||||||
for (const event of orig) {
|
for (const event of orig) {
|
||||||
|
|
@ -163,8 +157,8 @@ export const nameRequestsController: AppController = async (c) => {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await store.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }])
|
const events = await relay.query([{ kinds: [3036], ids: [...ids], authors: [pubkey] }])
|
||||||
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }));
|
.then((events) => hydrateEvents({ relay, events: events, signal }));
|
||||||
|
|
||||||
const nameRequests = await Promise.all(
|
const nameRequests = await Promise.all(
|
||||||
events.map((event) => renderNameRequest(event)),
|
events.map((event) => renderNameRequest(event)),
|
||||||
|
|
@ -182,10 +176,9 @@ const zapSplitSchema = z.record(
|
||||||
);
|
);
|
||||||
|
|
||||||
export const updateZapSplitsController: AppController = async (c) => {
|
export const updateZapSplitsController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay } = c.var;
|
||||||
const body = await parseBody(c.req.raw);
|
const body = await parseBody(c.req.raw);
|
||||||
const result = zapSplitSchema.safeParse(body);
|
const result = zapSplitSchema.safeParse(body);
|
||||||
const store = c.get('store');
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return c.json({ error: result.error }, 400);
|
return c.json({ error: result.error }, 400);
|
||||||
|
|
@ -193,7 +186,7 @@ export const updateZapSplitsController: AppController = async (c) => {
|
||||||
|
|
||||||
const adminPubkey = await conf.signer.getPublicKey();
|
const adminPubkey = await conf.signer.getPublicKey();
|
||||||
|
|
||||||
const dittoZapSplit = await getZapSplits(store, adminPubkey);
|
const dittoZapSplit = await getZapSplits(relay, adminPubkey);
|
||||||
if (!dittoZapSplit) {
|
if (!dittoZapSplit) {
|
||||||
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
|
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
|
||||||
}
|
}
|
||||||
|
|
@ -220,10 +213,9 @@ export const updateZapSplitsController: AppController = async (c) => {
|
||||||
const deleteZapSplitSchema = z.array(n.id()).min(1);
|
const deleteZapSplitSchema = z.array(n.id()).min(1);
|
||||||
|
|
||||||
export const deleteZapSplitsController: AppController = async (c) => {
|
export const deleteZapSplitsController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay } = c.var;
|
||||||
const body = await parseBody(c.req.raw);
|
const body = await parseBody(c.req.raw);
|
||||||
const result = deleteZapSplitSchema.safeParse(body);
|
const result = deleteZapSplitSchema.safeParse(body);
|
||||||
const store = c.get('store');
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return c.json({ error: result.error }, 400);
|
return c.json({ error: result.error }, 400);
|
||||||
|
|
@ -231,7 +223,7 @@ export const deleteZapSplitsController: AppController = async (c) => {
|
||||||
|
|
||||||
const adminPubkey = await conf.signer.getPublicKey();
|
const adminPubkey = await conf.signer.getPublicKey();
|
||||||
|
|
||||||
const dittoZapSplit = await getZapSplits(store, adminPubkey);
|
const dittoZapSplit = await getZapSplits(relay, adminPubkey);
|
||||||
if (!dittoZapSplit) {
|
if (!dittoZapSplit) {
|
||||||
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
|
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
|
||||||
}
|
}
|
||||||
|
|
@ -251,10 +243,9 @@ export const deleteZapSplitsController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getZapSplitsController: AppController = async (c) => {
|
export const getZapSplitsController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay } = c.var;
|
||||||
const store = c.get('store');
|
|
||||||
|
|
||||||
const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(store, await conf.signer.getPublicKey()) ?? {};
|
const dittoZapSplit: DittoZapSplits | undefined = await getZapSplits(relay, await conf.signer.getPublicKey()) ?? {};
|
||||||
if (!dittoZapSplit) {
|
if (!dittoZapSplit) {
|
||||||
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
|
return c.json({ error: 'Zap split not activated, restart the server.' }, 404);
|
||||||
}
|
}
|
||||||
|
|
@ -277,11 +268,11 @@ export const getZapSplitsController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const statusZapSplitsController: AppController = async (c) => {
|
export const statusZapSplitsController: AppController = async (c) => {
|
||||||
const store = c.get('store');
|
const { relay, 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 });
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const [event] = await relay.query([{ kinds: [1, 20], ids: [id], limit: 1 }], { signal });
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'Event not found' }, 404);
|
return c.json({ error: 'Event not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
@ -290,8 +281,8 @@ export const statusZapSplitsController: AppController = async (c) => {
|
||||||
|
|
||||||
const pubkeys = zapsTag.map((name) => name[1]);
|
const pubkeys = zapsTag.map((name) => name[1]);
|
||||||
|
|
||||||
const users = await store.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal });
|
const users = await relay.query([{ authors: pubkeys, kinds: [0], limit: pubkeys.length }], { signal });
|
||||||
await hydrateEvents({ events: users, store, signal });
|
await hydrateEvents({ events: users, relay, signal });
|
||||||
|
|
||||||
const zapSplits = (await Promise.all(pubkeys.map((pubkey) => {
|
const zapSplits = (await Promise.all(pubkeys.map((pubkey) => {
|
||||||
const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author;
|
const author = (users.find((event) => event.pubkey === pubkey) as DittoEvent | undefined)?.author;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@ interface Marker {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const markersController: AppController = async (c) => {
|
export const markersController: AppController = async (c) => {
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
const { user } = c.var;
|
||||||
|
|
||||||
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const timelines = c.req.queries('timeline[]') ?? [];
|
const timelines = c.req.queries('timeline[]') ?? [];
|
||||||
|
|
||||||
const results = await kv.getMany<Marker[]>(
|
const results = await kv.getMany<Marker[]>(
|
||||||
|
|
@ -37,7 +39,9 @@ const markerDataSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const updateMarkersController: AppController = async (c) => {
|
export const updateMarkersController: AppController = async (c) => {
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
const { user } = c.var;
|
||||||
|
|
||||||
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const record = z.record(z.enum(['home', 'notifications']), markerDataSchema).parse(await parseBody(c.req.raw));
|
const record = z.record(z.enum(['home', 'notifications']), markerDataSchema).parse(await parseBody(c.req.raw));
|
||||||
const timelines = Object.keys(record) as Timeline[];
|
const timelines = Object.keys(record) as Timeline[];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,10 @@ const mediaUpdateSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaController: AppController = async (c) => {
|
const mediaController: AppController = async (c) => {
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
const { user, signal } = c.var;
|
||||||
|
|
||||||
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const result = mediaBodySchema.safeParse(await parseBody(c.req.raw));
|
const result = mediaBodySchema.safeParse(await parseBody(c.req.raw));
|
||||||
const { signal } = c.req.raw;
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return c.json({ error: 'Bad request.', schema: result.error }, 422);
|
return c.json({ error: 'Bad request.', schema: result.error }, 422);
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,14 @@
|
||||||
import { type AppController } from '@/app.ts';
|
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';
|
import { renderAccounts } from '@/views.ts';
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/mutes/#get */
|
/** https://docs.joinmastodon.org/methods/mutes/#get */
|
||||||
const mutesController: AppController = async (c) => {
|
const mutesController: AppController = async (c) => {
|
||||||
const store = await Storages.db();
|
const { relay, user, signal } = c.var;
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
|
||||||
const { signal } = c.req.raw;
|
|
||||||
|
|
||||||
const [event10000] = await store.query(
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
|
|
||||||
|
const [event10000] = await relay.query(
|
||||||
[{ kinds: [10000], authors: [pubkey], limit: 1 }],
|
[{ kinds: [10000], authors: [pubkey], limit: 1 }],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,9 @@ const notificationsSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const notificationsController: AppController = async (c) => {
|
const notificationsController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, user } = c.var;
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
|
||||||
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const params = c.get('pagination');
|
const params = c.get('pagination');
|
||||||
|
|
||||||
const types = notificationTypes
|
const types = notificationTypes
|
||||||
|
|
@ -75,20 +76,21 @@ const notificationsController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const notificationController: AppController = async (c) => {
|
const notificationController: AppController = async (c) => {
|
||||||
|
const { relay, user } = c.var;
|
||||||
|
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const store = c.get('store');
|
|
||||||
|
|
||||||
// Remove the timestamp from the ID.
|
// Remove the timestamp from the ID.
|
||||||
const eventId = id.replace(/^\d+-/, '');
|
const eventId = id.replace(/^\d+-/, '');
|
||||||
|
|
||||||
const [event] = await store.query([{ ids: [eventId] }]);
|
const [event] = await relay.query([{ ids: [eventId] }]);
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'Event not found' }, { status: 404 });
|
return c.json({ error: 'Event not found' }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
await hydrateEvents({ events: [event], store });
|
await hydrateEvents({ events: [event], relay });
|
||||||
|
|
||||||
const notification = await renderNotification(event, { viewerPubkey: pubkey });
|
const notification = await renderNotification(event, { viewerPubkey: pubkey });
|
||||||
|
|
||||||
|
|
@ -105,16 +107,16 @@ async function renderNotifications(
|
||||||
params: DittoPagination,
|
params: DittoPagination,
|
||||||
c: AppContext,
|
c: AppContext,
|
||||||
) {
|
) {
|
||||||
const { conf } = c.var;
|
const { conf, user, signal } = c.var;
|
||||||
const store = c.get('store');
|
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
const relay = user!.relay;
|
||||||
const { signal } = c.req.raw;
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const opts = { signal, limit: params.limit, timeout: conf.db.timeouts.timelines };
|
const opts = { signal, limit: params.limit, timeout: conf.db.timeouts.timelines };
|
||||||
|
|
||||||
const events = await store
|
const events = await relay
|
||||||
.query(filters, opts)
|
.query(filters, opts)
|
||||||
.then((events) => events.filter((event) => event.pubkey !== pubkey))
|
.then((events) => events.filter((event) => event.pubkey !== pubkey))
|
||||||
.then((events) => hydrateEvents({ events, store, signal }));
|
.then((events) => hydrateEvents({ events, relay, signal }));
|
||||||
|
|
||||||
if (!events.length) {
|
if (!events.length) {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { type AppController } from '@/app.ts';
|
import { type AppController } from '@/app.ts';
|
||||||
import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts';
|
import { configSchema, elixirTupleSchema } from '@/schemas/pleroma-api.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
|
||||||
import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts';
|
import { createAdminEvent, updateAdminEvent, updateUser } from '@/utils/api.ts';
|
||||||
import { lookupPubkey } from '@/utils/lookup.ts';
|
import { lookupPubkey } from '@/utils/lookup.ts';
|
||||||
import { getPleromaConfigs } from '@/utils/pleroma.ts';
|
import { getPleromaConfigs } from '@/utils/pleroma.ts';
|
||||||
|
|
||||||
const frontendConfigController: AppController = async (c) => {
|
const frontendConfigController: AppController = async (c) => {
|
||||||
const store = await Storages.db();
|
const { relay, signal } = c.var;
|
||||||
const configDB = await getPleromaConfigs(store, c.req.raw.signal);
|
|
||||||
|
const configDB = await getPleromaConfigs(relay, signal);
|
||||||
const frontendConfig = configDB.get(':pleroma', ':frontend_configurations');
|
const frontendConfig = configDB.get(':pleroma', ':frontend_configurations');
|
||||||
|
|
||||||
if (frontendConfig) {
|
if (frontendConfig) {
|
||||||
|
|
@ -25,17 +25,17 @@ const frontendConfigController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const configController: AppController = async (c) => {
|
const configController: AppController = async (c) => {
|
||||||
const store = await Storages.db();
|
const { relay, signal } = c.var;
|
||||||
const configs = await getPleromaConfigs(store, c.req.raw.signal);
|
|
||||||
|
const configs = await getPleromaConfigs(relay, signal);
|
||||||
return c.json({ configs, need_reboot: false });
|
return c.json({ configs, need_reboot: false });
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Pleroma admin config controller. */
|
/** Pleroma admin config controller. */
|
||||||
const updateConfigController: AppController = async (c) => {
|
const updateConfigController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay, signal } = c.var;
|
||||||
|
|
||||||
const store = await Storages.db();
|
const configs = await getPleromaConfigs(relay, signal);
|
||||||
const configs = await getPleromaConfigs(store, c.req.raw.signal);
|
|
||||||
const { configs: newConfigs } = z.object({ configs: z.array(configSchema) }).parse(await c.req.json());
|
const { configs: newConfigs } = z.object({ configs: z.array(configSchema) }).parse(await c.req.json());
|
||||||
|
|
||||||
configs.merge(newConfigs);
|
configs.merge(newConfigs);
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ const pushSubscribeSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const pushSubscribeController: AppController = async (c) => {
|
export const pushSubscribeController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, user } = c.var;
|
||||||
const vapidPublicKey = await conf.vapidPublicKey;
|
const vapidPublicKey = await conf.vapidPublicKey;
|
||||||
|
|
||||||
if (!vapidPublicKey) {
|
if (!vapidPublicKey) {
|
||||||
|
|
@ -52,7 +52,7 @@ export const pushSubscribeController: AppController = async (c) => {
|
||||||
const accessToken = getAccessToken(c.req.raw);
|
const accessToken = getAccessToken(c.req.raw);
|
||||||
|
|
||||||
const kysely = await Storages.kysely();
|
const kysely = await Storages.kysely();
|
||||||
const signer = c.get('signer')!;
|
const signer = user!.signer;
|
||||||
|
|
||||||
const result = pushSubscribeSchema.safeParse(await parseBody(c.req.raw));
|
const result = pushSubscribeSchema.safeParse(await parseBody(c.req.raw));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { AppController } from '@/app.ts';
|
import { AppController } from '@/app.ts';
|
||||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
|
||||||
import { createEvent } from '@/utils/api.ts';
|
import { createEvent } from '@/utils/api.ts';
|
||||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
|
|
@ -11,16 +10,15 @@ import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
* https://docs.pleroma.social/backend/development/API/pleroma_api/#put-apiv1pleromastatusesidreactionsemoji
|
* https://docs.pleroma.social/backend/development/API/pleroma_api/#put-apiv1pleromastatusesidreactionsemoji
|
||||||
*/
|
*/
|
||||||
const reactionController: AppController = async (c) => {
|
const reactionController: AppController = async (c) => {
|
||||||
|
const { relay, user } = c.var;
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const emoji = c.req.param('emoji');
|
const emoji = c.req.param('emoji');
|
||||||
const signer = c.get('signer')!;
|
|
||||||
|
|
||||||
if (!/^\p{RGI_Emoji}$/v.test(emoji)) {
|
if (!/^\p{RGI_Emoji}$/v.test(emoji)) {
|
||||||
return c.json({ error: 'Invalid emoji' }, 400);
|
return c.json({ error: 'Invalid emoji' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = await Storages.db();
|
const [event] = await relay.query([{ kinds: [1, 20], ids: [id], limit: 1 }]);
|
||||||
const [event] = await store.query([{ kinds: [1, 20], ids: [id], limit: 1 }]);
|
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'Status not found' }, 404);
|
return c.json({ error: 'Status not found' }, 404);
|
||||||
|
|
@ -33,9 +31,9 @@ const reactionController: AppController = async (c) => {
|
||||||
tags: [['e', id], ['p', event.pubkey]],
|
tags: [['e', id], ['p', event.pubkey]],
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
await hydrateEvents({ events: [event], store });
|
await hydrateEvents({ events: [event], relay });
|
||||||
|
|
||||||
const status = await renderStatus(event, { viewerPubkey: await signer.getPublicKey() });
|
const status = await renderStatus(event, { viewerPubkey: await user!.signer.getPublicKey() });
|
||||||
|
|
||||||
return c.json(status);
|
return c.json(status);
|
||||||
};
|
};
|
||||||
|
|
@ -45,17 +43,17 @@ const reactionController: AppController = async (c) => {
|
||||||
* https://docs.pleroma.social/backend/development/API/pleroma_api/#delete-apiv1pleromastatusesidreactionsemoji
|
* https://docs.pleroma.social/backend/development/API/pleroma_api/#delete-apiv1pleromastatusesidreactionsemoji
|
||||||
*/
|
*/
|
||||||
const deleteReactionController: AppController = async (c) => {
|
const deleteReactionController: AppController = async (c) => {
|
||||||
|
const { relay, user } = c.var;
|
||||||
|
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const emoji = c.req.param('emoji');
|
const emoji = c.req.param('emoji');
|
||||||
const signer = c.get('signer')!;
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const pubkey = await signer.getPublicKey();
|
|
||||||
const store = await Storages.db();
|
|
||||||
|
|
||||||
if (!/^\p{RGI_Emoji}$/v.test(emoji)) {
|
if (!/^\p{RGI_Emoji}$/v.test(emoji)) {
|
||||||
return c.json({ error: 'Invalid emoji' }, 400);
|
return c.json({ error: 'Invalid emoji' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [event] = await store.query([
|
const [event] = await relay.query([
|
||||||
{ kinds: [1, 20], ids: [id], limit: 1 },
|
{ kinds: [1, 20], ids: [id], limit: 1 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -63,7 +61,7 @@ const deleteReactionController: AppController = async (c) => {
|
||||||
return c.json({ error: 'Status not found' }, 404);
|
return c.json({ error: 'Status not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await store.query([
|
const events = await relay.query([
|
||||||
{ kinds: [7], authors: [pubkey], '#e': [id] },
|
{ kinds: [7], authors: [pubkey], '#e': [id] },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -88,19 +86,20 @@ const deleteReactionController: AppController = async (c) => {
|
||||||
* https://docs.pleroma.social/backend/development/API/pleroma_api/#get-apiv1pleromastatusesidreactions
|
* https://docs.pleroma.social/backend/development/API/pleroma_api/#get-apiv1pleromastatusesidreactions
|
||||||
*/
|
*/
|
||||||
const reactionsController: AppController = async (c) => {
|
const reactionsController: AppController = async (c) => {
|
||||||
|
const { relay, user } = c.var;
|
||||||
|
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const store = await Storages.db();
|
const pubkey = await user?.signer.getPublicKey();
|
||||||
const pubkey = await c.get('signer')?.getPublicKey();
|
|
||||||
const emoji = c.req.param('emoji') as string | undefined;
|
const emoji = c.req.param('emoji') as string | undefined;
|
||||||
|
|
||||||
if (typeof emoji === 'string' && !/^\p{RGI_Emoji}$/v.test(emoji)) {
|
if (typeof emoji === 'string' && !/^\p{RGI_Emoji}$/v.test(emoji)) {
|
||||||
return c.json({ error: 'Invalid emoji' }, 400);
|
return c.json({ error: 'Invalid emoji' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await store.query([{ kinds: [7], '#e': [id], limit: 100 }])
|
const events = await relay.query([{ kinds: [7], '#e': [id], limit: 100 }])
|
||||||
.then((events) => events.filter(({ content }) => /^\p{RGI_Emoji}$/v.test(content)))
|
.then((events) => events.filter(({ content }) => /^\p{RGI_Emoji}$/v.test(content)))
|
||||||
.then((events) => events.filter((event) => !emoji || event.content === emoji))
|
.then((events) => events.filter((event) => !emoji || event.content === emoji))
|
||||||
.then((events) => hydrateEvents({ events, store }));
|
.then((events) => hydrateEvents({ events, relay }));
|
||||||
|
|
||||||
/** Events grouped by emoji. */
|
/** Events grouped by emoji. */
|
||||||
const byEmoji = events.reduce((acc, event) => {
|
const byEmoji = events.reduce((acc, event) => {
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ const reportSchema = z.object({
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/reports/#post */
|
/** https://docs.joinmastodon.org/methods/reports/#post */
|
||||||
const reportController: AppController = async (c) => {
|
const reportController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay } = c.var;
|
||||||
const store = c.get('store');
|
|
||||||
const body = await parseBody(c.req.raw);
|
const body = await parseBody(c.req.raw);
|
||||||
const result = reportSchema.safeParse(body);
|
const result = reportSchema.safeParse(body);
|
||||||
|
|
||||||
|
|
@ -49,7 +49,7 @@ const reportController: AppController = async (c) => {
|
||||||
tags,
|
tags,
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
await hydrateEvents({ events: [event], store });
|
await hydrateEvents({ events: [event], relay });
|
||||||
return c.json(await renderReport(event));
|
return c.json(await renderReport(event));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -61,18 +61,16 @@ const adminReportsSchema = z.object({
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/admin/reports/#get */
|
/** https://docs.joinmastodon.org/methods/admin/reports/#get */
|
||||||
const adminReportsController: AppController = async (c) => {
|
const adminReportsController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay, user, pagination } = c.var;
|
||||||
const store = c.get('store');
|
|
||||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
|
||||||
|
|
||||||
const params = c.get('pagination');
|
const viewerPubkey = await user?.signer.getPublicKey();
|
||||||
const { resolved, account_id, target_account_id } = adminReportsSchema.parse(c.req.query());
|
const { resolved, account_id, target_account_id } = adminReportsSchema.parse(c.req.query());
|
||||||
|
|
||||||
const filter: NostrFilter = {
|
const filter: NostrFilter = {
|
||||||
kinds: [30383],
|
kinds: [30383],
|
||||||
authors: [await conf.signer.getPublicKey()],
|
authors: [await conf.signer.getPublicKey()],
|
||||||
'#k': ['1984'],
|
'#k': ['1984'],
|
||||||
...params,
|
...pagination,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof resolved === 'boolean') {
|
if (typeof resolved === 'boolean') {
|
||||||
|
|
@ -85,7 +83,7 @@ const adminReportsController: AppController = async (c) => {
|
||||||
filter['#P'] = [target_account_id];
|
filter['#P'] = [target_account_id];
|
||||||
}
|
}
|
||||||
|
|
||||||
const orig = await store.query([filter]);
|
const orig = await relay.query([filter]);
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|
||||||
for (const event of orig) {
|
for (const event of orig) {
|
||||||
|
|
@ -95,8 +93,8 @@ const adminReportsController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await store.query([{ kinds: [1984], ids: [...ids] }])
|
const events = await relay.query([{ kinds: [1984], ids: [...ids] }])
|
||||||
.then((events) => hydrateEvents({ store, events: events, signal: c.req.raw.signal }));
|
.then((events) => hydrateEvents({ relay, events: events, signal: c.req.raw.signal }));
|
||||||
|
|
||||||
const reports = await Promise.all(
|
const reports = await Promise.all(
|
||||||
events.map((event) => renderAdminReport(event, { viewerPubkey })),
|
events.map((event) => renderAdminReport(event, { viewerPubkey })),
|
||||||
|
|
@ -107,12 +105,12 @@ const adminReportsController: AppController = async (c) => {
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/admin/reports/#get-one */
|
/** https://docs.joinmastodon.org/methods/admin/reports/#get-one */
|
||||||
const adminReportController: AppController = async (c) => {
|
const adminReportController: AppController = async (c) => {
|
||||||
const eventId = c.req.param('id');
|
const { relay, user, signal } = c.var;
|
||||||
const { signal } = c.req.raw;
|
|
||||||
const store = c.get('store');
|
|
||||||
const pubkey = await c.get('signer')?.getPublicKey();
|
|
||||||
|
|
||||||
const [event] = await store.query([{
|
const eventId = c.req.param('id');
|
||||||
|
const pubkey = await user?.signer.getPublicKey();
|
||||||
|
|
||||||
|
const [event] = await relay.query([{
|
||||||
kinds: [1984],
|
kinds: [1984],
|
||||||
ids: [eventId],
|
ids: [eventId],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
|
|
@ -122,7 +120,7 @@ const adminReportController: AppController = async (c) => {
|
||||||
return c.json({ error: 'Not found' }, 404);
|
return c.json({ error: 'Not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
await hydrateEvents({ events: [event], store, signal });
|
await hydrateEvents({ events: [event], relay, signal });
|
||||||
|
|
||||||
const report = await renderAdminReport(event, { viewerPubkey: pubkey });
|
const report = await renderAdminReport(event, { viewerPubkey: pubkey });
|
||||||
return c.json(report);
|
return c.json(report);
|
||||||
|
|
@ -130,12 +128,12 @@ const adminReportController: AppController = async (c) => {
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/admin/reports/#resolve */
|
/** https://docs.joinmastodon.org/methods/admin/reports/#resolve */
|
||||||
const adminReportResolveController: AppController = async (c) => {
|
const adminReportResolveController: AppController = async (c) => {
|
||||||
const eventId = c.req.param('id');
|
const { relay, user, signal } = c.var;
|
||||||
const { signal } = c.req.raw;
|
|
||||||
const store = c.get('store');
|
|
||||||
const pubkey = await c.get('signer')?.getPublicKey();
|
|
||||||
|
|
||||||
const [event] = await store.query([{
|
const eventId = c.req.param('id');
|
||||||
|
const pubkey = await user?.signer.getPublicKey();
|
||||||
|
|
||||||
|
const [event] = await relay.query([{
|
||||||
kinds: [1984],
|
kinds: [1984],
|
||||||
ids: [eventId],
|
ids: [eventId],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
|
|
@ -146,19 +144,19 @@ const adminReportResolveController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateEventInfo(eventId, { open: false, closed: true }, c);
|
await updateEventInfo(eventId, { open: false, closed: true }, c);
|
||||||
await hydrateEvents({ events: [event], store, signal });
|
await hydrateEvents({ events: [event], relay, signal });
|
||||||
|
|
||||||
const report = await renderAdminReport(event, { viewerPubkey: pubkey });
|
const report = await renderAdminReport(event, { viewerPubkey: pubkey });
|
||||||
return c.json(report);
|
return c.json(report);
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminReportReopenController: AppController = async (c) => {
|
const adminReportReopenController: AppController = async (c) => {
|
||||||
const eventId = c.req.param('id');
|
const { relay, user, signal } = c.var;
|
||||||
const { signal } = c.req.raw;
|
|
||||||
const store = c.get('store');
|
|
||||||
const pubkey = await c.get('signer')?.getPublicKey();
|
|
||||||
|
|
||||||
const [event] = await store.query([{
|
const eventId = c.req.param('id');
|
||||||
|
const pubkey = await user?.signer.getPublicKey();
|
||||||
|
|
||||||
|
const [event] = await relay.query([{
|
||||||
kinds: [1984],
|
kinds: [1984],
|
||||||
ids: [eventId],
|
ids: [eventId],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
|
|
@ -169,7 +167,7 @@ const adminReportReopenController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateEventInfo(eventId, { open: true, closed: false }, c);
|
await updateEventInfo(eventId, { open: true, closed: false }, c);
|
||||||
await hydrateEvents({ events: [event], store, signal });
|
await hydrateEvents({ events: [event], relay, signal });
|
||||||
|
|
||||||
const report = await renderAdminReport(event, { viewerPubkey: pubkey });
|
const report = await renderAdminReport(event, { viewerPubkey: pubkey });
|
||||||
return c.json(report);
|
return c.json(report);
|
||||||
|
|
|
||||||
|
|
@ -26,16 +26,16 @@ const searchQuerySchema = z.object({
|
||||||
type SearchQuery = z.infer<typeof searchQuerySchema> & { since?: number; until?: number; limit: number };
|
type SearchQuery = z.infer<typeof searchQuerySchema> & { since?: number; until?: number; limit: number };
|
||||||
|
|
||||||
const searchController: AppController = async (c) => {
|
const searchController: AppController = async (c) => {
|
||||||
|
const { user, pagination, signal } = c.var;
|
||||||
|
|
||||||
const result = searchQuerySchema.safeParse(c.req.query());
|
const result = searchQuerySchema.safeParse(c.req.query());
|
||||||
const params = c.get('pagination');
|
const viewerPubkey = await user?.signer.getPublicKey();
|
||||||
const { signal } = c.req.raw;
|
|
||||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return c.json({ error: 'Bad request', schema: result.error }, 422);
|
return c.json({ error: 'Bad request', schema: result.error }, 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = await lookupEvent({ ...result.data, ...params }, signal);
|
const event = await lookupEvent({ ...result.data, ...pagination }, signal);
|
||||||
const lookup = extractIdentifier(result.data.q);
|
const lookup = extractIdentifier(result.data.q);
|
||||||
|
|
||||||
// Render account from pubkey.
|
// Render account from pubkey.
|
||||||
|
|
@ -54,7 +54,7 @@ const searchController: AppController = async (c) => {
|
||||||
events = [event];
|
events = [event];
|
||||||
}
|
}
|
||||||
|
|
||||||
events.push(...(await searchEvents({ ...result.data, ...params, viewerPubkey }, signal)));
|
events.push(...(await searchEvents({ ...result.data, ...pagination, viewerPubkey }, signal)));
|
||||||
|
|
||||||
const [accounts, statuses] = await Promise.all([
|
const [accounts, statuses] = await Promise.all([
|
||||||
Promise.all(
|
Promise.all(
|
||||||
|
|
@ -78,7 +78,7 @@ const searchController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (result.data.type === 'accounts') {
|
if (result.data.type === 'accounts') {
|
||||||
return paginatedList(c, { ...result.data, ...params }, body);
|
return paginatedList(c, { ...result.data, ...pagination }, body);
|
||||||
} else {
|
} else {
|
||||||
return paginated(c, events, body);
|
return paginated(c, events, body);
|
||||||
}
|
}
|
||||||
|
|
@ -94,7 +94,7 @@ async function searchEvents(
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = await Storages.db();
|
const relay = await Storages.db();
|
||||||
|
|
||||||
const filter: NostrFilter = {
|
const filter: NostrFilter = {
|
||||||
kinds: typeToKinds(type),
|
kinds: typeToKinds(type),
|
||||||
|
|
@ -121,9 +121,9 @@ async function searchEvents(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query the events.
|
// Query the events.
|
||||||
let events = await store
|
let events = await relay
|
||||||
.query([filter], { signal })
|
.query([filter], { signal })
|
||||||
.then((events) => hydrateEvents({ events, store, signal }));
|
.then((events) => hydrateEvents({ events, relay, signal }));
|
||||||
|
|
||||||
// When using an authors filter, return the events in the same order as the filter.
|
// When using an authors filter, return the events in the same order as the filter.
|
||||||
if (filter.authors) {
|
if (filter.authors) {
|
||||||
|
|
@ -150,10 +150,10 @@ function typeToKinds(type: SearchQuery['type']): number[] {
|
||||||
/** Resolve a searched value into an event, if applicable. */
|
/** Resolve a searched value into an event, if applicable. */
|
||||||
async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<NostrEvent | undefined> {
|
async function lookupEvent(query: SearchQuery, signal: AbortSignal): Promise<NostrEvent | undefined> {
|
||||||
const filters = await getLookupFilters(query, signal);
|
const filters = await getLookupFilters(query, signal);
|
||||||
const store = await Storages.db();
|
const relay = await Storages.db();
|
||||||
|
|
||||||
return store.query(filters, { limit: 1, signal })
|
return relay.query(filters, { limit: 1, signal })
|
||||||
.then((events) => hydrateEvents({ events, store, signal }))
|
.then((events) => hydrateEvents({ events, relay, signal }))
|
||||||
.then(([event]) => event);
|
.then(([event]) => event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,11 @@ import { type AppController } from '@/app.ts';
|
||||||
import { DittoUpload, dittoUploads } from '@/DittoUploads.ts';
|
import { DittoUpload, dittoUploads } from '@/DittoUploads.ts';
|
||||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||||
import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts';
|
import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts';
|
||||||
|
import { paginationSchema } from '@/schemas/pagination.ts';
|
||||||
import { addTag, deleteTag } from '@/utils/tags.ts';
|
import { addTag, deleteTag } from '@/utils/tags.ts';
|
||||||
import { asyncReplaceAll } from '@/utils/text.ts';
|
import { asyncReplaceAll } from '@/utils/text.ts';
|
||||||
import { lookupPubkey } from '@/utils/lookup.ts';
|
import { lookupPubkey } from '@/utils/lookup.ts';
|
||||||
import { languageSchema } from '@/schema.ts';
|
import { languageSchema } from '@/schema.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
|
||||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { assertAuthenticated, createEvent, paginated, paginatedList, parseBody, updateListEvent } from '@/utils/api.ts';
|
import { assertAuthenticated, createEvent, paginated, paginatedList, parseBody, updateListEvent } from '@/utils/api.ts';
|
||||||
import { getInvoice, getLnurl } from '@/utils/lnurl.ts';
|
import { getInvoice, getLnurl } from '@/utils/lnurl.ts';
|
||||||
|
|
@ -46,9 +46,9 @@ const createStatusSchema = z.object({
|
||||||
);
|
);
|
||||||
|
|
||||||
const statusController: AppController = async (c) => {
|
const statusController: AppController = async (c) => {
|
||||||
const id = c.req.param('id');
|
const { user, signal } = c.var;
|
||||||
const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(1500)]);
|
|
||||||
|
|
||||||
|
const id = c.req.param('id');
|
||||||
const event = await getEvent(id, { signal });
|
const event = await getEvent(id, { signal });
|
||||||
|
|
||||||
if (event?.author) {
|
if (event?.author) {
|
||||||
|
|
@ -56,7 +56,7 @@ const statusController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
const viewerPubkey = await user?.signer.getPublicKey();
|
||||||
const status = await renderStatus(event, { viewerPubkey });
|
const status = await renderStatus(event, { viewerPubkey });
|
||||||
return c.json(status);
|
return c.json(status);
|
||||||
}
|
}
|
||||||
|
|
@ -65,10 +65,10 @@ const statusController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const createStatusController: AppController = async (c) => {
|
const createStatusController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay, user, signal } = c.var;
|
||||||
|
|
||||||
const body = await parseBody(c.req.raw);
|
const body = await parseBody(c.req.raw);
|
||||||
const result = createStatusSchema.safeParse(body);
|
const result = createStatusSchema.safeParse(body);
|
||||||
const store = c.get('store');
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return c.json({ error: 'Bad request', schema: result.error }, 400);
|
return c.json({ error: 'Bad request', schema: result.error }, 400);
|
||||||
|
|
@ -87,14 +87,14 @@ const createStatusController: AppController = async (c) => {
|
||||||
const tags: string[][] = [];
|
const tags: string[][] = [];
|
||||||
|
|
||||||
if (data.in_reply_to_id) {
|
if (data.in_reply_to_id) {
|
||||||
const [ancestor] = await store.query([{ ids: [data.in_reply_to_id] }]);
|
const [ancestor] = await relay.query([{ ids: [data.in_reply_to_id] }]);
|
||||||
|
|
||||||
if (!ancestor) {
|
if (!ancestor) {
|
||||||
return c.json({ error: 'Original post not found.' }, 404);
|
return c.json({ error: 'Original post not found.' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootId = ancestor.tags.find((tag) => tag[0] === 'e' && tag[3] === 'root')?.[1] ?? ancestor.id;
|
const rootId = ancestor.tags.find((tag) => tag[0] === 'e' && tag[3] === 'root')?.[1] ?? ancestor.id;
|
||||||
const root = rootId === ancestor.id ? ancestor : await store.query([{ ids: [rootId] }]).then(([event]) => event);
|
const root = rootId === ancestor.id ? ancestor : await relay.query([{ ids: [rootId] }]).then(([event]) => event);
|
||||||
|
|
||||||
if (root) {
|
if (root) {
|
||||||
tags.push(['e', root.id, conf.relay, 'root', root.pubkey]);
|
tags.push(['e', root.id, conf.relay, 'root', root.pubkey]);
|
||||||
|
|
@ -108,7 +108,7 @@ const createStatusController: AppController = async (c) => {
|
||||||
let quoted: DittoEvent | undefined;
|
let quoted: DittoEvent | undefined;
|
||||||
|
|
||||||
if (data.quote_id) {
|
if (data.quote_id) {
|
||||||
[quoted] = await store.query([{ ids: [data.quote_id] }]);
|
[quoted] = await relay.query([{ ids: [data.quote_id] }]);
|
||||||
|
|
||||||
if (!quoted) {
|
if (!quoted) {
|
||||||
return c.json({ error: 'Quoted post not found.' }, 404);
|
return c.json({ error: 'Quoted post not found.' }, 404);
|
||||||
|
|
@ -190,13 +190,13 @@ const createStatusController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const author = pubkey ? await getAuthor(pubkey) : undefined;
|
const author = pubkey ? await getAuthor(pubkey) : undefined;
|
||||||
|
|
||||||
if (conf.zapSplitsEnabled) {
|
if (conf.zapSplitsEnabled) {
|
||||||
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
|
const meta = n.json().pipe(n.metadata()).catch({}).parse(author?.content);
|
||||||
const lnurl = getLnurl(meta);
|
const lnurl = getLnurl(meta);
|
||||||
const dittoZapSplit = await getZapSplits(store, await conf.signer.getPublicKey());
|
const dittoZapSplit = await getZapSplits(relay, await conf.signer.getPublicKey());
|
||||||
if (lnurl && dittoZapSplit) {
|
if (lnurl && dittoZapSplit) {
|
||||||
const totalSplit = Object.values(dittoZapSplit).reduce((total, { weight }) => total + weight, 0);
|
const totalSplit = Object.values(dittoZapSplit).reduce((total, { weight }) => total + weight, 0);
|
||||||
for (const zapPubkey in dittoZapSplit) {
|
for (const zapPubkey in dittoZapSplit) {
|
||||||
|
|
@ -256,8 +256,8 @@ const createStatusController: AppController = async (c) => {
|
||||||
if (data.quote_id) {
|
if (data.quote_id) {
|
||||||
await hydrateEvents({
|
await hydrateEvents({
|
||||||
events: [event],
|
events: [event],
|
||||||
store: await Storages.db(),
|
relay,
|
||||||
signal: c.req.raw.signal,
|
signal,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,11 +265,11 @@ const createStatusController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteStatusController: AppController = async (c) => {
|
const deleteStatusController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, user, signal } = c.var;
|
||||||
const id = c.req.param('id');
|
|
||||||
const pubkey = await c.get('signer')?.getPublicKey();
|
|
||||||
|
|
||||||
const event = await getEvent(id, { signal: c.req.raw.signal });
|
const id = c.req.param('id');
|
||||||
|
const pubkey = await user?.signer.getPublicKey();
|
||||||
|
const event = await getEvent(id, { signal });
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
if (event.pubkey === pubkey) {
|
if (event.pubkey === pubkey) {
|
||||||
|
|
@ -289,10 +289,11 @@ const deleteStatusController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const contextController: AppController = async (c) => {
|
const contextController: AppController = async (c) => {
|
||||||
|
const { relay, user } = c.var;
|
||||||
|
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const store = c.get('store');
|
const [event] = await relay.query([{ kinds: [1, 20], ids: [id] }]);
|
||||||
const [event] = await store.query([{ kinds: [1, 20], ids: [id] }]);
|
const viewerPubkey = await user?.signer.getPublicKey();
|
||||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
|
||||||
|
|
||||||
async function renderStatuses(events: NostrEvent[]) {
|
async function renderStatuses(events: NostrEvent[]) {
|
||||||
const statuses = await Promise.all(
|
const statuses = await Promise.all(
|
||||||
|
|
@ -303,14 +304,14 @@ const contextController: AppController = async (c) => {
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
const [ancestorEvents, descendantEvents] = await Promise.all([
|
const [ancestorEvents, descendantEvents] = await Promise.all([
|
||||||
getAncestors(store, event),
|
getAncestors(relay, event),
|
||||||
getDescendants(store, event),
|
getDescendants(relay, event),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await hydrateEvents({
|
await hydrateEvents({
|
||||||
events: [...ancestorEvents, ...descendantEvents],
|
events: [...ancestorEvents, ...descendantEvents],
|
||||||
signal: c.req.raw.signal,
|
signal: c.req.raw.signal,
|
||||||
store,
|
relay,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [ancestors, descendants] = await Promise.all([
|
const [ancestors, descendants] = await Promise.all([
|
||||||
|
|
@ -325,10 +326,10 @@ const contextController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const favouriteController: AppController = async (c) => {
|
const favouriteController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay, user } = c.var;
|
||||||
|
|
||||||
const id = c.req.param('id');
|
const id = c.req.param('id');
|
||||||
const store = await Storages.db();
|
const [target] = await relay.query([{ ids: [id], kinds: [1, 20] }]);
|
||||||
const [target] = await store.query([{ ids: [id], kinds: [1, 20] }]);
|
|
||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
await createEvent({
|
await createEvent({
|
||||||
|
|
@ -340,9 +341,9 @@ const favouriteController: AppController = async (c) => {
|
||||||
],
|
],
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
await hydrateEvents({ events: [target], store });
|
await hydrateEvents({ events: [target], relay });
|
||||||
|
|
||||||
const status = await renderStatus(target, { viewerPubkey: await c.get('signer')?.getPublicKey() });
|
const status = await renderStatus(target, { viewerPubkey: await user?.signer.getPublicKey() });
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
status.favourited = true;
|
status.favourited = true;
|
||||||
|
|
@ -366,13 +367,10 @@ const favouritedByController: AppController = (c) => {
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/statuses/#boost */
|
/** https://docs.joinmastodon.org/methods/statuses/#boost */
|
||||||
const reblogStatusController: AppController = async (c) => {
|
const reblogStatusController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay, user, signal } = c.var;
|
||||||
const eventId = c.req.param('id');
|
|
||||||
const { signal } = c.req.raw;
|
|
||||||
|
|
||||||
const event = await getEvent(eventId, {
|
const eventId = c.req.param('id');
|
||||||
kind: 1,
|
const event = await getEvent(eventId);
|
||||||
});
|
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'Event not found.' }, 404);
|
return c.json({ error: 'Event not found.' }, 404);
|
||||||
|
|
@ -388,28 +386,28 @@ const reblogStatusController: AppController = async (c) => {
|
||||||
|
|
||||||
await hydrateEvents({
|
await hydrateEvents({
|
||||||
events: [reblogEvent],
|
events: [reblogEvent],
|
||||||
store: await Storages.db(),
|
relay,
|
||||||
signal: signal,
|
signal: signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
const status = await renderReblog(reblogEvent, { viewerPubkey: await c.get('signer')?.getPublicKey() });
|
const status = await renderReblog(reblogEvent, { viewerPubkey: await user?.signer.getPublicKey() });
|
||||||
|
|
||||||
return c.json(status);
|
return c.json(status);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/statuses/#unreblog */
|
/** https://docs.joinmastodon.org/methods/statuses/#unreblog */
|
||||||
const unreblogStatusController: AppController = async (c) => {
|
const unreblogStatusController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay, user } = c.var;
|
||||||
const eventId = c.req.param('id');
|
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
|
||||||
const store = await Storages.db();
|
|
||||||
|
|
||||||
const [event] = await store.query([{ ids: [eventId], kinds: [1, 20] }]);
|
const eventId = c.req.param('id');
|
||||||
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
|
|
||||||
|
const [event] = await relay.query([{ ids: [eventId], kinds: [1, 20] }]);
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'Record not found' }, 404);
|
return c.json({ error: 'Record not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [repostEvent] = await store.query(
|
const [repostEvent] = await relay.query(
|
||||||
[{ kinds: [6], authors: [pubkey], '#e': [event.id], limit: 1 }],
|
[{ kinds: [6], authors: [pubkey], '#e': [event.id], limit: 1 }],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -432,20 +430,20 @@ const rebloggedByController: AppController = (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const quotesController: AppController = async (c) => {
|
const quotesController: AppController = async (c) => {
|
||||||
const id = c.req.param('id');
|
const { relay, user, pagination } = c.var;
|
||||||
const params = c.get('pagination');
|
|
||||||
const store = await Storages.db();
|
|
||||||
|
|
||||||
const [event] = await store.query([{ ids: [id], kinds: [1, 20] }]);
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const [event] = await relay.query([{ ids: [id], kinds: [1, 20] }]);
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'Event not found.' }, 404);
|
return c.json({ error: 'Event not found.' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotes = await store
|
const quotes = await relay
|
||||||
.query([{ kinds: [1, 20], '#q': [event.id], ...params }])
|
.query([{ kinds: [1, 20], '#q': [event.id], ...pagination }])
|
||||||
.then((events) => hydrateEvents({ events, store }));
|
.then((events) => hydrateEvents({ events, relay }));
|
||||||
|
|
||||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
const viewerPubkey = await user?.signer.getPublicKey();
|
||||||
|
|
||||||
const statuses = await Promise.all(
|
const statuses = await Promise.all(
|
||||||
quotes.map((event) => renderStatus(event, { viewerPubkey })),
|
quotes.map((event) => renderStatus(event, { viewerPubkey })),
|
||||||
|
|
@ -460,14 +458,11 @@ const quotesController: AppController = async (c) => {
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/statuses/#bookmark */
|
/** https://docs.joinmastodon.org/methods/statuses/#bookmark */
|
||||||
const bookmarkController: AppController = async (c) => {
|
const bookmarkController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, user } = c.var;
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const eventId = c.req.param('id');
|
const eventId = c.req.param('id');
|
||||||
|
|
||||||
const event = await getEvent(eventId, {
|
const event = await getEvent(eventId);
|
||||||
kind: 1,
|
|
||||||
relations: ['author', 'event_stats', 'author_stats'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
await updateListEvent(
|
await updateListEvent(
|
||||||
|
|
@ -488,14 +483,12 @@ const bookmarkController: AppController = async (c) => {
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/statuses/#unbookmark */
|
/** https://docs.joinmastodon.org/methods/statuses/#unbookmark */
|
||||||
const unbookmarkController: AppController = async (c) => {
|
const unbookmarkController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, user } = c.var;
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
|
||||||
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const eventId = c.req.param('id');
|
const eventId = c.req.param('id');
|
||||||
|
|
||||||
const event = await getEvent(eventId, {
|
const event = await getEvent(eventId);
|
||||||
kind: 1,
|
|
||||||
relations: ['author', 'event_stats', 'author_stats'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
await updateListEvent(
|
await updateListEvent(
|
||||||
|
|
@ -516,14 +509,12 @@ const unbookmarkController: AppController = async (c) => {
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/statuses/#pin */
|
/** https://docs.joinmastodon.org/methods/statuses/#pin */
|
||||||
const pinController: AppController = async (c) => {
|
const pinController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, user } = c.var;
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
|
||||||
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const eventId = c.req.param('id');
|
const eventId = c.req.param('id');
|
||||||
|
|
||||||
const event = await getEvent(eventId, {
|
const event = await getEvent(eventId);
|
||||||
kind: 1,
|
|
||||||
relations: ['author', 'event_stats', 'author_stats'],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (event) {
|
if (event) {
|
||||||
await updateListEvent(
|
await updateListEvent(
|
||||||
|
|
@ -544,14 +535,13 @@ const pinController: AppController = async (c) => {
|
||||||
|
|
||||||
/** https://docs.joinmastodon.org/methods/statuses/#unpin */
|
/** https://docs.joinmastodon.org/methods/statuses/#unpin */
|
||||||
const unpinController: AppController = async (c) => {
|
const unpinController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, user, signal } = c.var;
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
|
||||||
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const eventId = c.req.param('id');
|
const eventId = c.req.param('id');
|
||||||
const { signal } = c.req.raw;
|
|
||||||
|
|
||||||
const event = await getEvent(eventId, {
|
const event = await getEvent(eventId, {
|
||||||
kind: 1,
|
kind: 1,
|
||||||
relations: ['author', 'event_stats', 'author_stats'],
|
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -580,11 +570,10 @@ const zapSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const zapController: AppController = async (c) => {
|
const zapController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay, signal } = c.var;
|
||||||
|
|
||||||
const body = await parseBody(c.req.raw);
|
const body = await parseBody(c.req.raw);
|
||||||
const result = zapSchema.safeParse(body);
|
const result = zapSchema.safeParse(body);
|
||||||
const { signal } = c.req.raw;
|
|
||||||
const store = c.get('store');
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return c.json({ error: 'Bad request', schema: result.error }, 400);
|
return c.json({ error: 'Bad request', schema: result.error }, 400);
|
||||||
|
|
@ -611,7 +600,7 @@ const zapController: AppController = async (c) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
[target] = await store.query([{ authors: [account_id], kinds: [0], limit: 1 }]);
|
[target] = await relay.query([{ authors: [account_id], kinds: [0], limit: 1 }]);
|
||||||
const meta = n.json().pipe(n.metadata()).catch({}).parse(target?.content);
|
const meta = n.json().pipe(n.metadata()).catch({}).parse(target?.content);
|
||||||
lnurl = getLnurl(meta);
|
lnurl = getLnurl(meta);
|
||||||
if (target && lnurl) {
|
if (target && lnurl) {
|
||||||
|
|
@ -638,19 +627,19 @@ const zapController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const zappedByController: AppController = async (c) => {
|
const zappedByController: AppController = async (c) => {
|
||||||
const id = c.req.param('id');
|
const { db, relay } = c.var;
|
||||||
const params = c.get('listPagination');
|
|
||||||
const store = await Storages.db();
|
|
||||||
const kysely = await Storages.kysely();
|
|
||||||
|
|
||||||
const zaps = await kysely.selectFrom('event_zaps')
|
const id = c.req.param('id');
|
||||||
|
const { offset, limit } = paginationSchema.parse(c.req.query());
|
||||||
|
|
||||||
|
const zaps = await db.kysely.selectFrom('event_zaps')
|
||||||
.selectAll()
|
.selectAll()
|
||||||
.where('target_event_id', '=', id)
|
.where('target_event_id', '=', id)
|
||||||
.orderBy('amount_millisats', 'desc')
|
.orderBy('amount_millisats', 'desc')
|
||||||
.limit(params.limit)
|
.limit(limit)
|
||||||
.offset(params.offset).execute();
|
.offset(offset).execute();
|
||||||
|
|
||||||
const authors = await store.query([{ kinds: [0], authors: zaps.map((zap) => zap.sender_pubkey) }]);
|
const authors = await relay.query([{ kinds: [0], authors: zaps.map((zap) => zap.sender_pubkey) }]);
|
||||||
|
|
||||||
const results = (await Promise.all(
|
const results = (await Promise.all(
|
||||||
zaps.map(async (zap) => {
|
zaps.map(async (zap) => {
|
||||||
|
|
@ -668,7 +657,7 @@ const zappedByController: AppController = async (c) => {
|
||||||
}),
|
}),
|
||||||
)).filter(Boolean);
|
)).filter(Boolean);
|
||||||
|
|
||||||
return paginatedList(c, params, results);
|
return paginatedList(c, { limit, offset }, results);
|
||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,10 @@ import { z } from 'zod';
|
||||||
import { type AppController } from '@/app.ts';
|
import { type AppController } from '@/app.ts';
|
||||||
import { getFeedPubkeys } from '@/queries.ts';
|
import { getFeedPubkeys } from '@/queries.ts';
|
||||||
import { hydrateEvents } from '@/storages/hydrate.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 { errorJson } from '@/utils/log.ts';
|
||||||
import { bech32ToPubkey, Time } from '@/utils.ts';
|
import { Time } from '@/utils.ts';
|
||||||
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
import { renderNotification } from '@/views/mastodon/notifications.ts';
|
||||||
import { HTTPException } from '@hono/hono/http-exception';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streaming timelines/categories.
|
* Streaming timelines/categories.
|
||||||
|
|
@ -68,7 +65,7 @@ const limiter = new TTLCache<string, number>();
|
||||||
const connections = new Set<WebSocket>();
|
const connections = new Set<WebSocket>();
|
||||||
|
|
||||||
const streamingController: AppController = async (c) => {
|
const streamingController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay, user } = c.var;
|
||||||
const upgrade = c.req.header('upgrade');
|
const upgrade = c.req.header('upgrade');
|
||||||
const token = c.req.header('sec-websocket-protocol');
|
const token = c.req.header('sec-websocket-protocol');
|
||||||
const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream'));
|
const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream'));
|
||||||
|
|
@ -78,11 +75,6 @@ const streamingController: AppController = async (c) => {
|
||||||
return c.text('Please use websocket protocol', 400);
|
return c.text('Please use websocket protocol', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pubkey = token ? await getTokenPubkey(token) : undefined;
|
|
||||||
if (token && !pubkey) {
|
|
||||||
return c.json({ error: 'Invalid access token' }, 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ip = c.req.header('x-real-ip');
|
const ip = c.req.header('x-real-ip');
|
||||||
if (ip) {
|
if (ip) {
|
||||||
const count = limiter.get(ip) ?? 0;
|
const count = limiter.get(ip) ?? 0;
|
||||||
|
|
@ -93,8 +85,8 @@ const streamingController: AppController = async (c) => {
|
||||||
|
|
||||||
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 });
|
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 });
|
||||||
|
|
||||||
const store = await Storages.db();
|
const pubkey = await user?.signer.getPublicKey();
|
||||||
const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined;
|
const policy = pubkey ? new MuteListPolicy(pubkey, relay) : undefined;
|
||||||
|
|
||||||
function send(e: StreamingEvent) {
|
function send(e: StreamingEvent) {
|
||||||
if (socket.readyState === WebSocket.OPEN) {
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
|
|
@ -108,7 +100,7 @@ const streamingController: AppController = async (c) => {
|
||||||
render: (event: NostrEvent) => Promise<StreamingEvent | undefined>,
|
render: (event: NostrEvent) => Promise<StreamingEvent | undefined>,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
for await (const msg of store.req([filter], { signal: controller.signal })) {
|
for await (const msg of relay.req([filter], { signal: controller.signal })) {
|
||||||
if (msg[0] === 'EVENT') {
|
if (msg[0] === 'EVENT') {
|
||||||
const event = msg[2];
|
const event = msg[2];
|
||||||
|
|
||||||
|
|
@ -119,7 +111,7 @@ const streamingController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await hydrateEvents({ events: [event], store, signal: AbortSignal.timeout(1000) });
|
await hydrateEvents({ events: [event], relay, signal: AbortSignal.timeout(1000) });
|
||||||
|
|
||||||
const result = await render(event);
|
const result = await render(event);
|
||||||
|
|
||||||
|
|
@ -230,25 +222,4 @@ async function topicToFilter(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTokenPubkey(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)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
throw new HTTPException(401, { message: 'Invalid access token' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return row.pubkey;
|
|
||||||
} else {
|
|
||||||
return bech32ToPubkey(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { streamingController };
|
export { streamingController };
|
||||||
|
|
|
||||||
|
|
@ -2,33 +2,32 @@ import { NostrFilter } from '@nostrify/nostrify';
|
||||||
import { matchFilter } from 'nostr-tools';
|
import { matchFilter } from 'nostr-tools';
|
||||||
|
|
||||||
import { AppContext, AppController } from '@/app.ts';
|
import { AppContext, AppController } from '@/app.ts';
|
||||||
|
import { paginationSchema } from '@/schemas/pagination.ts';
|
||||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { paginated, paginatedList } from '@/utils/api.ts';
|
import { paginated, paginatedList } from '@/utils/api.ts';
|
||||||
import { getTagSet } from '@/utils/tags.ts';
|
import { getTagSet } from '@/utils/tags.ts';
|
||||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
|
|
||||||
export const suggestionsV1Controller: AppController = async (c) => {
|
export const suggestionsV1Controller: AppController = async (c) => {
|
||||||
const signal = c.req.raw.signal;
|
const { signal } = c.var;
|
||||||
const params = c.get('listPagination');
|
const { offset, limit } = paginationSchema.parse(c.req.query());
|
||||||
const suggestions = await renderV2Suggestions(c, params, signal);
|
const suggestions = await renderV2Suggestions(c, { offset, limit }, signal);
|
||||||
const accounts = suggestions.map(({ account }) => account);
|
const accounts = suggestions.map(({ account }) => account);
|
||||||
return paginatedList(c, params, accounts);
|
return paginatedList(c, { offset, limit }, accounts);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const suggestionsV2Controller: AppController = async (c) => {
|
export const suggestionsV2Controller: AppController = async (c) => {
|
||||||
const signal = c.req.raw.signal;
|
const { signal } = c.var;
|
||||||
const params = c.get('listPagination');
|
const { offset, limit } = paginationSchema.parse(c.req.query());
|
||||||
const suggestions = await renderV2Suggestions(c, params, signal);
|
const suggestions = await renderV2Suggestions(c, { offset, limit }, signal);
|
||||||
return paginatedList(c, params, suggestions);
|
return paginatedList(c, { offset, limit }, suggestions);
|
||||||
};
|
};
|
||||||
|
|
||||||
async function renderV2Suggestions(c: AppContext, params: { offset: number; limit: number }, signal?: AbortSignal) {
|
async function renderV2Suggestions(c: AppContext, params: { offset: number; limit: number }, signal?: AbortSignal) {
|
||||||
const { conf } = c.var;
|
const { conf, relay, user } = c.var;
|
||||||
const { offset, limit } = params;
|
const { offset, limit } = params;
|
||||||
|
|
||||||
const store = c.get('store');
|
const pubkey = await user?.signer.getPublicKey();
|
||||||
const signer = c.get('signer');
|
|
||||||
const pubkey = await signer?.getPublicKey();
|
|
||||||
|
|
||||||
const filters: NostrFilter[] = [
|
const filters: NostrFilter[] = [
|
||||||
{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#n': ['suggested'], limit },
|
{ kinds: [30382], authors: [await conf.signer.getPublicKey()], '#n': ['suggested'], limit },
|
||||||
|
|
@ -40,7 +39,7 @@ async function renderV2Suggestions(c: AppContext, params: { offset: number; limi
|
||||||
filters.push({ kinds: [10000], authors: [pubkey], limit: 1 });
|
filters.push({ kinds: [10000], authors: [pubkey], limit: 1 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await store.query(filters, { signal });
|
const events = await relay.query(filters, { signal });
|
||||||
const adminPubkey = await conf.signer.getPublicKey();
|
const adminPubkey = await conf.signer.getPublicKey();
|
||||||
|
|
||||||
const [userEvents, followsEvent, mutesEvent, trendingEvent] = [
|
const [userEvents, followsEvent, mutesEvent, trendingEvent] = [
|
||||||
|
|
@ -79,11 +78,11 @@ async function renderV2Suggestions(c: AppContext, params: { offset: number; limi
|
||||||
|
|
||||||
const authors = [...pubkeys].slice(offset, offset + limit);
|
const authors = [...pubkeys].slice(offset, offset + limit);
|
||||||
|
|
||||||
const profiles = await store.query(
|
const profiles = await relay.query(
|
||||||
[{ kinds: [0], authors, limit: authors.length }],
|
[{ kinds: [0], authors, limit: authors.length }],
|
||||||
{ signal },
|
{ signal },
|
||||||
)
|
)
|
||||||
.then((events) => hydrateEvents({ events, store, signal }));
|
.then((events) => hydrateEvents({ events, relay, signal }));
|
||||||
|
|
||||||
return Promise.all(authors.map(async (pubkey) => {
|
return Promise.all(authors.map(async (pubkey) => {
|
||||||
const profile = profiles.find((event) => event.pubkey === pubkey);
|
const profile = profiles.find((event) => event.pubkey === pubkey);
|
||||||
|
|
@ -96,13 +95,10 @@ async function renderV2Suggestions(c: AppContext, params: { offset: number; limi
|
||||||
}
|
}
|
||||||
|
|
||||||
export const localSuggestionsController: AppController = async (c) => {
|
export const localSuggestionsController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay, pagination, signal } = c.var;
|
||||||
const signal = c.req.raw.signal;
|
|
||||||
const params = c.get('pagination');
|
|
||||||
const store = c.get('store');
|
|
||||||
|
|
||||||
const grants = await store.query(
|
const grants = await relay.query(
|
||||||
[{ kinds: [30360], authors: [await conf.signer.getPublicKey()], ...params }],
|
[{ kinds: [30360], authors: [await conf.signer.getPublicKey()], ...pagination }],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -115,11 +111,11 @@ export const localSuggestionsController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const profiles = await store.query(
|
const profiles = await relay.query(
|
||||||
[{ kinds: [0], authors: [...pubkeys], search: `domain:${conf.url.host}`, ...params }],
|
[{ kinds: [0], authors: [...pubkeys], search: `domain:${conf.url.host}`, ...pagination }],
|
||||||
{ signal },
|
{ signal },
|
||||||
)
|
)
|
||||||
.then((events) => hydrateEvents({ store, events, signal }));
|
.then((events) => hydrateEvents({ relay, events, signal }));
|
||||||
|
|
||||||
const suggestions = [...pubkeys].map((pubkey) => {
|
const suggestions = [...pubkeys].map((pubkey) => {
|
||||||
const profile = profiles.find((event) => event.pubkey === pubkey);
|
const profile = profiles.find((event) => event.pubkey === pubkey);
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ const homeQuerySchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const homeTimelineController: AppController = async (c) => {
|
const homeTimelineController: AppController = async (c) => {
|
||||||
const params = c.get('pagination');
|
const { user, pagination } = c.var;
|
||||||
const pubkey = await c.get('signer')?.getPublicKey()!;
|
const pubkey = await user?.signer.getPublicKey()!;
|
||||||
const result = homeQuerySchema.safeParse(c.req.query());
|
const result = homeQuerySchema.safeParse(c.req.query());
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|
@ -26,7 +26,7 @@ const homeTimelineController: AppController = async (c) => {
|
||||||
const { exclude_replies, only_media } = result.data;
|
const { exclude_replies, only_media } = result.data;
|
||||||
|
|
||||||
const authors = [...await getFeedPubkeys(pubkey)];
|
const authors = [...await getFeedPubkeys(pubkey)];
|
||||||
const filter: NostrFilter = { authors, kinds: [1, 6, 20], ...params };
|
const filter: NostrFilter = { authors, kinds: [1, 6, 20], ...pagination };
|
||||||
|
|
||||||
const search: string[] = [];
|
const search: string[] = [];
|
||||||
|
|
||||||
|
|
@ -90,35 +90,33 @@ const hashtagTimelineController: AppController = (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const suggestedTimelineController: AppController = async (c) => {
|
const suggestedTimelineController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay, pagination } = c.var;
|
||||||
const store = c.get('store');
|
|
||||||
const params = c.get('pagination');
|
|
||||||
|
|
||||||
const [follows] = await store.query(
|
const [follows] = await relay.query(
|
||||||
[{ kinds: [3], authors: [await conf.signer.getPublicKey()], limit: 1 }],
|
[{ kinds: [3], authors: [await conf.signer.getPublicKey()], limit: 1 }],
|
||||||
);
|
);
|
||||||
|
|
||||||
const authors = [...getTagSet(follows?.tags ?? [], 'p')];
|
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. */
|
/** Render statuses for timelines. */
|
||||||
async function renderStatuses(c: AppContext, filters: NostrFilter[]) {
|
async function renderStatuses(c: AppContext, filters: NostrFilter[]) {
|
||||||
const { conf } = c.var;
|
const { conf, user, signal } = c.var;
|
||||||
const { signal } = c.req.raw;
|
|
||||||
const store = c.get('store');
|
const relay = user?.relay ?? c.var.relay;
|
||||||
const opts = { signal, timeout: conf.db.timeouts.timelines };
|
const opts = { signal, timeout: conf.db.timeouts.timelines };
|
||||||
|
|
||||||
const events = await store
|
const events = await relay
|
||||||
.query(filters, opts)
|
.query(filters, opts)
|
||||||
.then((events) => hydrateEvents({ events, store, signal }));
|
.then((events) => hydrateEvents({ events, relay, signal }));
|
||||||
|
|
||||||
if (!events.length) {
|
if (!events.length) {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
const viewerPubkey = await user?.signer.getPublicKey();
|
||||||
|
|
||||||
const statuses = (await Promise.all(events.map((event) => {
|
const statuses = (await Promise.all(events.map((event) => {
|
||||||
if (event.kind === 6) {
|
if (event.kind === 6) {
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,9 @@ const translateSchema = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
const translateController: AppController = async (c) => {
|
const translateController: AppController = async (c) => {
|
||||||
|
const { user, signal } = c.var;
|
||||||
|
|
||||||
const result = translateSchema.safeParse(await parseBody(c.req.raw));
|
const result = translateSchema.safeParse(await parseBody(c.req.raw));
|
||||||
const { signal } = c.req.raw;
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return c.json({ error: 'Bad request.', schema: result.error }, 422);
|
return c.json({ error: 'Bad request.', schema: result.error }, 422);
|
||||||
|
|
@ -38,7 +39,7 @@ const translateController: AppController = async (c) => {
|
||||||
return c.json({ error: 'Record not found' }, 400);
|
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()) {
|
if (lang.toLowerCase() === event?.language?.toLowerCase()) {
|
||||||
return c.json({ error: 'Source and target languages are the same. No translation needed.' }, 400);
|
return c.json({ error: 'Source and target languages are the same. No translation needed.' }, 400);
|
||||||
|
|
|
||||||
|
|
@ -52,8 +52,8 @@ const trendingTagsController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getTrendingHashtags(conf: DittoConf) {
|
async function getTrendingHashtags(conf: DittoConf) {
|
||||||
const store = await Storages.db();
|
const relay = await Storages.db();
|
||||||
const trends = await getTrendingTags(store, 't', await conf.signer.getPublicKey());
|
const trends = await getTrendingTags(relay, 't', await conf.signer.getPublicKey());
|
||||||
|
|
||||||
return trends.map((trend) => {
|
return trends.map((trend) => {
|
||||||
const hashtag = trend.value;
|
const hashtag = trend.value;
|
||||||
|
|
@ -105,8 +105,8 @@ const trendingLinksController: AppController = async (c) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getTrendingLinks(conf: DittoConf) {
|
async function getTrendingLinks(conf: DittoConf) {
|
||||||
const store = await Storages.db();
|
const relay = await Storages.db();
|
||||||
const trends = await getTrendingTags(store, 'r', await conf.signer.getPublicKey());
|
const trends = await getTrendingTags(relay, 'r', await conf.signer.getPublicKey());
|
||||||
|
|
||||||
return Promise.all(trends.map(async (trend) => {
|
return Promise.all(trends.map(async (trend) => {
|
||||||
const link = trend.value;
|
const link = trend.value;
|
||||||
|
|
@ -140,11 +140,10 @@ async function getTrendingLinks(conf: DittoConf) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const trendingStatusesController: AppController = async (c) => {
|
const trendingStatusesController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay } = c.var;
|
||||||
const store = await Storages.db();
|
|
||||||
const { limit, offset, until } = paginationSchema.parse(c.req.query());
|
const { limit, offset, until } = paginationSchema.parse(c.req.query());
|
||||||
|
|
||||||
const [label] = await store.query([{
|
const [label] = await relay.query([{
|
||||||
kinds: [1985],
|
kinds: [1985],
|
||||||
'#L': ['pub.ditto.trends'],
|
'#L': ['pub.ditto.trends'],
|
||||||
'#l': ['#e'],
|
'#l': ['#e'],
|
||||||
|
|
@ -162,8 +161,8 @@ const trendingStatusesController: AppController = async (c) => {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = await store.query([{ kinds: [1, 20], ids }])
|
const results = await relay.query([{ kinds: [1, 20], ids }])
|
||||||
.then((events) => hydrateEvents({ events, store }));
|
.then((events) => hydrateEvents({ events, relay }));
|
||||||
|
|
||||||
// Sort events in the order they appear in the label.
|
// Sort events in the order they appear in the label.
|
||||||
const events = ids
|
const events = ids
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { logi } from '@soapbox/logi';
|
import { logi } from '@soapbox/logi';
|
||||||
|
|
||||||
import { AppMiddleware } from '@/app.ts';
|
import { AppMiddleware } from '@/app.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
|
||||||
import { getPathParams, MetadataEntities } from '@/utils/og-metadata.ts';
|
import { getPathParams, MetadataEntities } from '@/utils/og-metadata.ts';
|
||||||
import { getInstanceMetadata } from '@/utils/instance.ts';
|
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||||
import { errorJson } from '@/utils/log.ts';
|
import { errorJson } from '@/utils/log.ts';
|
||||||
|
|
@ -10,11 +9,14 @@ import { renderMetadata } from '@/views/meta.ts';
|
||||||
import { getAuthor, getEvent } from '@/queries.ts';
|
import { getAuthor, getEvent } from '@/queries.ts';
|
||||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
import { renderAccount } from '@/views/mastodon/accounts.ts';
|
import { renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
|
import { NStore } from '@nostrify/nostrify';
|
||||||
|
|
||||||
/** Placeholder to find & replace with metadata. */
|
/** Placeholder to find & replace with metadata. */
|
||||||
const META_PLACEHOLDER = '<!--server-generated-meta-->' as const;
|
const META_PLACEHOLDER = '<!--server-generated-meta-->' as const;
|
||||||
|
|
||||||
export const frontendController: AppMiddleware = async (c) => {
|
export const frontendController: AppMiddleware = async (c) => {
|
||||||
|
const { relay } = c.var;
|
||||||
|
|
||||||
c.header('Cache-Control', 'max-age=86400, s-maxage=30, public, stale-if-error=604800');
|
c.header('Cache-Control', 'max-age=86400, s-maxage=30, public, stale-if-error=604800');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -23,7 +25,7 @@ export const frontendController: AppMiddleware = async (c) => {
|
||||||
if (content.includes(META_PLACEHOLDER)) {
|
if (content.includes(META_PLACEHOLDER)) {
|
||||||
const params = getPathParams(c.req.path);
|
const params = getPathParams(c.req.path);
|
||||||
try {
|
try {
|
||||||
const entities = await getEntities(params ?? {});
|
const entities = await getEntities(relay, params ?? {});
|
||||||
const meta = renderMetadata(c.req.url, entities);
|
const meta = renderMetadata(c.req.url, entities);
|
||||||
return c.html(content.replace(META_PLACEHOLDER, meta));
|
return c.html(content.replace(META_PLACEHOLDER, meta));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -37,11 +39,9 @@ export const frontendController: AppMiddleware = async (c) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function getEntities(params: { acct?: string; statusId?: string }): Promise<MetadataEntities> {
|
async function getEntities(relay: NStore, params: { acct?: string; statusId?: string }): Promise<MetadataEntities> {
|
||||||
const store = await Storages.db();
|
|
||||||
|
|
||||||
const entities: MetadataEntities = {
|
const entities: MetadataEntities = {
|
||||||
instance: await getInstanceMetadata(store),
|
instance: await getInstanceMetadata(relay),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (params.statusId) {
|
if (params.statusId) {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { AppController } from '@/app.ts';
|
import { AppController } from '@/app.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
|
||||||
import { WebManifestCombined } from '@/types/webmanifest.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) => {
|
export const manifestController: AppController = async (c) => {
|
||||||
const meta = await getInstanceMetadata(await Storages.db(), c.req.raw.signal);
|
const { relay, signal } = c.var;
|
||||||
|
|
||||||
|
const meta = await getInstanceMetadata(relay, signal);
|
||||||
|
|
||||||
const manifest: WebManifestCombined = {
|
const manifest: WebManifestCombined = {
|
||||||
description: meta.about,
|
description: meta.about,
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
import denoJson from 'deno.json' with { type: 'json' };
|
import denoJson from 'deno.json' with { type: 'json' };
|
||||||
|
|
||||||
import { AppController } from '@/app.ts';
|
import { AppController } from '@/app.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
|
||||||
import { getInstanceMetadata } from '@/utils/instance.ts';
|
import { getInstanceMetadata } from '@/utils/instance.ts';
|
||||||
|
|
||||||
const relayInfoController: AppController = async (c) => {
|
const relayInfoController: AppController = async (c) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay, signal } = c.var;
|
||||||
const store = await Storages.db();
|
|
||||||
const meta = await getInstanceMetadata(store, c.req.raw.signal);
|
const meta = await getInstanceMetadata(relay, signal);
|
||||||
|
|
||||||
c.res.headers.set('access-control-allow-origin', '*');
|
c.res.headers.set('access-control-allow-origin', '*');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { AppController } from '@/app.ts';
|
||||||
import { relayInfoController } from '@/controllers/nostr/relay-info.ts';
|
import { relayInfoController } from '@/controllers/nostr/relay-info.ts';
|
||||||
import * as pipeline from '@/pipeline.ts';
|
import * as pipeline from '@/pipeline.ts';
|
||||||
import { RelayError } from '@/RelayError.ts';
|
import { RelayError } from '@/RelayError.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { type DittoPgStore } from '@/storages/DittoPgStore.ts';
|
||||||
import { errorJson } from '@/utils/log.ts';
|
import { errorJson } from '@/utils/log.ts';
|
||||||
import { purifyEvent } from '@/utils/purify.ts';
|
import { purifyEvent } from '@/utils/purify.ts';
|
||||||
import { Time } from '@/utils/time.ts';
|
import { Time } from '@/utils/time.ts';
|
||||||
|
|
@ -42,7 +42,7 @@ const limiters = {
|
||||||
const connections = new Set<WebSocket>();
|
const connections = new Set<WebSocket>();
|
||||||
|
|
||||||
/** Set up the Websocket connection. */
|
/** Set up the Websocket connection. */
|
||||||
function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoConf) {
|
function connectStream(conf: DittoConf, relay: DittoPgStore, socket: WebSocket, ip: string | undefined) {
|
||||||
const controllers = new Map<string, AbortController>();
|
const controllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
if (ip) {
|
if (ip) {
|
||||||
|
|
@ -133,10 +133,8 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon
|
||||||
controllers.get(subId)?.abort();
|
controllers.get(subId)?.abort();
|
||||||
controllers.set(subId, controller);
|
controllers.set(subId, controller);
|
||||||
|
|
||||||
const store = await Storages.db();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for await (const [verb, , ...rest] of store.req(filters, { limit: 100, timeout: conf.db.timeouts.relay })) {
|
for await (const [verb, , ...rest] of relay.req(filters, { limit: 100, timeout: conf.db.timeouts.relay })) {
|
||||||
send([verb, subId, ...rest] as NostrRelayMsg);
|
send([verb, subId, ...rest] as NostrRelayMsg);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -185,8 +183,7 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon
|
||||||
/** Handle COUNT. Return the number of events matching the filters. */
|
/** Handle COUNT. Return the number of events matching the filters. */
|
||||||
async function handleCount([_, subId, ...filters]: NostrClientCOUNT): Promise<void> {
|
async function handleCount([_, subId, ...filters]: NostrClientCOUNT): Promise<void> {
|
||||||
if (rateLimited(limiters.req)) return;
|
if (rateLimited(limiters.req)) return;
|
||||||
const store = await Storages.db();
|
const { count } = await relay.count(filters, { timeout: conf.db.timeouts.relay });
|
||||||
const { count } = await store.count(filters, { timeout: conf.db.timeouts.relay });
|
|
||||||
send(['COUNT', subId, { count, approximate: false }]);
|
send(['COUNT', subId, { count, approximate: false }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,7 +196,7 @@ function connectStream(socket: WebSocket, ip: string | undefined, conf: DittoCon
|
||||||
}
|
}
|
||||||
|
|
||||||
const relayController: AppController = (c, next) => {
|
const relayController: AppController = (c, next) => {
|
||||||
const { conf } = c.var;
|
const { conf, relay } = c.var;
|
||||||
const upgrade = c.req.header('upgrade');
|
const upgrade = c.req.header('upgrade');
|
||||||
|
|
||||||
// NIP-11: https://github.com/nostr-protocol/nips/blob/master/11.md
|
// NIP-11: https://github.com/nostr-protocol/nips/blob/master/11.md
|
||||||
|
|
@ -218,7 +215,7 @@ const relayController: AppController = (c, next) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { idleTimeout: 30 });
|
const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { idleTimeout: 30 });
|
||||||
connectStream(socket, ip, conf);
|
connectStream(conf, relay as DittoPgStore, socket, ip);
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,17 @@ const emptyResult: NostrJson = { names: {}, relays: {} };
|
||||||
* https://github.com/nostr-protocol/nips/blob/master/05.md
|
* https://github.com/nostr-protocol/nips/blob/master/05.md
|
||||||
*/
|
*/
|
||||||
const nostrController: AppController = async (c) => {
|
const nostrController: AppController = async (c) => {
|
||||||
|
const { relay } = c.var;
|
||||||
|
|
||||||
// If there are no query parameters, this will always return an empty result.
|
// If there are no query parameters, this will always return an empty result.
|
||||||
if (!Object.entries(c.req.queries()).length) {
|
if (!Object.entries(c.req.queries()).length) {
|
||||||
c.header('Cache-Control', 'max-age=31536000, public, immutable, stale-while-revalidate=86400');
|
c.header('Cache-Control', 'max-age=31536000, public, immutable, stale-while-revalidate=86400');
|
||||||
return c.json(emptyResult);
|
return c.json(emptyResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = c.get('store');
|
|
||||||
|
|
||||||
const result = nameSchema.safeParse(c.req.query('name'));
|
const result = nameSchema.safeParse(c.req.query('name'));
|
||||||
const name = result.success ? result.data : undefined;
|
const name = result.success ? result.data : undefined;
|
||||||
const pointer = name ? await localNip05Lookup(store, name) : undefined;
|
const pointer = name ? await localNip05Lookup(relay, name) : undefined;
|
||||||
|
|
||||||
if (!name || !pointer) {
|
if (!name || !pointer) {
|
||||||
// Not found, cache for 5 minutes.
|
// Not found, cache for 5 minutes.
|
||||||
|
|
|
||||||
|
|
@ -1,116 +0,0 @@
|
||||||
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();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 [user] = await store.query([{
|
|
||||||
kinds: [30382],
|
|
||||||
authors: [await conf.signer.getPublicKey()],
|
|
||||||
'#d': [proof.pubkey],
|
|
||||||
limit: 1,
|
|
||||||
}]);
|
|
||||||
|
|
||||||
if (user && matchesRole(user, role)) {
|
|
||||||
await next();
|
|
||||||
} else {
|
|
||||||
throw new HTTPException(401);
|
|
||||||
}
|
|
||||||
}, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Require the user to demonstrate they own the pubkey by signing an event. */
|
|
||||||
function requireProof(opts?: ParseAuthRequestOpts): AppMiddleware {
|
|
||||||
return withProof(async (_c, _proof, next) => {
|
|
||||||
await next();
|
|
||||||
}, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check whether the user fulfills the role. */
|
|
||||||
function matchesRole(user: NostrEvent, role: UserRole): boolean {
|
|
||||||
return user.tags.some(([tag, value]) => tag === 'n' && value === role);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** HOC to obtain proof in middleware. */
|
|
||||||
function withProof(
|
|
||||||
handler: (c: AppContext, proof: NostrEvent, next: () => Promise<void>) => Promise<void>,
|
|
||||||
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' });
|
|
||||||
}
|
|
||||||
|
|
||||||
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' });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get the proof over Nostr Connect. */
|
|
||||||
async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
|
|
||||||
const signer = c.get('signer');
|
|
||||||
if (!signer) {
|
|
||||||
throw new HTTPException(401, {
|
|
||||||
res: c.json({ error: 'No way to sign Nostr event' }, 401),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const req = localRequest(c);
|
|
||||||
const reqEvent = await buildAuthEventTemplate(req, opts);
|
|
||||||
const resEvent = await signer.signEvent(reqEvent);
|
|
||||||
const result = await validateAuthEvent(req, resEvent, opts);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { auth98Middleware, requireProof, requireRole };
|
|
||||||
|
|
@ -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();
|
|
||||||
};
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { MiddlewareHandler } from '@hono/hono';
|
|
||||||
import { HTTPException } from '@hono/hono/http-exception';
|
|
||||||
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')) {
|
|
||||||
throw new HTTPException(401, { message: 'No pubkey provided' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 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');
|
|
||||||
|
|
||||||
if (!signer) {
|
|
||||||
throw new HTTPException(401, { message: 'No pubkey provided' });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!signer.nip44) {
|
|
||||||
throw new HTTPException(401, { message: 'No NIP-44 signer provided' });
|
|
||||||
}
|
|
||||||
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
import { type DittoConf } from '@ditto/conf';
|
|
||||||
import { MiddlewareHandler } from '@hono/hono';
|
|
||||||
import { HTTPException } from '@hono/hono/http-exception';
|
|
||||||
import { NostrSigner, NSecSigner } from '@nostrify/nostrify';
|
|
||||||
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';
|
|
||||||
|
|
||||||
/** 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 (
|
|
||||||
c,
|
|
||||||
next,
|
|
||||||
) => {
|
|
||||||
const { conf } = c.var;
|
|
||||||
const header = c.req.header('authorization');
|
|
||||||
const match = header?.match(BEARER_REGEX);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
const [_, bech32] = match;
|
|
||||||
|
|
||||||
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
|
|
||||||
.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);
|
|
||||||
|
|
||||||
c.set(
|
|
||||||
'signer',
|
|
||||||
new ConnectSigner({
|
|
||||||
bunkerPubkey,
|
|
||||||
userPubkey,
|
|
||||||
signer: new NSecSigner(nep46Seckey),
|
|
||||||
relays: nip46_relays,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
throw new HTTPException(401);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const decoded = nip19.decode(bech32!);
|
|
||||||
|
|
||||||
switch (decoded.type) {
|
|
||||||
case 'npub':
|
|
||||||
c.set('signer', new ReadOnlySigner(decoded.data));
|
|
||||||
break;
|
|
||||||
case 'nprofile':
|
|
||||||
c.set('signer', new ReadOnlySigner(decoded.data.pubkey));
|
|
||||||
break;
|
|
||||||
case 'nsec':
|
|
||||||
c.set('signer', new NSecSigner(decoded.data));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw new HTTPException(401);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await next();
|
|
||||||
};
|
|
||||||
|
|
@ -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,13 +1,12 @@
|
||||||
import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts';
|
import { CashuMint, CashuWallet, getEncodedToken, type Proof } from '@cashu/cashu-ts';
|
||||||
import { type DittoConf } from '@ditto/conf';
|
|
||||||
import { MiddlewareHandler } from '@hono/hono';
|
import { MiddlewareHandler } from '@hono/hono';
|
||||||
import { HTTPException } from '@hono/hono/http-exception';
|
import { HTTPException } from '@hono/hono/http-exception';
|
||||||
import { getPublicKey } from 'nostr-tools';
|
import { getPublicKey } from 'nostr-tools';
|
||||||
import { NostrFilter, NostrSigner, NSchema as n, NStore } from '@nostrify/nostrify';
|
import { NostrFilter, NSchema as n } from '@nostrify/nostrify';
|
||||||
import { SetRequired } from 'type-fest';
|
|
||||||
import { stringToBytes } from '@scure/base';
|
import { stringToBytes } from '@scure/base';
|
||||||
import { logi } from '@soapbox/logi';
|
import { logi } from '@soapbox/logi';
|
||||||
|
|
||||||
|
import { AppEnv } from '@/app.ts';
|
||||||
import { isNostrId } from '@/utils.ts';
|
import { isNostrId } from '@/utils.ts';
|
||||||
import { errorJson } from '@/utils/log.ts';
|
import { errorJson } from '@/utils/log.ts';
|
||||||
import { createEvent } from '@/utils/api.ts';
|
import { createEvent } from '@/utils/api.ts';
|
||||||
|
|
@ -17,33 +16,28 @@ import { z } from 'zod';
|
||||||
* Swap nutzaps into wallet (create new events) if the user has a wallet, otheriwse, just fallthrough.
|
* 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.
|
* Errors are only thrown if 'signer' and 'store' middlewares are not set.
|
||||||
*/
|
*/
|
||||||
export const swapNutzapsMiddleware: MiddlewareHandler<
|
export const swapNutzapsMiddleware: MiddlewareHandler<AppEnv> = async (c, next) => {
|
||||||
{ Variables: { signer: SetRequired<NostrSigner, 'nip44'>; store: NStore; conf: DittoConf } }
|
const { conf, relay, user, signal } = c.var;
|
||||||
> = async (c, next) => {
|
|
||||||
const { conf } = c.var;
|
|
||||||
const signer = c.get('signer');
|
|
||||||
const store = c.get('store');
|
|
||||||
|
|
||||||
if (!signer) {
|
if (!user) {
|
||||||
throw new HTTPException(401, { message: 'No pubkey provided' });
|
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' });
|
throw new HTTPException(401, { message: 'No NIP-44 signer provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!store) {
|
if (!relay) {
|
||||||
throw new HTTPException(401, { message: 'No store provided' });
|
throw new HTTPException(401, { message: 'No store provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { signal } = c.req.raw;
|
const pubkey = await user.signer.getPublicKey();
|
||||||
const pubkey = await signer.getPublicKey();
|
const [wallet] = await relay.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
||||||
const [wallet] = await store.query([{ authors: [pubkey], kinds: [17375] }], { signal });
|
|
||||||
|
|
||||||
if (wallet) {
|
if (wallet) {
|
||||||
let decryptedContent: string;
|
let decryptedContent: string;
|
||||||
try {
|
try {
|
||||||
decryptedContent = await signer.nip44.decrypt(pubkey, wallet.content);
|
decryptedContent = await user.signer.nip44.decrypt(pubkey, wallet.content);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logi({
|
logi({
|
||||||
level: 'error',
|
level: 'error',
|
||||||
|
|
@ -68,7 +62,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
|
||||||
}
|
}
|
||||||
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
const p2pk = getPublicKey(stringToBytes('hex', privkey));
|
||||||
|
|
||||||
const [nutzapInformation] = await store.query([{ authors: [pubkey], kinds: [10019] }], { signal });
|
const [nutzapInformation] = await relay.query([{ authors: [pubkey], kinds: [10019] }], { signal });
|
||||||
if (!nutzapInformation) {
|
if (!nutzapInformation) {
|
||||||
return c.json({ error: 'You need to have a nutzap information event so we can get the mints.' }, 400);
|
return c.json({ error: 'You need to have a nutzap information event so we can get the mints.' }, 400);
|
||||||
}
|
}
|
||||||
|
|
@ -88,14 +82,14 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
|
||||||
|
|
||||||
const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints };
|
const nutzapsFilter: NostrFilter = { kinds: [9321], '#p': [pubkey], '#u': mints };
|
||||||
|
|
||||||
const [nutzapHistory] = await store.query([{ kinds: [7376], authors: [pubkey] }], { signal });
|
const [nutzapHistory] = await relay.query([{ kinds: [7376], authors: [pubkey] }], { signal });
|
||||||
if (nutzapHistory) {
|
if (nutzapHistory) {
|
||||||
nutzapsFilter.since = nutzapHistory.created_at;
|
nutzapsFilter.since = nutzapHistory.created_at;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {};
|
const mintsToProofs: { [key: string]: { proofs: Proof[]; redeemed: string[][] } } = {};
|
||||||
|
|
||||||
const nutzaps = await store.query([nutzapsFilter], { signal });
|
const nutzaps = await relay.query([nutzapsFilter], { signal });
|
||||||
|
|
||||||
for (const event of nutzaps) {
|
for (const event of nutzaps) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -154,7 +148,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
|
||||||
|
|
||||||
const unspentProofs = await createEvent({
|
const unspentProofs = await createEvent({
|
||||||
kind: 7375,
|
kind: 7375,
|
||||||
content: await signer.nip44.encrypt(
|
content: await user.signer.nip44.encrypt(
|
||||||
pubkey,
|
pubkey,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
mint,
|
mint,
|
||||||
|
|
@ -169,7 +163,7 @@ export const swapNutzapsMiddleware: MiddlewareHandler<
|
||||||
|
|
||||||
await createEvent({
|
await createEvent({
|
||||||
kind: 7376,
|
kind: 7376,
|
||||||
content: await signer.nip44.encrypt(
|
content: await user.signer.nip44.encrypt(
|
||||||
pubkey,
|
pubkey,
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
['direction', 'in'],
|
['direction', 'in'],
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import { AppMiddleware } from '@/app.ts';
|
||||||
|
|
||||||
/** Set an uploader for the user. */
|
/** Set an uploader for the user. */
|
||||||
export const uploaderMiddleware: AppMiddleware = async (c, next) => {
|
export const uploaderMiddleware: AppMiddleware = async (c, next) => {
|
||||||
const { signer, conf } = c.var;
|
const { user, conf } = c.var;
|
||||||
|
const signer = user?.signer;
|
||||||
|
|
||||||
switch (conf.uploader) {
|
switch (conf.uploader) {
|
||||||
case 's3':
|
case 's3':
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ function isProtectedEvent(event: NostrEvent): boolean {
|
||||||
|
|
||||||
/** Hydrate the event with the user, if applicable. */
|
/** Hydrate the event with the user, if applicable. */
|
||||||
async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<void> {
|
async function hydrateEvent(event: DittoEvent, signal: AbortSignal): Promise<void> {
|
||||||
await hydrateEvents({ events: [event], store: await Storages.db(), signal });
|
await hydrateEvents({ events: [event], relay: await Storages.db(), signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maybe store the event, if eligible. */
|
/** Maybe store the event, if eligible. */
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,13 @@ interface GetEventOpts {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a Nostr event by its ID.
|
* Get a Nostr event by its ID.
|
||||||
* @deprecated Use `store.query` directly.
|
* @deprecated Use `relay.query` directly.
|
||||||
*/
|
*/
|
||||||
const getEvent = async (
|
const getEvent = async (
|
||||||
id: string,
|
id: string,
|
||||||
opts: GetEventOpts = {},
|
opts: GetEventOpts = {},
|
||||||
): Promise<DittoEvent | undefined> => {
|
): Promise<DittoEvent | undefined> => {
|
||||||
const store = await Storages.db();
|
const relay = await Storages.db();
|
||||||
const { kind, signal = AbortSignal.timeout(1000) } = opts;
|
const { kind, signal = AbortSignal.timeout(1000) } = opts;
|
||||||
|
|
||||||
const filter: NostrFilter = { ids: [id], limit: 1 };
|
const filter: NostrFilter = { ids: [id], limit: 1 };
|
||||||
|
|
@ -33,23 +33,23 @@ const getEvent = async (
|
||||||
filter.kinds = [kind];
|
filter.kinds = [kind];
|
||||||
}
|
}
|
||||||
|
|
||||||
return await store.query([filter], { limit: 1, signal })
|
return await relay.query([filter], { limit: 1, signal })
|
||||||
.then((events) => hydrateEvents({ events, store, signal }))
|
.then((events) => hydrateEvents({ events, relay, signal }))
|
||||||
.then(([event]) => event);
|
.then(([event]) => event);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a Nostr `set_medatadata` event for a user's pubkey.
|
* Get a Nostr `set_medatadata` event for a user's pubkey.
|
||||||
* @deprecated Use `store.query` directly.
|
* @deprecated Use `relay.query` directly.
|
||||||
*/
|
*/
|
||||||
async function getAuthor(pubkey: string, opts: GetEventOpts = {}): Promise<NostrEvent | undefined> {
|
async function getAuthor(pubkey: string, opts: GetEventOpts = {}): Promise<NostrEvent | undefined> {
|
||||||
const store = await Storages.db();
|
const relay = await Storages.db();
|
||||||
const { signal = AbortSignal.timeout(1000) } = opts;
|
const { signal = AbortSignal.timeout(1000) } = opts;
|
||||||
|
|
||||||
const events = await store.query([{ authors: [pubkey], kinds: [0], limit: 1 }], { limit: 1, signal });
|
const events = await relay.query([{ authors: [pubkey], kinds: [0], limit: 1 }], { limit: 1, signal });
|
||||||
const event = events[0] ?? fallbackAuthor(pubkey);
|
const event = events[0] ?? fallbackAuthor(pubkey);
|
||||||
|
|
||||||
await hydrateEvents({ events: [event], store, signal });
|
await hydrateEvents({ events: [event], relay, signal });
|
||||||
|
|
||||||
return event;
|
return event;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,6 @@ function filteredArray<T extends z.ZodTypeAny>(schema: T) {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */
|
|
||||||
const decode64Schema = z.string().transform((value, ctx) => {
|
|
||||||
try {
|
|
||||||
const binString = atob(value);
|
|
||||||
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0)!);
|
|
||||||
return new TextDecoder().decode(bytes);
|
|
||||||
} catch (_e) {
|
|
||||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid base64', fatal: true });
|
|
||||||
return z.NEVER;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Parses a hashtag, eg `#yolo`. */
|
/** Parses a hashtag, eg `#yolo`. */
|
||||||
const hashtagSchema = z.string().regex(/^\w{1,30}$/);
|
const hashtagSchema = z.string().regex(/^\w{1,30}$/);
|
||||||
|
|
||||||
|
|
@ -96,7 +84,6 @@ const walletSchema = z.object({
|
||||||
|
|
||||||
export {
|
export {
|
||||||
booleanParamSchema,
|
booleanParamSchema,
|
||||||
decode64Schema,
|
|
||||||
fileSchema,
|
fileSchema,
|
||||||
filteredArray,
|
filteredArray,
|
||||||
hashtagSchema,
|
hashtagSchema,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,8 @@
|
||||||
import { NSchema as n } from '@nostrify/nostrify';
|
import { NSchema as n } from '@nostrify/nostrify';
|
||||||
import { getEventHash, verifyEvent } from 'nostr-tools';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { safeUrlSchema, sizesSchema } from '@/schema.ts';
|
import { safeUrlSchema, sizesSchema } from '@/schema.ts';
|
||||||
|
|
||||||
/** Nostr event schema that also verifies the event's signature. */
|
|
||||||
const signedEventSchema = n.event()
|
|
||||||
.refine((event) => event.id === getEventHash(event), 'Event ID does not match hash')
|
|
||||||
.refine(verifyEvent, 'Event signature is invalid');
|
|
||||||
|
|
||||||
/** Kind 0 standardized fields extended with Ditto custom fields. */
|
/** Kind 0 standardized fields extended with Ditto custom fields. */
|
||||||
const metadataSchema = n.metadata().and(z.object({
|
const metadataSchema = n.metadata().and(z.object({
|
||||||
fields: z.tuple([z.string(), z.string()]).array().optional().catch(undefined),
|
fields: z.tuple([z.string(), z.string()]).array().optional().catch(undefined),
|
||||||
|
|
@ -68,12 +62,4 @@ const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()
|
||||||
/** NIP-30 custom emoji tag. */
|
/** NIP-30 custom emoji tag. */
|
||||||
type EmojiTag = z.infer<typeof emojiTagSchema>;
|
type EmojiTag = z.infer<typeof emojiTagSchema>;
|
||||||
|
|
||||||
export {
|
export { type EmojiTag, emojiTagSchema, metadataSchema, relayInfoDocSchema, screenshotsSchema, serverMetaSchema };
|
||||||
type EmojiTag,
|
|
||||||
emojiTagSchema,
|
|
||||||
metadataSchema,
|
|
||||||
relayInfoDocSchema,
|
|
||||||
screenshotsSchema,
|
|
||||||
serverMetaSchema,
|
|
||||||
signedEventSchema,
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { logi } from '@soapbox/logi';
|
||||||
|
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { wsUrlSchema } from '@/schema.ts';
|
import { wsUrlSchema } from '@/schema.ts';
|
||||||
import { AdminStore } from '@/storages/AdminStore.ts';
|
|
||||||
import { DittoPgStore } from '@/storages/DittoPgStore.ts';
|
import { DittoPgStore } from '@/storages/DittoPgStore.ts';
|
||||||
import { getRelays } from '@/utils/outbox.ts';
|
import { getRelays } from '@/utils/outbox.ts';
|
||||||
import { seedZapSplits } from '@/utils/zap-split.ts';
|
import { seedZapSplits } from '@/utils/zap-split.ts';
|
||||||
|
|
@ -13,7 +12,6 @@ import { seedZapSplits } from '@/utils/zap-split.ts';
|
||||||
export class Storages {
|
export class Storages {
|
||||||
private static _db: Promise<DittoPgStore> | undefined;
|
private static _db: Promise<DittoPgStore> | undefined;
|
||||||
private static _database: Promise<DittoDB> | undefined;
|
private static _database: Promise<DittoDB> | undefined;
|
||||||
private static _admin: Promise<AdminStore> | undefined;
|
|
||||||
private static _client: Promise<NPool<NRelay1>> | undefined;
|
private static _client: Promise<NPool<NRelay1>> | undefined;
|
||||||
|
|
||||||
public static async database(): Promise<DittoDB> {
|
public static async database(): Promise<DittoDB> {
|
||||||
|
|
@ -53,14 +51,6 @@ export class Storages {
|
||||||
return this._db;
|
return this._db;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Admin user storage. */
|
|
||||||
public static async admin(): Promise<AdminStore> {
|
|
||||||
if (!this._admin) {
|
|
||||||
this._admin = Promise.resolve(new AdminStore(await this.db()));
|
|
||||||
}
|
|
||||||
return this._admin;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Relay pool storage. */
|
/** Relay pool storage. */
|
||||||
public static async client(): Promise<NPool<NRelay1>> {
|
public static async client(): Promise<NPool<NRelay1>> {
|
||||||
if (!this._client) {
|
if (!this._client) {
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
|
|
||||||
|
|
||||||
import { Conf } from '@/config.ts';
|
|
||||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
|
||||||
import { getTagSet } from '@/utils/tags.ts';
|
|
||||||
|
|
||||||
/** A store that prevents banned users from being displayed. */
|
|
||||||
export class AdminStore implements NStore {
|
|
||||||
constructor(private store: NStore) {}
|
|
||||||
|
|
||||||
async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise<void> {
|
|
||||||
return await this.store.event(event, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise<DittoEvent[]> {
|
|
||||||
const events = await this.store.query(filters, opts);
|
|
||||||
const pubkeys = new Set(events.map((event) => event.pubkey));
|
|
||||||
|
|
||||||
const users = await this.store.query([{
|
|
||||||
kinds: [30382],
|
|
||||||
authors: [await Conf.signer.getPublicKey()],
|
|
||||||
'#d': [...pubkeys],
|
|
||||||
limit: pubkeys.size,
|
|
||||||
}]);
|
|
||||||
|
|
||||||
const adminPubkey = await Conf.signer.getPublicKey();
|
|
||||||
|
|
||||||
return events.filter((event) => {
|
|
||||||
const user = users.find(
|
|
||||||
({ kind, pubkey, tags }) =>
|
|
||||||
kind === 30382 && pubkey === adminPubkey && tags.find(([name]) => name === 'd')?.[1] === event.pubkey,
|
|
||||||
);
|
|
||||||
|
|
||||||
const n = getTagSet(user?.tags ?? [], 'n');
|
|
||||||
|
|
||||||
if (n.has('disabled')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { NostrEvent, NostrFilter, NStore } from '@nostrify/nostrify';
|
|
||||||
|
|
||||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
|
||||||
import { getTagSet } from '@/utils/tags.ts';
|
|
||||||
|
|
||||||
export class UserStore implements NStore {
|
|
||||||
private promise: Promise<DittoEvent[]> | undefined;
|
|
||||||
|
|
||||||
constructor(private pubkey: string, private store: NStore) {}
|
|
||||||
|
|
||||||
async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise<void> {
|
|
||||||
return await this.store.event(event, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Query events that `pubkey` did not mute
|
|
||||||
* https://github.com/nostr-protocol/nips/blob/master/51.md#standard-lists
|
|
||||||
*/
|
|
||||||
async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise<DittoEvent[]> {
|
|
||||||
const events = await this.store.query(filters, opts);
|
|
||||||
const pubkeys = await this.getMutedPubkeys();
|
|
||||||
|
|
||||||
return events.filter((event) => {
|
|
||||||
return event.kind === 0 || !pubkeys.has(event.pubkey);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getMuteList(): Promise<DittoEvent | undefined> {
|
|
||||||
if (!this.promise) {
|
|
||||||
this.promise = this.store.query([{ authors: [this.pubkey], kinds: [10000], limit: 1 }]);
|
|
||||||
}
|
|
||||||
const [muteList] = await this.promise;
|
|
||||||
return muteList;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getMutedPubkeys(): Promise<Set<string>> {
|
|
||||||
const mutedPubkeysEvent = await this.getMuteList();
|
|
||||||
if (!mutedPubkeysEvent) {
|
|
||||||
return new Set();
|
|
||||||
}
|
|
||||||
return getTagSet(mutedPubkeysEvent.tags, 'p');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -18,7 +18,7 @@ Deno.test('hydrateEvents(): author --- WITHOUT stats', async () => {
|
||||||
|
|
||||||
await hydrateEvents({
|
await hydrateEvents({
|
||||||
events: [event1],
|
events: [event1],
|
||||||
store: relay,
|
relay,
|
||||||
kysely: db.kysely,
|
kysely: db.kysely,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ Deno.test('hydrateEvents(): repost --- WITHOUT stats', async () => {
|
||||||
|
|
||||||
await hydrateEvents({
|
await hydrateEvents({
|
||||||
events: [event6],
|
events: [event6],
|
||||||
store: relay,
|
relay,
|
||||||
kysely: db.kysely,
|
kysely: db.kysely,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ Deno.test('hydrateEvents(): quote repost --- WITHOUT stats', async () => {
|
||||||
|
|
||||||
await hydrateEvents({
|
await hydrateEvents({
|
||||||
events: [event1quoteRepost],
|
events: [event1quoteRepost],
|
||||||
store: relay,
|
relay,
|
||||||
kysely: db.kysely,
|
kysely: db.kysely,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -102,7 +102,7 @@ Deno.test('hydrateEvents(): repost of quote repost --- WITHOUT stats', async ()
|
||||||
|
|
||||||
await hydrateEvents({
|
await hydrateEvents({
|
||||||
events: [event6],
|
events: [event6],
|
||||||
store: relay,
|
relay,
|
||||||
kysely: db.kysely,
|
kysely: db.kysely,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -131,7 +131,7 @@ Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stat
|
||||||
|
|
||||||
await hydrateEvents({
|
await hydrateEvents({
|
||||||
events: [reportEvent],
|
events: [reportEvent],
|
||||||
store: relay,
|
relay,
|
||||||
kysely: db.kysely,
|
kysely: db.kysely,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -161,7 +161,7 @@ Deno.test('hydrateEvents(): zap sender, zap amount, zapped post // kind 9735 ---
|
||||||
|
|
||||||
await hydrateEvents({
|
await hydrateEvents({
|
||||||
events: [zapReceipt],
|
events: [zapReceipt],
|
||||||
store: relay,
|
relay,
|
||||||
kysely: db.kysely,
|
kysely: db.kysely,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,14 @@ import { Storages } from '@/storages.ts';
|
||||||
|
|
||||||
interface HydrateOpts {
|
interface HydrateOpts {
|
||||||
events: DittoEvent[];
|
events: DittoEvent[];
|
||||||
store: NStore;
|
relay: NStore;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
kysely?: Kysely<DittoTables>;
|
kysely?: Kysely<DittoTables>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hydrate events using the provided storage. */
|
/** Hydrate events using the provided storage. */
|
||||||
async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
|
async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
|
||||||
const { events, store, signal, kysely = await Storages.kysely() } = opts;
|
const { events, relay, signal, kysely = await Storages.kysely() } = opts;
|
||||||
|
|
||||||
if (!events.length) {
|
if (!events.length) {
|
||||||
return events;
|
return events;
|
||||||
|
|
@ -30,23 +30,23 @@ async function hydrateEvents(opts: HydrateOpts): Promise<DittoEvent[]> {
|
||||||
|
|
||||||
const cache = [...events];
|
const cache = [...events];
|
||||||
|
|
||||||
for (const event of await gatherRelatedEvents({ events: cache, store, signal })) {
|
for (const event of await gatherRelatedEvents({ events: cache, relay, signal })) {
|
||||||
cache.push(event);
|
cache.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const event of await gatherQuotes({ events: cache, store, signal })) {
|
for (const event of await gatherQuotes({ events: cache, relay, signal })) {
|
||||||
cache.push(event);
|
cache.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const event of await gatherProfiles({ events: cache, store, signal })) {
|
for (const event of await gatherProfiles({ events: cache, relay, signal })) {
|
||||||
cache.push(event);
|
cache.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const event of await gatherUsers({ events: cache, store, signal })) {
|
for (const event of await gatherUsers({ events: cache, relay, signal })) {
|
||||||
cache.push(event);
|
cache.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const event of await gatherInfo({ events: cache, store, signal })) {
|
for (const event of await gatherInfo({ events: cache, relay, signal })) {
|
||||||
cache.push(event);
|
cache.push(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,7 +199,7 @@ export function assembleEvents(
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect event targets (eg reposts, quote posts, reacted posts, etc.) */
|
/** Collect event targets (eg reposts, quote posts, reacted posts, etc.) */
|
||||||
function gatherRelatedEvents({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
function gatherRelatedEvents({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
|
|
@ -234,14 +234,14 @@ function gatherRelatedEvents({ events, store, signal }: HydrateOpts): Promise<Di
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return store.query(
|
return relay.query(
|
||||||
[{ ids: [...ids], limit: ids.size }],
|
[{ ids: [...ids], limit: ids.size }],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect quotes from the events. */
|
/** Collect quotes from the events. */
|
||||||
function gatherQuotes({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
function gatherQuotes({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
|
|
@ -253,14 +253,14 @@ function gatherQuotes({ events, store, signal }: HydrateOpts): Promise<DittoEven
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return store.query(
|
return relay.query(
|
||||||
[{ ids: [...ids], limit: ids.size }],
|
[{ ids: [...ids], limit: ids.size }],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect profiles from the events. */
|
/** Collect profiles from the events. */
|
||||||
async function gatherProfiles({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
async function gatherProfiles({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
||||||
const pubkeys = new Set<string>();
|
const pubkeys = new Set<string>();
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
|
|
@ -300,7 +300,7 @@ async function gatherProfiles({ events, store, signal }: HydrateOpts): Promise<D
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const authors = await store.query(
|
const authors = await relay.query(
|
||||||
[{ kinds: [0], authors: [...pubkeys], limit: pubkeys.size }],
|
[{ kinds: [0], authors: [...pubkeys], limit: pubkeys.size }],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
@ -317,21 +317,21 @@ async function gatherProfiles({ events, store, signal }: HydrateOpts): Promise<D
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect users from the events. */
|
/** Collect users from the events. */
|
||||||
async function gatherUsers({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
async function gatherUsers({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
||||||
const pubkeys = new Set(events.map((event) => event.pubkey));
|
const pubkeys = new Set(events.map((event) => event.pubkey));
|
||||||
|
|
||||||
if (!pubkeys.size) {
|
if (!pubkeys.size) {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return store.query(
|
return relay.query(
|
||||||
[{ kinds: [30382], authors: [await Conf.signer.getPublicKey()], '#d': [...pubkeys], limit: pubkeys.size }],
|
[{ kinds: [30382], authors: [await Conf.signer.getPublicKey()], '#d': [...pubkeys], limit: pubkeys.size }],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect info events from the events. */
|
/** Collect info events from the events. */
|
||||||
async function gatherInfo({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
async function gatherInfo({ events, relay, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
||||||
const ids = new Set<string>();
|
const ids = new Set<string>();
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
|
|
@ -344,7 +344,7 @@ async function gatherInfo({ events, store, signal }: HydrateOpts): Promise<Ditto
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return store.query(
|
return relay.query(
|
||||||
[{ kinds: [30383], authors: [await Conf.signer.getPublicKey()], '#d': [...ids], limit: ids.size }],
|
[{ kinds: [30383], authors: [await Conf.signer.getPublicKey()], '#d': [...ids], limit: ids.size }],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { type Context } from '@hono/hono';
|
|
||||||
import { HTTPException } from '@hono/hono/http-exception';
|
import { HTTPException } from '@hono/hono/http-exception';
|
||||||
import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||||
import { logi } from '@soapbox/logi';
|
import { logi } from '@soapbox/logi';
|
||||||
|
|
@ -19,16 +18,16 @@ import { purifyEvent } from '@/utils/purify.ts';
|
||||||
type EventStub = TypeFest.SetOptional<EventTemplate, 'content' | 'created_at' | 'tags'>;
|
type EventStub = TypeFest.SetOptional<EventTemplate, 'content' | 'created_at' | 'tags'>;
|
||||||
|
|
||||||
/** Publish an event through the pipeline. */
|
/** Publish an event through the pipeline. */
|
||||||
async function createEvent(t: EventStub, c: Context): Promise<NostrEvent> {
|
async function createEvent(t: EventStub, c: AppContext): Promise<NostrEvent> {
|
||||||
const signer = c.get('signer');
|
const { user } = c.var;
|
||||||
|
|
||||||
if (!signer) {
|
if (!user) {
|
||||||
throw new HTTPException(401, {
|
throw new HTTPException(401, {
|
||||||
res: c.json({ error: 'No way to sign Nostr event' }, 401),
|
res: c.json({ error: 'No way to sign Nostr event' }, 401),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = await signer.signEvent({
|
const event = await user.signer.signEvent({
|
||||||
content: '',
|
content: '',
|
||||||
created_at: nostrNow(),
|
created_at: nostrNow(),
|
||||||
tags: [],
|
tags: [],
|
||||||
|
|
@ -265,17 +264,10 @@ function paginatedList(
|
||||||
return c.json(results, 200, headers);
|
return c.json(results, 200, headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rewrite the URL of the request object to use the local domain. */
|
|
||||||
function localRequest(c: Context): Request {
|
|
||||||
return Object.create(c.req.raw, {
|
|
||||||
url: { value: Conf.local(c.req.url) },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Actors with Bluesky's `!no-unauthenticated` self-label should require authorization to view. */
|
/** Actors with Bluesky's `!no-unauthenticated` self-label should require authorization to view. */
|
||||||
function assertAuthenticated(c: AppContext, author: NostrEvent): void {
|
function assertAuthenticated(c: AppContext, author: NostrEvent): void {
|
||||||
if (
|
if (
|
||||||
!c.get('signer') && author.tags.some(([name, value, ns]) =>
|
!c.var.user && author.tags.some(([name, value, ns]) =>
|
||||||
name === 'l' &&
|
name === 'l' &&
|
||||||
value === '!no-unauthenticated' &&
|
value === '!no-unauthenticated' &&
|
||||||
ns === 'com.atproto.label.defs#selfLabel'
|
ns === 'com.atproto.label.defs#selfLabel'
|
||||||
|
|
@ -290,7 +282,6 @@ export {
|
||||||
createAdminEvent,
|
createAdminEvent,
|
||||||
createEvent,
|
createEvent,
|
||||||
type EventStub,
|
type EventStub,
|
||||||
localRequest,
|
|
||||||
paginated,
|
paginated,
|
||||||
paginatedList,
|
paginatedList,
|
||||||
parseBody,
|
parseBody,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
import { NostrEvent, NostrFilter } from '@nostrify/nostrify';
|
||||||
|
|
||||||
import { AppContext } from '@/app.ts';
|
import { AppContext } from '@/app.ts';
|
||||||
import { Storages } from '@/storages.ts';
|
import { paginationSchema } from '@/schemas/pagination.ts';
|
||||||
import { renderAccount } from '@/views/mastodon/accounts.ts';
|
import { renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
import { paginated, paginatedList } from '@/utils/api.ts';
|
import { paginated, paginatedList } from '@/utils/api.ts';
|
||||||
|
|
@ -20,13 +20,12 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?:
|
||||||
}
|
}
|
||||||
|
|
||||||
const { signal = AbortSignal.timeout(1000), filterFn } = opts ?? {};
|
const { signal = AbortSignal.timeout(1000), filterFn } = opts ?? {};
|
||||||
|
const { relay } = c.var;
|
||||||
|
|
||||||
const store = await Storages.db();
|
const events = await relay.query(filters, { signal })
|
||||||
|
|
||||||
const events = await store.query(filters, { signal })
|
|
||||||
// Deduplicate by author.
|
// Deduplicate by author.
|
||||||
.then((events) => Array.from(new Map(events.map((event) => [event.pubkey, event])).values()))
|
.then((events) => Array.from(new Map(events.map((event) => [event.pubkey, event])).values()))
|
||||||
.then((events) => hydrateEvents({ events, store, signal }))
|
.then((events) => hydrateEvents({ events, relay, signal }))
|
||||||
.then((events) => filterFn ? events.filter(filterFn) : events);
|
.then((events) => filterFn ? events.filter(filterFn) : events);
|
||||||
|
|
||||||
const accounts = await Promise.all(
|
const accounts = await Promise.all(
|
||||||
|
|
@ -43,14 +42,13 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?:
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderAccounts(c: AppContext, pubkeys: string[]) {
|
async function renderAccounts(c: AppContext, pubkeys: string[]) {
|
||||||
const { offset, limit } = c.get('listPagination');
|
const { offset, limit } = paginationSchema.parse(c.req.query());
|
||||||
const authors = pubkeys.reverse().slice(offset, offset + limit);
|
const authors = pubkeys.reverse().slice(offset, offset + limit);
|
||||||
|
|
||||||
const store = await Storages.db();
|
const { relay, signal } = c.var;
|
||||||
const signal = c.req.raw.signal;
|
|
||||||
|
|
||||||
const events = await store.query([{ kinds: [0], authors }], { signal })
|
const events = await relay.query([{ kinds: [0], authors }], { signal })
|
||||||
.then((events) => hydrateEvents({ events, store, signal }));
|
.then((events) => hydrateEvents({ events, relay, signal }));
|
||||||
|
|
||||||
const accounts = await Promise.all(
|
const accounts = await Promise.all(
|
||||||
authors.map((pubkey) => {
|
authors.map((pubkey) => {
|
||||||
|
|
@ -72,11 +70,11 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = await Storages.db();
|
const { user, relay, pagination } = c.var;
|
||||||
const { limit } = c.get('pagination');
|
const { limit } = pagination;
|
||||||
|
|
||||||
const events = await store.query([{ kinds: [1, 20], ids, limit }], { signal })
|
const events = await relay.query([{ kinds: [1, 20], ids, limit }], { signal })
|
||||||
.then((events) => hydrateEvents({ events, store, signal }));
|
.then((events) => hydrateEvents({ events, relay, signal }));
|
||||||
|
|
||||||
if (!events.length) {
|
if (!events.length) {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
|
|
@ -84,7 +82,7 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal
|
||||||
|
|
||||||
const sortedEvents = [...events].sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
|
const sortedEvents = [...events].sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
|
||||||
|
|
||||||
const viewerPubkey = await c.get('signer')?.getPublicKey();
|
const viewerPubkey = await user?.signer.getPublicKey();
|
||||||
|
|
||||||
const statuses = await Promise.all(
|
const statuses = await Promise.all(
|
||||||
sortedEvents.map((event) => renderStatus(event, { viewerPubkey })),
|
sortedEvents.map((event) => renderStatus(event, { viewerPubkey })),
|
||||||
|
|
|
||||||
18
packages/mastoapi/auth/aes.bench.ts
Normal file
18
packages/mastoapi/auth/aes.bench.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { generateSecretKey } from 'nostr-tools';
|
||||||
|
|
||||||
|
import { aesDecrypt, aesEncrypt } from './aes.ts';
|
||||||
|
|
||||||
|
Deno.bench('aesEncrypt', async (b) => {
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const decrypted = generateSecretKey();
|
||||||
|
b.start();
|
||||||
|
await aesEncrypt(sk, decrypted);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.bench('aesDecrypt', async (b) => {
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const decrypted = generateSecretKey();
|
||||||
|
const encrypted = await aesEncrypt(sk, decrypted);
|
||||||
|
b.start();
|
||||||
|
await aesDecrypt(sk, encrypted);
|
||||||
|
});
|
||||||
15
packages/mastoapi/auth/aes.test.ts
Normal file
15
packages/mastoapi/auth/aes.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { assertEquals } from '@std/assert';
|
||||||
|
import { encodeHex } from '@std/encoding/hex';
|
||||||
|
import { generateSecretKey } from 'nostr-tools';
|
||||||
|
|
||||||
|
import { aesDecrypt, aesEncrypt } from './aes.ts';
|
||||||
|
|
||||||
|
Deno.test('aesDecrypt & aesEncrypt', async () => {
|
||||||
|
const sk = generateSecretKey();
|
||||||
|
const data = generateSecretKey();
|
||||||
|
|
||||||
|
const encrypted = await aesEncrypt(sk, data);
|
||||||
|
const decrypted = await aesDecrypt(sk, encrypted);
|
||||||
|
|
||||||
|
assertEquals(encodeHex(decrypted), encodeHex(data));
|
||||||
|
});
|
||||||
17
packages/mastoapi/auth/aes.ts
Normal file
17
packages/mastoapi/auth/aes.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
/** Encrypt data with AES-GCM and a secret key. */
|
||||||
|
export async function aesEncrypt(sk: Uint8Array, plaintext: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
const buffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, secretKey, plaintext);
|
||||||
|
|
||||||
|
return new Uint8Array([...iv, ...new Uint8Array(buffer)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Decrypt data with AES-GCM and a secret key. */
|
||||||
|
export async function aesDecrypt(sk: Uint8Array, ciphertext: Uint8Array): Promise<Uint8Array> {
|
||||||
|
const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['decrypt']);
|
||||||
|
const iv = ciphertext.slice(0, 12);
|
||||||
|
const buffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, secretKey, ciphertext.slice(12));
|
||||||
|
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
11
packages/mastoapi/auth/token.bench.ts
Normal file
11
packages/mastoapi/auth/token.bench.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { generateToken, getTokenHash } from './token.ts';
|
||||||
|
|
||||||
|
Deno.bench('generateToken', async () => {
|
||||||
|
await generateToken();
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.bench('getTokenHash', async (b) => {
|
||||||
|
const { token } = await generateToken();
|
||||||
|
b.start();
|
||||||
|
await getTokenHash(token);
|
||||||
|
});
|
||||||
18
packages/mastoapi/auth/token.test.ts
Normal file
18
packages/mastoapi/auth/token.test.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { assertEquals } from '@std/assert';
|
||||||
|
import { decodeHex, encodeHex } from '@std/encoding/hex';
|
||||||
|
|
||||||
|
import { generateToken, getTokenHash } from './token.ts';
|
||||||
|
|
||||||
|
Deno.test('generateToken', async () => {
|
||||||
|
const sk = decodeHex('a0968751df8fd42f362213f08751911672f2a037113b392403bbb7dd31b71c95');
|
||||||
|
|
||||||
|
const { token, hash } = await generateToken(sk);
|
||||||
|
|
||||||
|
assertEquals(token, 'token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd');
|
||||||
|
assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('getTokenHash', async () => {
|
||||||
|
const hash = await getTokenHash('token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd');
|
||||||
|
assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a');
|
||||||
|
});
|
||||||
30
packages/mastoapi/auth/token.ts
Normal file
30
packages/mastoapi/auth/token.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { bech32 } from '@scure/base';
|
||||||
|
import { generateSecretKey } from 'nostr-tools';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an auth token for the API.
|
||||||
|
*
|
||||||
|
* Returns a bech32 encoded API token and the SHA-256 hash of the bytes.
|
||||||
|
* The token should be presented to the user, but only the hash should be stored in the database.
|
||||||
|
*/
|
||||||
|
export async function generateToken(sk = generateSecretKey()): Promise<{ token: `token1${string}`; hash: Uint8Array }> {
|
||||||
|
const words = bech32.toWords(sk);
|
||||||
|
const token = bech32.encode('token', words);
|
||||||
|
|
||||||
|
const buffer = await crypto.subtle.digest('SHA-256', sk);
|
||||||
|
const hash = new Uint8Array(buffer);
|
||||||
|
|
||||||
|
return { token, hash };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the SHA-256 hash of an API token.
|
||||||
|
* First decodes from bech32 then hashes the bytes.
|
||||||
|
* Used to identify the user in the database by the hash of their token.
|
||||||
|
*/
|
||||||
|
export async function getTokenHash(token: `token1${string}`): Promise<Uint8Array> {
|
||||||
|
const { bytes: sk } = bech32.decodeToBytes(token);
|
||||||
|
const buffer = await crypto.subtle.digest('SHA-256', sk);
|
||||||
|
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
9
packages/mastoapi/deno.json
Normal file
9
packages/mastoapi/deno.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"name": "@ditto/mastoapi",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"exports": {
|
||||||
|
"./middleware": "./middleware/mod.ts",
|
||||||
|
"./router": "./router/mod.ts",
|
||||||
|
"./test": "./test.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
packages/mastoapi/middleware/User.ts
Normal file
6
packages/mastoapi/middleware/User.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { NostrSigner, NRelay } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
export interface User<S extends NostrSigner = NostrSigner, R extends NRelay = NRelay> {
|
||||||
|
signer: S;
|
||||||
|
relay: R;
|
||||||
|
}
|
||||||
5
packages/mastoapi/middleware/mod.ts
Normal file
5
packages/mastoapi/middleware/mod.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export { paginationMiddleware } from './paginationMiddleware.ts';
|
||||||
|
export { tokenMiddleware } from './tokenMiddleware.ts';
|
||||||
|
export { userMiddleware } from './userMiddleware.ts';
|
||||||
|
|
||||||
|
export type { User } from './User.ts';
|
||||||
81
packages/mastoapi/middleware/paginationMiddleware.ts
Normal file
81
packages/mastoapi/middleware/paginationMiddleware.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { paginated, paginatedList } from '../pagination/paginate.ts';
|
||||||
|
import { paginationSchema } from '../pagination/schema.ts';
|
||||||
|
|
||||||
|
import type { DittoMiddleware } from '@ditto/mastoapi/router';
|
||||||
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
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();
|
||||||
|
};
|
||||||
|
}
|
||||||
136
packages/mastoapi/middleware/tokenMiddleware.ts
Normal file
136
packages/mastoapi/middleware/tokenMiddleware.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
import { parseAuthRequest } from '@ditto/nip98';
|
||||||
|
import { HTTPException } from '@hono/hono/http-exception';
|
||||||
|
import { type NostrSigner, NSecSigner } from '@nostrify/nostrify';
|
||||||
|
import { nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
|
import { aesDecrypt } from '../auth/aes.ts';
|
||||||
|
import { getTokenHash } from '../auth/token.ts';
|
||||||
|
import { ConnectSigner } from '../signers/ConnectSigner.ts';
|
||||||
|
import { ReadOnlySigner } from '../signers/ReadOnlySigner.ts';
|
||||||
|
import { UserStore } from '../storages/UserStore.ts';
|
||||||
|
|
||||||
|
import type { DittoEnv, DittoMiddleware } from '@ditto/mastoapi/router';
|
||||||
|
import type { Context } from '@hono/hono';
|
||||||
|
import type { User } from './User.ts';
|
||||||
|
|
||||||
|
type CredentialsFn = (c: Context) => string | undefined;
|
||||||
|
|
||||||
|
export function tokenMiddleware(fn?: CredentialsFn): DittoMiddleware<{ user?: User }> {
|
||||||
|
return async (c, next) => {
|
||||||
|
const header = fn ? fn(c) : c.req.header('authorization');
|
||||||
|
|
||||||
|
if (header) {
|
||||||
|
const { relay, conf } = c.var;
|
||||||
|
|
||||||
|
const auth = parseAuthorization(header);
|
||||||
|
const signer = await getSigner(c, auth);
|
||||||
|
const userPubkey = await signer.getPublicKey();
|
||||||
|
const adminPubkey = await conf.signer.getPublicKey();
|
||||||
|
|
||||||
|
const user: User = {
|
||||||
|
signer,
|
||||||
|
relay: new UserStore({ relay, userPubkey, adminPubkey }),
|
||||||
|
};
|
||||||
|
|
||||||
|
c.set('user', user);
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSigner(c: Context<DittoEnv>, auth: Authorization): NostrSigner | Promise<NostrSigner> {
|
||||||
|
switch (auth.realm) {
|
||||||
|
case 'Bearer': {
|
||||||
|
if (isToken(auth.token)) {
|
||||||
|
return getSignerFromToken(c, auth.token);
|
||||||
|
} else {
|
||||||
|
return getSignerFromNip19(auth.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'Nostr': {
|
||||||
|
return getSignerFromNip98(c);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new HTTPException(400, { message: 'Unsupported Authorization realm.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSignerFromToken(c: Context<DittoEnv>, token: `token1${string}`): Promise<NostrSigner> {
|
||||||
|
const { conf, db, relay } = c.var;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokenHash = await getTokenHash(token);
|
||||||
|
|
||||||
|
const row = 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, row.nip46_sk_enc);
|
||||||
|
|
||||||
|
return new ConnectSigner({
|
||||||
|
bunkerPubkey: row.bunker_pubkey,
|
||||||
|
userPubkey: row.pubkey,
|
||||||
|
signer: new NSecSigner(nep46Seckey),
|
||||||
|
relays: row.nip46_relays,
|
||||||
|
relay,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
throw new HTTPException(401, { message: 'Token is wrong or expired.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSignerFromNip19(bech32: string): NostrSigner {
|
||||||
|
try {
|
||||||
|
const decoded = nip19.decode(bech32);
|
||||||
|
|
||||||
|
switch (decoded.type) {
|
||||||
|
case 'npub':
|
||||||
|
return new ReadOnlySigner(decoded.data);
|
||||||
|
case 'nprofile':
|
||||||
|
return new ReadOnlySigner(decoded.data.pubkey);
|
||||||
|
case 'nsec':
|
||||||
|
return new NSecSigner(decoded.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fallthrough
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HTTPException(401, { message: 'Invalid NIP-19 identifier in Authorization header.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSignerFromNip98(c: Context<DittoEnv>): Promise<NostrSigner> {
|
||||||
|
const { conf } = c.var;
|
||||||
|
|
||||||
|
const req = Object.create(c.req.raw, {
|
||||||
|
url: { value: conf.local(c.req.url) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await parseAuthRequest(req);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return new ReadOnlySigner(result.data.pubkey);
|
||||||
|
} else {
|
||||||
|
throw new HTTPException(401, { message: 'Invalid NIP-98 event in Authorization header.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Authorization {
|
||||||
|
realm: string;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAuthorization(header: string): Authorization {
|
||||||
|
const [realm, ...parts] = header.split(' ');
|
||||||
|
return {
|
||||||
|
realm,
|
||||||
|
token: parts.join(' '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isToken(value: string): value is `token1${string}` {
|
||||||
|
return value.startsWith('token1');
|
||||||
|
}
|
||||||
74
packages/mastoapi/middleware/userMiddleware.test.ts
Normal file
74
packages/mastoapi/middleware/userMiddleware.test.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { setUser, testApp } from '@ditto/mastoapi/test';
|
||||||
|
import { assertEquals } from '@std/assert';
|
||||||
|
|
||||||
|
import { userMiddleware } from './userMiddleware.ts';
|
||||||
|
import { ReadOnlySigner } from '../signers/ReadOnlySigner.ts';
|
||||||
|
|
||||||
|
Deno.test('no user 401', async () => {
|
||||||
|
const { app } = testApp();
|
||||||
|
const response = await app.use(userMiddleware()).request('/');
|
||||||
|
assertEquals(response.status, 401);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('unsupported signer 400', async () => {
|
||||||
|
const { app, relay } = testApp();
|
||||||
|
const signer = new ReadOnlySigner('0461fcbecc4c3374439932d6b8f11269ccdb7cc973ad7a50ae362db135a474dd');
|
||||||
|
|
||||||
|
const response = await app
|
||||||
|
.use(setUser({ signer, relay }))
|
||||||
|
.use(userMiddleware({ enc: 'nip44' }))
|
||||||
|
.use((c, next) => {
|
||||||
|
c.var.user.signer.nip44.encrypt; // test that the type is set
|
||||||
|
return next();
|
||||||
|
})
|
||||||
|
.request('/');
|
||||||
|
|
||||||
|
assertEquals(response.status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('with user 200', async () => {
|
||||||
|
const { app, user } = testApp();
|
||||||
|
|
||||||
|
const response = await app
|
||||||
|
.use(setUser(user))
|
||||||
|
.use(userMiddleware())
|
||||||
|
.get('/', (c) => c.text('ok'))
|
||||||
|
.request('/');
|
||||||
|
|
||||||
|
assertEquals(response.status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('user and role 403', async () => {
|
||||||
|
const { app, user } = testApp();
|
||||||
|
|
||||||
|
const response = await app
|
||||||
|
.use(setUser(user))
|
||||||
|
.use(userMiddleware({ role: 'admin' }))
|
||||||
|
.request('/');
|
||||||
|
|
||||||
|
assertEquals(response.status, 403);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('admin role 200', async () => {
|
||||||
|
const { conf, app, user, relay } = testApp();
|
||||||
|
|
||||||
|
const event = await conf.signer.signEvent({
|
||||||
|
kind: 30382,
|
||||||
|
tags: [
|
||||||
|
['d', await user.signer.getPublicKey()],
|
||||||
|
['n', 'admin'],
|
||||||
|
],
|
||||||
|
content: '',
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
await relay.event(event);
|
||||||
|
|
||||||
|
const response = await app
|
||||||
|
.use(setUser(user))
|
||||||
|
.use(userMiddleware({ role: 'admin' }))
|
||||||
|
.get('/', (c) => c.text('ok'))
|
||||||
|
.request('/');
|
||||||
|
|
||||||
|
assertEquals(response.status, 200);
|
||||||
|
});
|
||||||
77
packages/mastoapi/middleware/userMiddleware.ts
Normal file
77
packages/mastoapi/middleware/userMiddleware.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { buildAuthEventTemplate, validateAuthEvent } from '@ditto/nip98';
|
||||||
|
import { HTTPException } from '@hono/hono/http-exception';
|
||||||
|
|
||||||
|
import type { DittoMiddleware } from '@ditto/mastoapi/router';
|
||||||
|
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
|
||||||
|
import type { SetRequired } from 'type-fest';
|
||||||
|
import type { User } from './User.ts';
|
||||||
|
|
||||||
|
type Nip44Signer = SetRequired<NostrSigner, 'nip44'>;
|
||||||
|
|
||||||
|
interface UserMiddlewareOpts {
|
||||||
|
enc?: 'nip04' | 'nip44';
|
||||||
|
role?: string;
|
||||||
|
verify?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userMiddleware(): DittoMiddleware<{ user: User }>;
|
||||||
|
// @ts-ignore Types are right.
|
||||||
|
export function userMiddleware(
|
||||||
|
opts: UserMiddlewareOpts & { enc: 'nip44' },
|
||||||
|
): DittoMiddleware<{ user: User<Nip44Signer> }>;
|
||||||
|
export function userMiddleware(opts: UserMiddlewareOpts): DittoMiddleware<{ user: User }>;
|
||||||
|
export function userMiddleware(opts: UserMiddlewareOpts = {}): DittoMiddleware<{ user: User }> {
|
||||||
|
return async (c, next) => {
|
||||||
|
const { conf, user, relay } = c.var;
|
||||||
|
const { enc, role, verify } = opts;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new HTTPException(401, { message: 'Authorization required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enc && !user.signer[enc]) {
|
||||||
|
throw new HTTPException(400, { message: `User does not have a ${enc} signer` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role || verify) {
|
||||||
|
const req = setRequestUrl(c.req.raw, conf.local(c.req.url));
|
||||||
|
const reqEvent = await buildAuthEventTemplate(req);
|
||||||
|
const resEvent = await user.signer.signEvent(reqEvent);
|
||||||
|
const result = await validateAuthEvent(req, resEvent);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new HTTPException(401, { message: 'Verification failed' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent people from accidentally using the wrong account. This has no other security implications.
|
||||||
|
if (result.data.pubkey !== await user.signer.getPublicKey()) {
|
||||||
|
throw new HTTPException(401, { message: 'Pubkey mismatch' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role) {
|
||||||
|
const [user] = await relay.query([{
|
||||||
|
kinds: [30382],
|
||||||
|
authors: [await conf.signer.getPublicKey()],
|
||||||
|
'#d': [result.data.pubkey],
|
||||||
|
limit: 1,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
if (!user || !matchesRole(user, role)) {
|
||||||
|
throw new HTTPException(403, { message: `Must have ${role} role` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrite the URL of the request object. */
|
||||||
|
function setRequestUrl(req: Request, url: string): Request {
|
||||||
|
return Object.create(req, { url: { value: url } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether the user fulfills the role. */
|
||||||
|
function matchesRole(user: NostrEvent, role: string): boolean {
|
||||||
|
return user.tags.some(([tag, value]) => tag === 'n' && value === role);
|
||||||
|
}
|
||||||
34
packages/mastoapi/pagination/link-header.test.ts
Normal file
34
packages/mastoapi/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/mastoapi/pagination/link-header.ts
Normal file
39
packages/mastoapi/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"`;
|
||||||
|
}
|
||||||
0
packages/mastoapi/pagination/paginate.test.ts
Normal file
0
packages/mastoapi/pagination/paginate.test.ts
Normal file
43
packages/mastoapi/pagination/paginate.ts
Normal file
43
packages/mastoapi/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/mastoapi/pagination/schema.test.ts
Normal file
23
packages/mastoapi/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,
|
||||||
|
});
|
||||||
|
});
|
||||||
14
packages/mastoapi/pagination/schema.ts
Normal file
14
packages/mastoapi/pagination/schema.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/** Schema to parse pagination query params. */
|
||||||
|
export const paginationSchema = z.object({
|
||||||
|
max_id: z.string().transform((val) => {
|
||||||
|
if (!val.includes('-')) return val;
|
||||||
|
return val.split('-')[1];
|
||||||
|
}).optional().catch(undefined),
|
||||||
|
min_id: z.string().optional().catch(undefined),
|
||||||
|
since: z.coerce.number().nonnegative().optional().catch(undefined),
|
||||||
|
until: z.coerce.number().nonnegative().optional().catch(undefined),
|
||||||
|
limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)),
|
||||||
|
offset: z.coerce.number().nonnegative().catch(0),
|
||||||
|
});
|
||||||
23
packages/mastoapi/router/DittoApp.test.ts
Normal file
23
packages/mastoapi/router/DittoApp.test.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { DittoConf } from '@ditto/conf';
|
||||||
|
import { DittoPolyPg } 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 = DittoPolyPg.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/mastoapi/router/DittoApp.ts
Normal file
21
packages/mastoapi/router/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/mastoapi/router/DittoEnv.ts
Normal file
20
packages/mastoapi/router/DittoEnv.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { DittoConf } from '@ditto/conf';
|
||||||
|
import type { DittoDB } 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: DittoDB;
|
||||||
|
/** Abort signal for the request. */
|
||||||
|
signal: AbortSignal;
|
||||||
|
};
|
||||||
|
}
|
||||||
5
packages/mastoapi/router/DittoMiddleware.ts
Normal file
5
packages/mastoapi/router/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/mastoapi/router/DittoRoute.test.ts
Normal file
12
packages/mastoapi/router/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/mastoapi/router/DittoRoute.ts
Normal file
53
packages/mastoapi/router/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.relay) this.throwMissingVar('relay');
|
||||||
|
if (!vars.signal) this.throwMissingVar('signal');
|
||||||
|
|
||||||
|
return {
|
||||||
|
...vars,
|
||||||
|
db: vars.db,
|
||||||
|
conf: vars.conf,
|
||||||
|
relay: vars.relay,
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
5
packages/mastoapi/router/mod.ts
Normal file
5
packages/mastoapi/router/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 { DittoMiddleware } from './DittoMiddleware.ts';
|
||||||
124
packages/mastoapi/signers/ConnectSigner.ts
Normal file
124
packages/mastoapi/signers/ConnectSigner.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
// deno-lint-ignore-file require-await
|
||||||
|
import { HTTPException } from '@hono/hono/http-exception';
|
||||||
|
import { NConnectSigner, type NostrEvent, type NostrSigner, type NRelay } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
interface ConnectSignerOpts {
|
||||||
|
relay: NRelay;
|
||||||
|
bunkerPubkey: string;
|
||||||
|
userPubkey: string;
|
||||||
|
signer: NostrSigner;
|
||||||
|
relays?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NIP-46 signer.
|
||||||
|
*
|
||||||
|
* Simple extension of nostrify's `NConnectSigner`, with our options to keep it DRY.
|
||||||
|
*/
|
||||||
|
export class ConnectSigner implements NostrSigner {
|
||||||
|
private signer: Promise<NConnectSigner>;
|
||||||
|
|
||||||
|
constructor(private opts: ConnectSignerOpts) {
|
||||||
|
this.signer = this.init(opts.signer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(signer: NostrSigner): Promise<NConnectSigner> {
|
||||||
|
return new NConnectSigner({
|
||||||
|
encryption: 'nip44',
|
||||||
|
pubkey: this.opts.bunkerPubkey,
|
||||||
|
relay: this.opts.relay,
|
||||||
|
signer,
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async signEvent(event: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>): Promise<NostrEvent> {
|
||||||
|
const signer = await this.signer;
|
||||||
|
try {
|
||||||
|
return await signer.signEvent(event);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name === 'AbortError') {
|
||||||
|
throw new HTTPException(408, { message: 'The event was not signed quickly enough' });
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readonly nip04 = {
|
||||||
|
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
|
||||||
|
const signer = await this.signer;
|
||||||
|
try {
|
||||||
|
return await signer.nip04.encrypt(pubkey, plaintext);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name === 'AbortError') {
|
||||||
|
throw new HTTPException(408, {
|
||||||
|
message: 'Text was not encrypted quickly enough',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
||||||
|
const signer = await this.signer;
|
||||||
|
try {
|
||||||
|
return await signer.nip04.decrypt(pubkey, ciphertext);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name === 'AbortError') {
|
||||||
|
throw new HTTPException(408, {
|
||||||
|
message: 'Text was not decrypted quickly enough',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly nip44 = {
|
||||||
|
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
|
||||||
|
const signer = await this.signer;
|
||||||
|
try {
|
||||||
|
return await signer.nip44.encrypt(pubkey, plaintext);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name === 'AbortError') {
|
||||||
|
throw new HTTPException(408, {
|
||||||
|
message: 'Text was not encrypted quickly enough',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
|
||||||
|
const signer = await this.signer;
|
||||||
|
try {
|
||||||
|
return await signer.nip44.decrypt(pubkey, ciphertext);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name === 'AbortError') {
|
||||||
|
throw new HTTPException(408, {
|
||||||
|
message: 'Text was not decrypted quickly enough',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prevent unnecessary NIP-46 round-trips.
|
||||||
|
async getPublicKey(): Promise<string> {
|
||||||
|
return this.opts.userPubkey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the user's relays if they passed in an `nprofile` auth token. */
|
||||||
|
async getRelays(): Promise<Record<string, { read: boolean; write: boolean }>> {
|
||||||
|
return this.opts.relays?.reduce<Record<string, { read: boolean; write: boolean }>>((acc, relay) => {
|
||||||
|
acc[relay] = { read: true, write: true };
|
||||||
|
return acc;
|
||||||
|
}, {}) ?? {};
|
||||||
|
}
|
||||||
|
}
|
||||||
18
packages/mastoapi/signers/ReadOnlySigner.ts
Normal file
18
packages/mastoapi/signers/ReadOnlySigner.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// deno-lint-ignore-file require-await
|
||||||
|
import { HTTPException } from '@hono/hono/http-exception';
|
||||||
|
|
||||||
|
import type { NostrEvent, NostrSigner } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
export class ReadOnlySigner implements NostrSigner {
|
||||||
|
constructor(private pubkey: string) {}
|
||||||
|
|
||||||
|
async signEvent(): Promise<NostrEvent> {
|
||||||
|
throw new HTTPException(401, {
|
||||||
|
message: 'Log in with Nostr Connect to sign events',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicKey(): Promise<string> {
|
||||||
|
return this.pubkey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { MockRelay } from '@nostrify/nostrify/test';
|
import { MockRelay } from '@nostrify/nostrify/test';
|
||||||
|
|
||||||
import { assertEquals } from '@std/assert';
|
import { assertEquals } from '@std/assert';
|
||||||
import { UserStore } from '@/storages/UserStore.ts';
|
|
||||||
|
import { UserStore } from './UserStore.ts';
|
||||||
|
|
||||||
import userBlack from '~/fixtures/events/kind-0-black.json' with { type: 'json' };
|
import userBlack from '~/fixtures/events/kind-0-black.json' with { type: 'json' };
|
||||||
import userMe from '~/fixtures/events/event-0-makes-repost-with-quote-repost.json' with { type: 'json' };
|
import userMe from '~/fixtures/events/event-0-makes-repost-with-quote-repost.json' with { type: 'json' };
|
||||||
|
|
@ -14,9 +14,8 @@ Deno.test('query events of users that are not muted', async () => {
|
||||||
const blockEventCopy = structuredClone(blockEvent);
|
const blockEventCopy = structuredClone(blockEvent);
|
||||||
const event1authorUserMeCopy = structuredClone(event1authorUserMe);
|
const event1authorUserMeCopy = structuredClone(event1authorUserMe);
|
||||||
|
|
||||||
const db = new MockRelay();
|
const relay = new MockRelay();
|
||||||
|
const store = new UserStore({ relay, userPubkey: userBlackCopy.pubkey });
|
||||||
const store = new UserStore(userBlackCopy.pubkey, db);
|
|
||||||
|
|
||||||
await store.event(blockEventCopy);
|
await store.event(blockEventCopy);
|
||||||
await store.event(userBlackCopy);
|
await store.event(userBlackCopy);
|
||||||
|
|
@ -30,9 +29,8 @@ Deno.test('user never muted anyone', async () => {
|
||||||
const userBlackCopy = structuredClone(userBlack);
|
const userBlackCopy = structuredClone(userBlack);
|
||||||
const userMeCopy = structuredClone(userMe);
|
const userMeCopy = structuredClone(userMe);
|
||||||
|
|
||||||
const db = new MockRelay();
|
const relay = new MockRelay();
|
||||||
|
const store = new UserStore({ relay, userPubkey: userBlackCopy.pubkey });
|
||||||
const store = new UserStore(userBlackCopy.pubkey, db);
|
|
||||||
|
|
||||||
await store.event(userBlackCopy);
|
await store.event(userBlackCopy);
|
||||||
await store.event(userMeCopy);
|
await store.event(userMeCopy);
|
||||||
73
packages/mastoapi/storages/UserStore.ts
Normal file
73
packages/mastoapi/storages/UserStore.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import type {
|
||||||
|
NostrEvent,
|
||||||
|
NostrFilter,
|
||||||
|
NostrRelayCLOSED,
|
||||||
|
NostrRelayEOSE,
|
||||||
|
NostrRelayEVENT,
|
||||||
|
NRelay,
|
||||||
|
} from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
interface UserStoreOpts {
|
||||||
|
relay: NRelay;
|
||||||
|
userPubkey: string;
|
||||||
|
adminPubkey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserStore implements NRelay {
|
||||||
|
constructor(private opts: UserStoreOpts) {}
|
||||||
|
|
||||||
|
req(
|
||||||
|
filters: NostrFilter[],
|
||||||
|
opts?: { signal?: AbortSignal },
|
||||||
|
): AsyncIterable<NostrRelayEVENT | NostrRelayEOSE | NostrRelayCLOSED> {
|
||||||
|
// TODO: support req maybe? It would be inefficient.
|
||||||
|
return this.opts.relay.req(filters, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
async event(event: NostrEvent, opts?: { signal?: AbortSignal }): Promise<void> {
|
||||||
|
return await this.opts.relay.event(event, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query events that `pubkey` did not mute
|
||||||
|
* https://github.com/nostr-protocol/nips/blob/master/51.md#standard-lists
|
||||||
|
*/
|
||||||
|
async query(filters: NostrFilter[], opts: { signal?: AbortSignal; limit?: number } = {}): Promise<NostrEvent[]> {
|
||||||
|
const { relay, userPubkey, adminPubkey } = this.opts;
|
||||||
|
|
||||||
|
const mutes = new Set<string>();
|
||||||
|
const [muteList] = await this.opts.relay.query([{ authors: [userPubkey], kinds: [10000], limit: 1 }]);
|
||||||
|
|
||||||
|
for (const [name, value] of muteList?.tags ?? []) {
|
||||||
|
if (name === 'p') {
|
||||||
|
mutes.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await relay.query(filters, opts);
|
||||||
|
|
||||||
|
const users = adminPubkey
|
||||||
|
? await relay.query([{
|
||||||
|
kinds: [30382],
|
||||||
|
authors: [adminPubkey],
|
||||||
|
'#d': [...events.map(({ pubkey }) => pubkey)],
|
||||||
|
}])
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return events.filter((event) => {
|
||||||
|
const user = users.find((user) => user.tags.find(([name]) => name === 'd')?.[1] === event.pubkey);
|
||||||
|
|
||||||
|
for (const [name, value] of user?.tags ?? []) {
|
||||||
|
if (name === 'n' && value === 'disabled') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return event.kind === 0 || !mutes.has(event.pubkey);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
close(): Promise<void> {
|
||||||
|
return this.opts.relay.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
41
packages/mastoapi/test.ts
Normal file
41
packages/mastoapi/test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { DittoConf } from '@ditto/conf';
|
||||||
|
import { type DittoDB, DummyDB } from '@ditto/db';
|
||||||
|
import { DittoApp, type DittoMiddleware } from '@ditto/mastoapi/router';
|
||||||
|
import { type NostrSigner, type NRelay, NSecSigner } from '@nostrify/nostrify';
|
||||||
|
import { MockRelay } from '@nostrify/nostrify/test';
|
||||||
|
import { generateSecretKey, nip19 } from 'nostr-tools';
|
||||||
|
|
||||||
|
import type { User } from '@ditto/mastoapi/middleware';
|
||||||
|
|
||||||
|
export function testApp(): {
|
||||||
|
app: DittoApp;
|
||||||
|
relay: NRelay;
|
||||||
|
conf: DittoConf;
|
||||||
|
db: DittoDB;
|
||||||
|
user: {
|
||||||
|
signer: NostrSigner;
|
||||||
|
relay: NRelay;
|
||||||
|
};
|
||||||
|
} {
|
||||||
|
const db = new DummyDB();
|
||||||
|
|
||||||
|
const nsec = nip19.nsecEncode(generateSecretKey());
|
||||||
|
const conf = new DittoConf(new Map([['DITTO_NSEC', nsec]]));
|
||||||
|
|
||||||
|
const relay = new MockRelay();
|
||||||
|
const app = new DittoApp({ conf, relay, db });
|
||||||
|
|
||||||
|
const user = {
|
||||||
|
signer: new NSecSigner(generateSecretKey()),
|
||||||
|
relay,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { app, relay, conf, db, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUser<S extends NostrSigner>(user: User<S>): DittoMiddleware<{ user: User<S> }> {
|
||||||
|
return async (c, next) => {
|
||||||
|
c.set('user', user);
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
}
|
||||||
7
packages/nip98/deno.json
Normal file
7
packages/nip98/deno.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"name": "@ditto/nip98",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"exports": {
|
||||||
|
".": "./nip98.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { NostrEvent, NSchema as n } from '@nostrify/nostrify';
|
import { type NostrEvent, NSchema as n } from '@nostrify/nostrify';
|
||||||
import { encodeHex } from '@std/encoding/hex';
|
import { encodeHex } from '@std/encoding/hex';
|
||||||
import { EventTemplate, nip13 } from 'nostr-tools';
|
import { type EventTemplate, nip13 } from 'nostr-tools';
|
||||||
|
|
||||||
import { decode64Schema } from '@/schema.ts';
|
import { decode64Schema, signedEventSchema } from './schema.ts';
|
||||||
import { signedEventSchema } from '@/schemas/nostr.ts';
|
|
||||||
import { eventAge, findTag, nostrNow } from '@/utils.ts';
|
import type { z } from 'zod';
|
||||||
import { Time } from '@/utils/time.ts';
|
|
||||||
|
|
||||||
/** Decode a Nostr event from a base64 encoded string. */
|
/** Decode a Nostr event from a base64 encoded string. */
|
||||||
const decode64EventSchema = decode64Schema.pipe(n.json()).pipe(signedEventSchema);
|
const decode64EventSchema = decode64Schema.pipe(n.json()).pipe(signedEventSchema);
|
||||||
|
|
@ -21,7 +20,10 @@ interface ParseAuthRequestOpts {
|
||||||
|
|
||||||
/** Parse the auth event from a Request, returning a zod SafeParse type. */
|
/** Parse the auth event from a Request, returning a zod SafeParse type. */
|
||||||
// deno-lint-ignore require-await
|
// deno-lint-ignore require-await
|
||||||
async function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) {
|
async function parseAuthRequest(
|
||||||
|
req: Request,
|
||||||
|
opts: ParseAuthRequestOpts = {},
|
||||||
|
): Promise<z.SafeParseReturnType<NostrEvent, NostrEvent> | z.SafeParseError<string>> {
|
||||||
const header = req.headers.get('authorization');
|
const header = req.headers.get('authorization');
|
||||||
const base64 = header?.match(/^Nostr (.+)$/)?.[1];
|
const base64 = header?.match(/^Nostr (.+)$/)?.[1];
|
||||||
const result = decode64EventSchema.safeParse(base64);
|
const result = decode64EventSchema.safeParse(base64);
|
||||||
|
|
@ -31,8 +33,12 @@ async function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compare the auth event with the request, returning a zod SafeParse type. */
|
/** Compare the auth event with the request, returning a zod SafeParse type. */
|
||||||
function validateAuthEvent(req: Request, event: NostrEvent, opts: ParseAuthRequestOpts = {}) {
|
function validateAuthEvent(
|
||||||
const { maxAge = Time.minutes(1), validatePayload = true, pow = 0 } = opts;
|
req: Request,
|
||||||
|
event: NostrEvent,
|
||||||
|
opts: ParseAuthRequestOpts = {},
|
||||||
|
): Promise<z.SafeParseReturnType<NostrEvent, NostrEvent>> {
|
||||||
|
const { maxAge = 60_000, validatePayload = true, pow = 0 } = opts;
|
||||||
|
|
||||||
const schema = signedEventSchema
|
const schema = signedEventSchema
|
||||||
.refine((event) => event.kind === 27235, 'Event must be kind 27235')
|
.refine((event) => event.kind === 27235, 'Event must be kind 27235')
|
||||||
|
|
@ -87,4 +93,19 @@ function tagValue(event: NostrEvent, tagName: string): string | undefined {
|
||||||
return findTag(event.tags, tagName)?.[1];
|
return findTag(event.tags, tagName)?.[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get the current time in Nostr format. */
|
||||||
|
const nostrNow = (): number => Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
/** Convenience function to convert Nostr dates into native Date objects. */
|
||||||
|
const nostrDate = (seconds: number): Date => new Date(seconds * 1000);
|
||||||
|
|
||||||
|
/** Return the event's age in milliseconds. */
|
||||||
|
function eventAge(event: NostrEvent): number {
|
||||||
|
return Date.now() - nostrDate(event.created_at).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTag(tags: string[][], name: string): string[] | undefined {
|
||||||
|
return tags.find((tag) => tag[0] === name);
|
||||||
|
}
|
||||||
|
|
||||||
export { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts, validateAuthEvent };
|
export { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts, validateAuthEvent };
|
||||||
20
packages/nip98/schema.ts
Normal file
20
packages/nip98/schema.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { NSchema as n } from '@nostrify/nostrify';
|
||||||
|
import { getEventHash, verifyEvent } from 'nostr-tools';
|
||||||
|
import z from 'zod';
|
||||||
|
|
||||||
|
/** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */
|
||||||
|
export const decode64Schema = z.string().transform((value, ctx) => {
|
||||||
|
try {
|
||||||
|
const binString = atob(value);
|
||||||
|
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0)!);
|
||||||
|
return new TextDecoder().decode(bytes);
|
||||||
|
} catch (_e) {
|
||||||
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid base64', fatal: true });
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Nostr event schema that also verifies the event's signature. */
|
||||||
|
export const signedEventSchema = n.event()
|
||||||
|
.refine((event) => event.id === getEventHash(event), 'Event ID does not match hash')
|
||||||
|
.refine(verifyEvent, 'Event signature is invalid');
|
||||||
|
|
@ -25,7 +25,7 @@ Deno.test('block event: muted user cannot post', async () => {
|
||||||
|
|
||||||
const ok = await policy.call(event1authorUserMeCopy);
|
const ok = await policy.call(event1authorUserMeCopy);
|
||||||
|
|
||||||
assertEquals(ok, ['OK', event1authorUserMeCopy.id, false, 'blocked: Your account has been deactivated.']);
|
assertEquals(ok, ['OK', event1authorUserMeCopy.id, false, 'blocked: account blocked']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('allow event: user is NOT muted because there is no muted event', async () => {
|
Deno.test('allow event: user is NOT muted because there is no muted event', async () => {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ export class MuteListPolicy implements NPolicy {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pubkeys.has(event.pubkey)) {
|
if (pubkeys.has(event.pubkey)) {
|
||||||
return ['OK', event.id, false, 'blocked: Your account has been deactivated.'];
|
return ['OK', event.id, false, 'blocked: account blocked'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['OK', event.id, true, ''];
|
return ['OK', event.id, true, ''];
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue