Merge branch 'signer-middleware' into 'main'

Add a signerMiddleware, c.get('pubkey') -> c.get('signer')

See merge request soapbox-pub/ditto!251
This commit is contained in:
Alex Gleason 2024-05-14 19:51:58 +00:00
commit a3597edb90
22 changed files with 240 additions and 223 deletions

View File

@ -1,9 +1,8 @@
import { NostrEvent, NStore } from '@nostrify/nostrify';
import { NostrEvent, NostrSigner, NStore } from '@nostrify/nostrify';
import Debug from '@soapbox/stickynotes/debug';
import { type Context, Env as HonoEnv, type Handler, Hono, Input as HonoInput, type MiddlewareHandler } from 'hono';
import { cors, logger, serveStatic } from 'hono/middleware';
import { type User } from '@/db/users.ts';
import '@/firehose.ts';
import { Time } from '@/utils.ts';
@ -29,6 +28,7 @@ import { adminAccountAction, adminAccountsController } from '@/controllers/api/a
import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts';
import { blocksController } from '@/controllers/api/blocks.ts';
import { bookmarksController } from '@/controllers/api/bookmarks.ts';
import { adminRelaysController, adminSetRelaysController } from '@/controllers/api/ditto.ts';
import { emptyArrayController, emptyObjectController, notImplementedController } from '@/controllers/api/fallback.ts';
import { instanceController } from '@/controllers/api/instance.ts';
import { markersController, updateMarkersController } from '@/controllers/api/markers.ts';
@ -80,25 +80,21 @@ import { hostMetaController } from '@/controllers/well-known/host-meta.ts';
import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts';
import { nostrController } from '@/controllers/well-known/nostr.ts';
import { webfingerController } from '@/controllers/well-known/webfinger.ts';
import { auth19, requirePubkey } from '@/middleware/auth19.ts';
import { auth98, requireProof, requireRole } from '@/middleware/auth98.ts';
import { cache } from '@/middleware/cache.ts';
import { csp } from '@/middleware/csp.ts';
import { adminRelaysController, adminSetRelaysController } from '@/controllers/api/ditto.ts';
import { storeMiddleware } from '@/middleware/store.ts';
import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts';
import { cacheMiddleware } from '@/middleware/cacheMiddleware.ts';
import { cspMiddleware } from '@/middleware/cspMiddleware.ts';
import { requireSigner } from '@/middleware/requireSigner.ts';
import { signerMiddleware } from '@/middleware/signerMiddleware.ts';
import { storeMiddleware } from '@/middleware/storeMiddleware.ts';
import { blockController } from '@/controllers/api/accounts.ts';
import { unblockController } from '@/controllers/api/accounts.ts';
interface AppEnv extends HonoEnv {
Variables: {
/** Hex pubkey for the current user. If provided, the user is considered "logged in." */
pubkey?: string;
/** Hex secret key for the current user. Optional, but easiest way to use legacy Mastodon apps. */
seckey?: Uint8Array;
/** 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;
/** NIP-98 signed event proving the pubkey is owned by the user. */
proof?: NostrEvent;
/** User associated with the pubkey, if any. */
user?: User;
/** Store */
store: NStore;
};
@ -123,7 +119,14 @@ app.get('/api/v1/streaming', streamingController);
app.get('/api/v1/streaming/', streamingController);
app.get('/relay', relayController);
app.use('*', csp(), cors({ origin: '*', exposeHeaders: ['link'] }), auth19, auth98(), storeMiddleware);
app.use(
'*',
cspMiddleware(),
cors({ origin: '*', exposeHeaders: ['link'] }),
signerMiddleware,
auth98Middleware(),
storeMiddleware,
);
app.get('/.well-known/webfinger', webfingerController);
app.get('/.well-known/host-meta', hostMetaController);
@ -134,7 +137,7 @@ app.get('/users/:username', actorController);
app.get('/nodeinfo/:version', nodeInfoSchemaController);
app.get('/api/v1/instance', cache({ cacheName: 'web', expires: Time.minutes(5) }), instanceController);
app.get('/api/v1/instance', cacheMiddleware({ cacheName: 'web', expires: Time.minutes(5) }), instanceController);
app.get('/api/v1/apps/verify_credentials', appCredentialsController);
app.post('/api/v1/apps', createAppController);
@ -145,17 +148,17 @@ app.post('/oauth/authorize', oauthAuthorizeController);
app.get('/oauth/authorize', oauthController);
app.post('/api/v1/accounts', requireProof({ pow: 20 }), createAccountController);
app.get('/api/v1/accounts/verify_credentials', requirePubkey, verifyCredentialsController);
app.patch('/api/v1/accounts/update_credentials', requirePubkey, updateCredentialsController);
app.get('/api/v1/accounts/verify_credentials', requireSigner, verifyCredentialsController);
app.patch('/api/v1/accounts/update_credentials', requireSigner, updateCredentialsController);
app.get('/api/v1/accounts/search', accountSearchController);
app.get('/api/v1/accounts/lookup', accountLookupController);
app.get('/api/v1/accounts/relationships', requirePubkey, relationshipsController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/block', requirePubkey, blockController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unblock', requirePubkey, unblockController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/mute', requirePubkey, muteController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unmute', requirePubkey, unmuteController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/follow', requirePubkey, followController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unfollow', requirePubkey, unfollowController);
app.get('/api/v1/accounts/relationships', requireSigner, relationshipsController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/block', requireSigner, blockController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unblock', requireSigner, unblockController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/mute', requireSigner, muteController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unmute', requireSigner, unmuteController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/follow', requireSigner, followController);
app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/unfollow', requireSigner, unfollowController);
app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/followers', followersController);
app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/following', followingController);
app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/statuses', accountStatusesController);
@ -165,21 +168,21 @@ 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}}/context', contextController);
app.get('/api/v1/statuses/:id{[0-9a-f]{64}}', statusController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/favourite', requirePubkey, favouriteController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', requirePubkey, bookmarkController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', requirePubkey, unbookmarkController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', requirePubkey, pinController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', requirePubkey, unpinController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/zap', requirePubkey, zapController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/reblog', requirePubkey, reblogStatusController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unreblog', requirePubkey, unreblogStatusController);
app.post('/api/v1/statuses', requirePubkey, createStatusController);
app.delete('/api/v1/statuses/:id{[0-9a-f]{64}}', requirePubkey, deleteStatusController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/favourite', requireSigner, favouriteController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/bookmark', requireSigner, bookmarkController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unbookmark', requireSigner, unbookmarkController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/pin', requireSigner, pinController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unpin', requireSigner, unpinController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/zap', requireSigner, zapController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/reblog', requireSigner, reblogStatusController);
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unreblog', requireSigner, unreblogStatusController);
app.post('/api/v1/statuses', requireSigner, createStatusController);
app.delete('/api/v1/statuses/:id{[0-9a-f]{64}}', requireSigner, deleteStatusController);
app.post('/api/v1/media', mediaController);
app.post('/api/v2/media', mediaController);
app.get('/api/v1/timelines/home', requirePubkey, homeTimelineController);
app.get('/api/v1/timelines/home', requireSigner, homeTimelineController);
app.get('/api/v1/timelines/public', publicTimelineController);
app.get('/api/v1/timelines/tag/:hashtag', hashtagTimelineController);
@ -189,17 +192,21 @@ app.get('/api/v2/search', searchController);
app.get('/api/pleroma/frontend_configurations', frontendConfigController);
app.get('/api/v1/trends/tags', cache({ cacheName: 'web', expires: Time.minutes(15) }), trendingTagsController);
app.get('/api/v1/trends', cache({ cacheName: 'web', expires: Time.minutes(15) }), trendingTagsController);
app.get(
'/api/v1/trends/tags',
cacheMiddleware({ cacheName: 'web', expires: Time.minutes(15) }),
trendingTagsController,
);
app.get('/api/v1/trends', cacheMiddleware({ cacheName: 'web', expires: Time.minutes(15) }), trendingTagsController);
app.get('/api/v1/suggestions', suggestionsV1Controller);
app.get('/api/v2/suggestions', suggestionsV2Controller);
app.get('/api/v1/notifications', requirePubkey, notificationsController);
app.get('/api/v1/favourites', requirePubkey, favouritesController);
app.get('/api/v1/bookmarks', requirePubkey, bookmarksController);
app.get('/api/v1/blocks', requirePubkey, blocksController);
app.get('/api/v1/mutes', requirePubkey, mutesController);
app.get('/api/v1/notifications', requireSigner, notificationsController);
app.get('/api/v1/favourites', requireSigner, favouritesController);
app.get('/api/v1/bookmarks', requireSigner, bookmarksController);
app.get('/api/v1/blocks', requireSigner, blocksController);
app.get('/api/v1/mutes', requireSigner, mutesController);
app.get('/api/v1/markers', requireProof(), markersController);
app.post('/api/v1/markers', requireProof(), updateMarkersController);
@ -212,17 +219,17 @@ app.delete('/api/v1/pleroma/admin/statuses/:id', requireRole('admin'), pleromaAd
app.get('/api/v1/admin/ditto/relays', requireRole('admin'), adminRelaysController);
app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysController);
app.post('/api/v1/reports', requirePubkey, reportController);
app.get('/api/v1/admin/reports', requirePubkey, requireRole('admin'), adminReportsController);
app.get('/api/v1/admin/reports/:id{[0-9a-f]{64}}', requirePubkey, requireRole('admin'), adminReportController);
app.post('/api/v1/reports', requireSigner, reportController);
app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController);
app.get('/api/v1/admin/reports/:id{[0-9a-f]{64}}', requireSigner, requireRole('admin'), adminReportController);
app.post(
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/resolve',
requirePubkey,
requireSigner,
requireRole('admin'),
adminReportResolveController,
);
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requirePubkey, requireRole('admin'), adminAccountAction);
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', requireSigner, requireRole('admin'), adminAccountAction);
// Not (yet) implemented.
app.get('/api/v1/custom_emojis', emptyArrayController);

View File

@ -29,7 +29,7 @@ const createAccountSchema = z.object({
});
const createAccountController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const result = createAccountSchema.safeParse(await c.req.json());
if (!result.success) {
@ -45,7 +45,7 @@ const createAccountController: AppController = async (c) => {
};
const verifyCredentialsController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const event = await getAuthor(pubkey, { relations: ['author_stats'] });
if (event) {
@ -122,7 +122,7 @@ const accountSearchController: AppController = async (c) => {
};
const relationshipsController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const ids = z.array(z.string()).safeParse(c.req.queries('id[]'));
if (!ids.success) {
@ -178,7 +178,11 @@ const accountStatusesController: AppController = async (c) => {
return events;
});
const statuses = await Promise.all(events.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') })));
const viewerPubkey = await c.get('signer')?.getPublicKey();
const statuses = await Promise.all(
events.map((event) => renderStatus(event, { viewerPubkey })),
);
return paginated(c, events, statuses);
};
@ -194,7 +198,7 @@ const updateCredentialsSchema = z.object({
});
const updateCredentialsController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const body = await parseBody(c.req.raw);
const result = updateCredentialsSchema.safeParse(body);
@ -236,7 +240,7 @@ const updateCredentialsController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/accounts/#follow */
const followController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!;
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey');
await updateListEvent(
@ -253,7 +257,7 @@ const followController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/accounts/#unfollow */
const unfollowController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!;
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey');
await updateListEvent(
@ -290,7 +294,7 @@ const unblockController: AppController = (c) => {
/** https://docs.joinmastodon.org/methods/accounts/#mute */
const muteController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!;
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey');
await updateListEvent(
@ -305,7 +309,7 @@ const muteController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/accounts/#unmute */
const unmuteController: AppController = async (c) => {
const sourcePubkey = c.get('pubkey')!;
const sourcePubkey = await c.get('signer')?.getPublicKey()!;
const targetPubkey = c.req.param('pubkey');
await updateListEvent(
@ -319,7 +323,7 @@ const unmuteController: AppController = async (c) => {
};
const favouritesController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const params = paginationSchema.parse(c.req.query());
const { signal } = c.req.raw;
@ -335,7 +339,11 @@ const favouritesController: AppController = async (c) => {
const events1 = await Storages.db.query([{ kinds: [1], ids }], { signal })
.then((events) => hydrateEvents({ events, storage: Storages.db, signal }));
const statuses = await Promise.all(events1.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') })));
const viewerPubkey = await c.get('signer')?.getPublicKey();
const statuses = await Promise.all(
events1.map((event) => renderStatus(event, { viewerPubkey })),
);
return paginated(c, events1, statuses);
};

View File

@ -5,7 +5,7 @@ import { renderStatuses } from '@/views.ts';
/** https://docs.joinmastodon.org/methods/bookmarks/#get */
const bookmarksController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw;
const [event10003] = await Storages.db.query(

View File

@ -14,7 +14,7 @@ interface Marker {
}
export const markersController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const timelines = c.req.queries('timeline[]') ?? [];
const results = await kv.getMany<Marker[]>(
@ -37,7 +37,7 @@ const markerDataSchema = z.object({
});
export const updateMarkersController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const record = z.record(z.enum(['home', 'notifications']), markerDataSchema).parse(await parseBody(c.req.raw));
const timelines = Object.keys(record) as Timeline[];

View File

@ -14,7 +14,7 @@ const mediaBodySchema = z.object({
});
const mediaController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const result = mediaBodySchema.safeParse(await parseBody(c.req.raw));
const { signal } = c.req.raw;

View File

@ -5,7 +5,7 @@ import { renderAccounts } from '@/views.ts';
/** https://docs.joinmastodon.org/methods/mutes/#get */
const mutesController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw;
const [event10000] = await Storages.db.query(

View File

@ -5,8 +5,8 @@ import { hydrateEvents } from '@/storages/hydrate.ts';
import { paginated, paginationSchema } from '@/utils/api.ts';
import { renderNotification } from '@/views/mastodon/notifications.ts';
const notificationsController: AppController = (c) => {
const pubkey = c.get('pubkey')!;
const notificationsController: AppController = async (c) => {
const pubkey = await c.get('signer')?.getPublicKey()!;
const { since, until } = paginationSchema.parse(c.req.query());
return renderNotifications(c, [{ kinds: [1, 6, 7], '#p': [pubkey], since, until }]);
@ -14,7 +14,7 @@ const notificationsController: AppController = (c) => {
async function renderNotifications(c: AppContext, filters: NostrFilter[]) {
const store = c.get('store');
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const { signal } = c.req.raw;
const events = await store

View File

@ -55,9 +55,15 @@ const reportController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/admin/reports/#get */
const adminReportsController: AppController = async (c) => {
const store = c.get('store');
const viewerPubkey = await c.get('signer')?.getPublicKey();
const reports = await store.query([{ kinds: [1984], '#P': [Conf.pubkey] }])
.then((events) => hydrateEvents({ storage: store, events: events, signal: c.req.raw.signal }))
.then((events) => Promise.all(events.map((event) => renderAdminReport(event, { viewerPubkey: c.get('pubkey') }))));
.then((events) =>
Promise.all(
events.map((event) => renderAdminReport(event, { viewerPubkey })),
)
);
return c.json(reports);
};
@ -67,7 +73,7 @@ const adminReportController: AppController = async (c) => {
const eventId = c.req.param('id');
const { signal } = c.req.raw;
const store = c.get('store');
const pubkey = c.get('pubkey');
const pubkey = await c.get('signer')?.getPublicKey();
const [event] = await store.query([{
kinds: [1984],
@ -89,7 +95,7 @@ const adminReportResolveController: AppController = async (c) => {
const eventId = c.req.param('id');
const { signal } = c.req.raw;
const store = c.get('store');
const pubkey = c.get('pubkey');
const pubkey = await c.get('signer')?.getPublicKey();
const [event] = await store.query([{
kinds: [1984],

View File

@ -43,6 +43,7 @@ const searchController: AppController = async (c) => {
}
const results = dedupeEvents(events);
const viewerPubkey = await c.get('signer')?.getPublicKey();
const [accounts, statuses] = await Promise.all([
Promise.all(
@ -54,7 +55,7 @@ const searchController: AppController = async (c) => {
Promise.all(
results
.filter((event) => event.kind === 1)
.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') }))
.map((event) => renderStatus(event, { viewerPubkey }))
.filter(Boolean),
),
]);

View File

@ -47,7 +47,7 @@ const statusController: AppController = async (c) => {
});
if (event) {
return c.json(await renderStatus(event, { viewerPubkey: c.get('pubkey') }));
return c.json(await renderStatus(event, { viewerPubkey: await c.get('signer')?.getPublicKey() }));
}
return c.json({ error: 'Event not found.' }, 404);
@ -89,9 +89,11 @@ const createStatusController: AppController = async (c) => {
tags.push(['subject', data.spoiler_text]);
}
const viewerPubkey = await c.get('signer')?.getPublicKey();
if (data.media_ids?.length) {
const media = await getUnattachedMediaByIds(data.media_ids)
.then((media) => media.filter(({ pubkey }) => pubkey === c.get('pubkey')))
.then((media) => media.filter(({ pubkey }) => pubkey === viewerPubkey))
.then((media) => media.map(({ url, data }) => ['media', url, data]));
tags.push(...media);
@ -143,12 +145,12 @@ const createStatusController: AppController = async (c) => {
});
}
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: c.get('pubkey') }));
return c.json(await renderStatus({ ...event, author }, { viewerPubkey: await c.get('signer')?.getPublicKey() }));
};
const deleteStatusController: AppController = async (c) => {
const id = c.req.param('id');
const pubkey = c.get('pubkey');
const pubkey = await c.get('signer')?.getPublicKey();
const event = await getEvent(id, { signal: c.req.raw.signal });
@ -172,9 +174,12 @@ const deleteStatusController: AppController = async (c) => {
const contextController: AppController = async (c) => {
const id = c.req.param('id');
const event = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] });
const viewerPubkey = await c.get('signer')?.getPublicKey();
async function renderStatuses(events: NostrEvent[]) {
const statuses = await Promise.all(events.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') })));
const statuses = await Promise.all(
events.map((event) => renderStatus(event, { viewerPubkey })),
);
return statuses.filter(Boolean);
}
@ -204,7 +209,7 @@ const favouriteController: AppController = async (c) => {
],
}, c);
const status = await renderStatus(target, { viewerPubkey: c.get('pubkey') });
const status = await renderStatus(target, { viewerPubkey: await c.get('signer')?.getPublicKey() });
if (status) {
status.favourited = true;
@ -247,7 +252,7 @@ const reblogStatusController: AppController = async (c) => {
signal: signal,
});
const status = await renderReblog(reblogEvent, { viewerPubkey: c.get('pubkey') });
const status = await renderReblog(reblogEvent, { viewerPubkey: await c.get('signer')?.getPublicKey() });
return c.json(status);
};
@ -255,7 +260,7 @@ const reblogStatusController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unreblog */
const unreblogStatusController: AppController = async (c) => {
const eventId = c.req.param('id');
const pubkey = c.get('pubkey') as string;
const pubkey = await c.get('signer')?.getPublicKey() as string;
const event = await getEvent(eventId, {
kind: 1,
@ -282,7 +287,7 @@ const rebloggedByController: AppController = (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#bookmark */
const bookmarkController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id');
const event = await getEvent(eventId, {
@ -309,7 +314,7 @@ const bookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unbookmark */
const unbookmarkController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id');
const event = await getEvent(eventId, {
@ -336,7 +341,7 @@ const unbookmarkController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#pin */
const pinController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id');
const event = await getEvent(eventId, {
@ -363,7 +368,7 @@ const pinController: AppController = async (c) => {
/** https://docs.joinmastodon.org/methods/statuses/#unpin */
const unpinController: AppController = async (c) => {
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const eventId = c.req.param('id');
const { signal } = c.req.raw;
@ -423,7 +428,7 @@ const zapController: AppController = async (c) => {
],
}, c);
const status = await renderStatus(target, { viewerPubkey: c.get('pubkey') });
const status = await renderStatus(target, { viewerPubkey: await c.get('signer')?.getPublicKey() });
status.zapped = true;
return c.json(status);

View File

@ -11,7 +11,7 @@ import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts';
const homeTimelineController: AppController = async (c) => {
const params = paginationSchema.parse(c.req.query());
const pubkey = c.get('pubkey')!;
const pubkey = await c.get('signer')?.getPublicKey()!;
const authors = await getFeedPubkeys(pubkey);
return renderStatuses(c, [{ authors, kinds: [1, 6], ...params }]);
};
@ -61,11 +61,13 @@ async function renderStatuses(c: AppContext, filters: NostrFilter[]) {
return c.json([]);
}
const viewerPubkey = await c.get('signer')?.getPublicKey();
const statuses = (await Promise.all(events.map((event) => {
if (event.kind === 6) {
return renderReblog(event, { viewerPubkey: c.get('pubkey') });
return renderReblog(event, { viewerPubkey });
}
return renderStatus(event, { viewerPubkey: c.get('pubkey') });
return renderStatus(event, { viewerPubkey });
}))).filter((boolean) => boolean);
if (!statuses.length) {

View File

@ -1,49 +0,0 @@
import { HTTPException } from 'hono';
import { getPublicKey, nip19 } from 'nostr-tools';
import { type AppMiddleware } from '@/app.ts';
/** We only accept "Bearer" type. */
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
/** NIP-19 auth middleware. */
const auth19: AppMiddleware = async (c, next) => {
const authHeader = c.req.header('authorization');
const match = authHeader?.match(BEARER_REGEX);
if (match) {
const [_, bech32] = match;
try {
const decoded = nip19.decode(bech32!);
switch (decoded.type) {
case 'npub':
c.set('pubkey', decoded.data);
break;
case 'nprofile':
c.set('pubkey', decoded.data.pubkey);
break;
case 'nsec':
c.set('pubkey', getPublicKey(decoded.data));
c.set('seckey', decoded.data);
break;
}
} catch (_e) {
//
}
}
await next();
};
/** Throw a 401 if the pubkey isn't set. */
const requirePubkey: AppMiddleware = async (c, next) => {
if (!c.get('pubkey')) {
throw new HTTPException(401, { message: 'No pubkey provided' });
}
await next();
};
export { auth19, requirePubkey };

View File

@ -1,27 +1,28 @@
import { NostrEvent } from '@nostrify/nostrify';
import { HTTPException } from 'hono';
import { type AppContext, type AppMiddleware } from '@/app.ts';
import { findUser, User } from '@/db/users.ts';
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
import { localRequest } from '@/utils/api.ts';
import {
buildAuthEventTemplate,
parseAuthRequest,
type ParseAuthRequestOpts,
validateAuthEvent,
} from '@/utils/nip98.ts';
import { localRequest } from '@/utils/api.ts';
import { APISigner } from '@/signers/APISigner.ts';
import { findUser, User } from '@/db/users.ts';
/**
* NIP-98 auth.
* https://github.com/nostr-protocol/nips/blob/master/98.md
*/
function auth98(opts: ParseAuthRequestOpts = {}): AppMiddleware {
function auth98Middleware(opts: ParseAuthRequestOpts = {}): AppMiddleware {
return async (c, next) => {
const req = localRequest(c);
const result = await parseAuthRequest(req, opts);
if (result.success) {
c.set('pubkey', result.data.pubkey);
c.set('signer', new ConnectSigner(result.data.pubkey));
c.set('proof', result.data);
}
@ -33,9 +34,8 @@ 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) => {
return withProof(async (_c, proof, next) => {
const user = await findUser({ pubkey: proof.pubkey });
c.set('user', user);
if (user && matchesRole(user, role)) {
await next();
@ -70,7 +70,7 @@ function withProof(
opts?: ParseAuthRequestOpts,
): AppMiddleware {
return async (c, next) => {
const pubkey = c.get('pubkey');
const pubkey = await c.get('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.
@ -79,7 +79,7 @@ function withProof(
}
if (proof) {
c.set('pubkey', proof.pubkey);
c.set('signer', new ConnectSigner(proof.pubkey));
c.set('proof', proof);
await handler(c, proof, next);
} else {
@ -90,9 +90,16 @@ function withProof(
/** 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 new APISigner(c).signEvent(reqEvent);
const resEvent = await signer.signEvent(reqEvent);
const result = await validateAuthEvent(req, resEvent, opts);
if (result.success) {
@ -100,4 +107,4 @@ async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
}
}
export { auth98, requireProof, requireRole };
export { auth98Middleware, requireProof, requireRole };

View File

@ -5,7 +5,7 @@ import ExpiringCache from '@/utils/expiring-cache.ts';
const debug = Debug('ditto:middleware:cache');
export const cache = (options: {
export const cacheMiddleware = (options: {
cacheName: string;
expires?: number;
}): MiddlewareHandler => {

View File

@ -1,7 +1,7 @@
import { AppMiddleware } from '@/app.ts';
import { Conf } from '@/config.ts';
const csp = (): AppMiddleware => {
export const cspMiddleware = (): AppMiddleware => {
return async (c, next) => {
const { host, protocol, origin } = Conf.url;
const wsProtocol = protocol === 'http:' ? 'ws:' : 'wss:';
@ -26,5 +26,3 @@ const csp = (): AppMiddleware => {
await next();
};
};
export { csp };

View File

@ -0,0 +1,12 @@
import { HTTPException } from 'hono';
import { AppMiddleware } from '@/app.ts';
/** Throw a 401 if a signer isn't set. */
export const requireSigner: AppMiddleware = async (c, next) => {
if (!c.get('signer')) {
throw new HTTPException(401, { message: 'No pubkey provided' });
}
await next();
};

View File

@ -0,0 +1,41 @@
import { NSecSigner } from '@nostrify/nostrify';
import { Stickynotes } from '@soapbox/stickynotes';
import { nip19 } from 'nostr-tools';
import { AppMiddleware } from '@/app.ts';
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
const console = new Stickynotes('ditto:signerMiddleware');
/** 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: AppMiddleware = async (c, next) => {
const header = c.req.header('authorization');
const match = header?.match(BEARER_REGEX);
if (match) {
const [_, bech32] = match;
try {
const decoded = nip19.decode(bech32!);
switch (decoded.type) {
case 'npub':
c.set('signer', new ConnectSigner(decoded.data));
break;
case 'nprofile':
c.set('signer', new ConnectSigner(decoded.data.pubkey, decoded.data.relays));
break;
case 'nsec':
c.set('signer', new NSecSigner(decoded.data));
break;
}
} catch {
console.debug('The user is not logged in');
}
}
await next();
};

View File

@ -3,8 +3,8 @@ import { UserStore } from '@/storages/UserStore.ts';
import { Storages } from '@/storages.ts';
/** Store middleware. */
const storeMiddleware: AppMiddleware = async (c, next) => {
const pubkey = c.get('pubkey');
export const storeMiddleware: AppMiddleware = async (c, next) => {
const pubkey = await c.get('signer')?.getPublicKey();
if (pubkey) {
const store = new UserStore(pubkey, Storages.admin);
@ -14,5 +14,3 @@ const storeMiddleware: AppMiddleware = async (c, next) => {
}
await next();
};
export { storeMiddleware };

View File

@ -1,65 +0,0 @@
// deno-lint-ignore-file require-await
import { NConnectSigner, NostrEvent, NostrSigner, NSecSigner } from '@nostrify/nostrify';
import { HTTPException } from 'hono';
import { type AppContext } from '@/app.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Storages } from '@/storages.ts';
/**
* Sign Nostr event using the app context.
*
* - If a secret key is provided, it will be used to sign the event.
* - Otherwise, it will use NIP-46 to sign the event.
*/
export class APISigner implements NostrSigner {
private signer: NostrSigner;
constructor(c: AppContext) {
const seckey = c.get('seckey');
const pubkey = c.get('pubkey');
if (!pubkey) {
throw new HTTPException(401, { message: 'Missing pubkey' });
}
if (seckey) {
this.signer = new NSecSigner(seckey);
} else {
this.signer = new NConnectSigner({
pubkey,
relay: Storages.pubsub,
signer: new AdminSigner(),
timeout: 60000,
});
}
}
async getPublicKey(): Promise<string> {
return this.signer.getPublicKey();
}
async signEvent(event: Omit<NostrEvent, 'id' | 'pubkey' | 'sig'>): Promise<NostrEvent> {
return this.signer.signEvent(event);
}
readonly nip04 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
return this.signer.nip04!.encrypt(pubkey, plaintext);
},
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
return this.signer.nip04!.decrypt(pubkey, ciphertext);
},
};
readonly nip44 = {
encrypt: async (pubkey: string, plaintext: string): Promise<string> => {
return this.signer.nip44!.encrypt(pubkey, plaintext);
},
decrypt: async (pubkey: string, ciphertext: string): Promise<string> => {
return this.signer.nip44!.decrypt(pubkey, ciphertext);
},
};
}

View File

@ -0,0 +1,39 @@
// deno-lint-ignore-file require-await
import { NConnectSigner } from '@nostrify/nostrify';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { Storages } from '@/storages.ts';
/**
* NIP-46 signer.
*
* Simple extension of nostrify's `NConnectSigner`, with our options to keep it DRY.
*/
export class ConnectSigner extends NConnectSigner {
private _pubkey: string;
constructor(pubkey: string, private relays?: string[]) {
super({
pubkey,
// TODO: use a remote relay for `nprofile` signing (if present and `Conf.relay` isn't already in the list)
relay: Storages.pubsub,
signer: new AdminSigner(),
timeout: 60000,
});
this._pubkey = pubkey;
}
// Prevent unnecessary NIP-46 round-trips.
async getPublicKey(): Promise<string> {
return this._pubkey;
}
/** Get the user's relays if they passed in an `nprofile` auth token. */
async getRelays(): Promise<Record<string, { read: boolean; write: boolean }>> {
return this.relays?.reduce<Record<string, { read: boolean; write: boolean }>>((acc, relay) => {
acc[relay] = { read: true, write: true };
return acc;
}, {}) ?? {};
}
}

View File

@ -11,7 +11,6 @@ import { Conf } from '@/config.ts';
import * as pipeline from '@/pipeline.ts';
import { RelayError } from '@/RelayError.ts';
import { AdminSigner } from '@/signers/AdminSigner.ts';
import { APISigner } from '@/signers/APISigner.ts';
import { Storages } from '@/storages.ts';
import { nostrNow } from '@/utils.ts';
@ -22,7 +21,13 @@ type EventStub = TypeFest.SetOptional<EventTemplate, 'content' | 'created_at' |
/** Publish an event through the pipeline. */
async function createEvent(t: EventStub, c: AppContext): Promise<NostrEvent> {
const signer = new APISigner(c);
const signer = c.get('signer');
if (!signer) {
throw new HTTPException(401, {
res: c.json({ error: 'No way to sign Nostr event' }, 401),
});
}
const event = await signer.signEvent({
content: '',

View File

@ -59,8 +59,10 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal
const sortedEvents = [...events].sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
const viewerPubkey = await c.get('signer')?.getPublicKey();
const statuses = await Promise.all(
sortedEvents.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') })),
sortedEvents.map((event) => renderStatus(event, { viewerPubkey })),
);
// TODO: pagination with min_id and max_id based on the order of `ids`.