mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 03:19:46 +00:00
Merge branch 'main' into mint-cashu
This commit is contained in:
commit
0b8b9e726a
26 changed files with 755 additions and 221 deletions
|
|
@ -87,7 +87,6 @@ import {
|
|||
} from '@/controllers/api/pleroma.ts';
|
||||
import { preferencesController } from '@/controllers/api/preferences.ts';
|
||||
import { getSubscriptionController, pushSubscribeController } from '@/controllers/api/push.ts';
|
||||
import { deleteReactionController, reactionController, reactionsController } from '@/controllers/api/reactions.ts';
|
||||
import { relayController } from '@/controllers/nostr/relay.ts';
|
||||
import {
|
||||
adminReportController,
|
||||
|
|
@ -147,8 +146,10 @@ import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts';
|
|||
import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
|
||||
import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts';
|
||||
import { logiMiddleware } from '@/middleware/logiMiddleware.ts';
|
||||
import customEmojisRoute from '@/routes/customEmojisRoute.ts';
|
||||
import dittoNamesRoute from '@/routes/dittoNamesRoute.ts';
|
||||
import pleromaAdminPermissionGroupsRoute from '@/routes/pleromaAdminPermissionGroupsRoute.ts';
|
||||
import pleromaStatusesRoute from '@/routes/pleromaStatusesRoute.ts';
|
||||
import { DittoRelayStore } from '@/storages/DittoRelayStore.ts';
|
||||
|
||||
export interface AppEnv extends DittoEnv {
|
||||
|
|
@ -434,10 +435,7 @@ app.post('/api/v1/markers', userMiddleware({ verify: true }), updateMarkersContr
|
|||
app.get('/api/v1/push/subscription', userMiddleware(), getSubscriptionController);
|
||||
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/:emoji', reactionsController);
|
||||
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', userMiddleware(), deleteReactionController);
|
||||
app.route('/api/v1/pleroma/statuses', pleromaStatusesRoute);
|
||||
|
||||
app.get('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), configController);
|
||||
app.post('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), updateConfigController);
|
||||
|
|
@ -475,53 +473,32 @@ app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController
|
|||
app.route('/api/v1/ditto/cashu', cashuApp);
|
||||
|
||||
app.post('/api/v1/reports', userMiddleware(), reportController);
|
||||
app.get('/api/v1/admin/reports', userMiddleware(), userMiddleware({ role: 'admin' }), adminReportsController);
|
||||
app.get(
|
||||
'/api/v1/admin/reports/:id{[0-9a-f]{64}}',
|
||||
userMiddleware(),
|
||||
userMiddleware({ role: 'admin' }),
|
||||
adminReportController,
|
||||
);
|
||||
app.get('/api/v1/admin/reports', userMiddleware({ role: 'admin' }), adminReportsController);
|
||||
app.get('/api/v1/admin/reports/:id{[0-9a-f]{64}}', userMiddleware({ role: 'admin' }), adminReportController);
|
||||
app.post(
|
||||
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/resolve',
|
||||
userMiddleware(),
|
||||
userMiddleware({ role: 'admin' }),
|
||||
adminReportResolveController,
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/admin/reports/:id{[0-9a-f]{64}}/reopen',
|
||||
userMiddleware(),
|
||||
userMiddleware({ role: 'admin' }),
|
||||
adminReportReopenController,
|
||||
);
|
||||
|
||||
app.get('/api/v1/admin/accounts', userMiddleware({ role: 'admin' }), adminAccountsController);
|
||||
app.post(
|
||||
'/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action',
|
||||
userMiddleware(),
|
||||
userMiddleware({ role: 'admin' }),
|
||||
adminActionController,
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/admin/accounts/:id{[0-9a-f]{64}}/approve',
|
||||
userMiddleware(),
|
||||
userMiddleware({ role: 'admin' }),
|
||||
adminApproveController,
|
||||
);
|
||||
app.post(
|
||||
'/api/v1/admin/accounts/:id{[0-9a-f]{64}}/reject',
|
||||
userMiddleware(),
|
||||
userMiddleware({ role: 'admin' }),
|
||||
adminRejectController,
|
||||
);
|
||||
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/action', userMiddleware({ role: 'admin' }), adminActionController);
|
||||
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/approve', userMiddleware({ role: 'admin' }), adminApproveController);
|
||||
app.post('/api/v1/admin/accounts/:id{[0-9a-f]{64}}/reject', userMiddleware({ role: 'admin' }), adminRejectController);
|
||||
|
||||
app.put('/api/v1/pleroma/admin/users/tag', userMiddleware({ role: 'admin' }), pleromaAdminTagController);
|
||||
app.delete('/api/v1/pleroma/admin/users/tag', userMiddleware({ role: 'admin' }), pleromaAdminUntagController);
|
||||
app.patch('/api/v1/pleroma/admin/users/suggest', userMiddleware({ role: 'admin' }), pleromaAdminSuggestController);
|
||||
app.patch('/api/v1/pleroma/admin/users/unsuggest', userMiddleware({ role: 'admin' }), pleromaAdminUnsuggestController);
|
||||
|
||||
app.route('/api/v1/custom_emojis', customEmojisRoute);
|
||||
|
||||
// Not (yet) implemented.
|
||||
app.get('/api/v1/custom_emojis', emptyArrayController);
|
||||
app.get('/api/v1/filters', emptyArrayController);
|
||||
app.get('/api/v1/domain_blocks', emptyArrayController);
|
||||
app.get('/api/v1/conversations', emptyArrayController);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const features = [
|
|||
'exposable_reactions',
|
||||
'mastodon_api',
|
||||
'mastodon_api_streaming',
|
||||
'pleroma_custom_emoji_reactions',
|
||||
'pleroma_emoji_reactions',
|
||||
'quote_posting',
|
||||
'v2_suggestions',
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
import { AppController } from '@/app.ts';
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { createEvent } from '@/utils/api.ts';
|
||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
|
||||
/**
|
||||
* React to a status.
|
||||
* https://docs.pleroma.social/backend/development/API/pleroma_api/#put-apiv1pleromastatusesidreactionsemoji
|
||||
*/
|
||||
const reactionController: AppController = async (c) => {
|
||||
const { relay, user } = c.var;
|
||||
const id = c.req.param('id');
|
||||
const emoji = c.req.param('emoji');
|
||||
|
||||
if (!/^\p{RGI_Emoji}$/v.test(emoji)) {
|
||||
return c.json({ error: 'Invalid emoji' }, 400);
|
||||
}
|
||||
|
||||
const [event] = await relay.query([{ kinds: [1, 20], ids: [id], limit: 1 }]);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Status not found' }, 404);
|
||||
}
|
||||
|
||||
await createEvent({
|
||||
kind: 7,
|
||||
content: emoji,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['e', id], ['p', event.pubkey]],
|
||||
}, c);
|
||||
|
||||
await hydrateEvents({ ...c.var, events: [event] });
|
||||
|
||||
const status = await renderStatus(relay, event, { viewerPubkey: await user!.signer.getPublicKey() });
|
||||
|
||||
return c.json(status);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete reactions to a status.
|
||||
* https://docs.pleroma.social/backend/development/API/pleroma_api/#delete-apiv1pleromastatusesidreactionsemoji
|
||||
*/
|
||||
const deleteReactionController: AppController = async (c) => {
|
||||
const { relay, user } = c.var;
|
||||
|
||||
const id = c.req.param('id');
|
||||
const emoji = c.req.param('emoji');
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
|
||||
if (!/^\p{RGI_Emoji}$/v.test(emoji)) {
|
||||
return c.json({ error: 'Invalid emoji' }, 400);
|
||||
}
|
||||
|
||||
const [event] = await relay.query([
|
||||
{ kinds: [1, 20], ids: [id], limit: 1 },
|
||||
]);
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Status not found' }, 404);
|
||||
}
|
||||
|
||||
const events = await relay.query([
|
||||
{ kinds: [7], authors: [pubkey], '#e': [id] },
|
||||
]);
|
||||
|
||||
const tags = events
|
||||
.filter((event) => event.content === emoji)
|
||||
.map((event) => ['e', event.id]);
|
||||
|
||||
await createEvent({
|
||||
kind: 5,
|
||||
content: '',
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags,
|
||||
}, c);
|
||||
|
||||
const status = renderStatus(relay, event, { viewerPubkey: pubkey });
|
||||
|
||||
return c.json(status);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an object of emoji to account mappings with accounts that reacted to the post.
|
||||
* https://docs.pleroma.social/backend/development/API/pleroma_api/#get-apiv1pleromastatusesidreactions
|
||||
*/
|
||||
const reactionsController: AppController = async (c) => {
|
||||
const { relay, user } = c.var;
|
||||
|
||||
const id = c.req.param('id');
|
||||
const pubkey = await user?.signer.getPublicKey();
|
||||
const emoji = c.req.param('emoji') as string | undefined;
|
||||
|
||||
if (typeof emoji === 'string' && !/^\p{RGI_Emoji}$/v.test(emoji)) {
|
||||
return c.json({ error: 'Invalid emoji' }, 400);
|
||||
}
|
||||
|
||||
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((event) => !emoji || event.content === emoji))
|
||||
.then((events) => hydrateEvents({ ...c.var, events }));
|
||||
|
||||
/** Events grouped by emoji. */
|
||||
const byEmoji = events.reduce((acc, event) => {
|
||||
const emoji = event.content;
|
||||
acc[emoji] = acc[emoji] || [];
|
||||
acc[emoji].push(event);
|
||||
return acc;
|
||||
}, {} as Record<string, DittoEvent[]>);
|
||||
|
||||
const results = await Promise.all(
|
||||
Object.entries(byEmoji).map(async ([name, events]) => {
|
||||
return {
|
||||
name,
|
||||
count: events.length,
|
||||
me: pubkey && events.some((event) => event.pubkey === pubkey),
|
||||
accounts: await Promise.all(
|
||||
events.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)),
|
||||
),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return c.json(results);
|
||||
};
|
||||
|
||||
export { deleteReactionController, reactionController, reactionsController };
|
||||
|
|
@ -16,6 +16,7 @@ import { lookupPubkey } from '@/utils/lookup.ts';
|
|||
import { languageSchema } from '@/schema.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { assertAuthenticated, createEvent, parseBody, updateListEvent } from '@/utils/api.ts';
|
||||
import { getCustomEmojis } from '@/utils/custom-emoji.ts';
|
||||
import { getInvoice, getLnurl } from '@/utils/lnurl.ts';
|
||||
import { purifyEvent } from '@/utils/purify.ts';
|
||||
import { getZapSplits } from '@/utils/zap-split.ts';
|
||||
|
|
@ -190,6 +191,23 @@ const createStatusController: AppController = async (c) => {
|
|||
}
|
||||
}
|
||||
|
||||
const shortcodes = new Set<string>();
|
||||
|
||||
for (const [, shortcode] of data.status?.matchAll(/(?<!\w):(\w+):(?!\w)/g) ?? []) {
|
||||
shortcodes.add(shortcode);
|
||||
}
|
||||
|
||||
if (shortcodes.size) {
|
||||
const emojis = await getCustomEmojis(await user!.signer.getPublicKey(), c.var);
|
||||
|
||||
for (const shortcode of shortcodes) {
|
||||
const emoji = emojis.get(shortcode);
|
||||
if (emoji) {
|
||||
tags.push(['emoji', shortcode, emoji.url.toString()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pubkey = await user!.signer.getPublicKey();
|
||||
const author = pubkey ? await getAuthor(pubkey, c.var) : undefined;
|
||||
|
||||
|
|
@ -615,7 +633,7 @@ const zappedByController: AppController = async (c) => {
|
|||
const { db, relay } = c.var;
|
||||
|
||||
const id = c.req.param('id');
|
||||
const { offset, limit } = paginationSchema.parse(c.req.query());
|
||||
const { offset, limit } = paginationSchema().parse(c.req.query());
|
||||
|
||||
const zaps = await db.kysely.selectFrom('event_zaps')
|
||||
.selectAll()
|
||||
|
|
|
|||
|
|
@ -179,22 +179,31 @@ const streamingController: AppController = async (c) => {
|
|||
limiter.set(ip, count + 1, { ttl: LIMITER_WINDOW });
|
||||
|
||||
if (count > LIMITER_LIMIT) {
|
||||
socket.close(1008, 'Rate limit exceeded');
|
||||
closeSocket(1008, 'Rate limit exceeded');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof e.data !== 'string') {
|
||||
socket.close(1003, 'Invalid message');
|
||||
closeSocket(1003, 'Invalid message');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
function closeSocket(code?: number, reason?: string) {
|
||||
socket.close(code, reason);
|
||||
handleClose();
|
||||
}
|
||||
|
||||
function handleClose(): void {
|
||||
connections.delete(socket);
|
||||
streamingConnectionsGauge.set(connections.size);
|
||||
controller.abort();
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
|||
|
||||
export const suggestionsV1Controller: AppController = async (c) => {
|
||||
const { signal } = c.var;
|
||||
const { offset, limit } = paginationSchema.parse(c.req.query());
|
||||
const { offset, limit } = paginationSchema().parse(c.req.query());
|
||||
const suggestions = await renderV2Suggestions(c, { offset, limit }, signal);
|
||||
const accounts = suggestions.map(({ account }) => account);
|
||||
return paginatedList(c, { offset, limit }, accounts);
|
||||
|
|
@ -17,7 +17,7 @@ export const suggestionsV1Controller: AppController = async (c) => {
|
|||
|
||||
export const suggestionsV2Controller: AppController = async (c) => {
|
||||
const { signal } = c.var;
|
||||
const { offset, limit } = paginationSchema.parse(c.req.query());
|
||||
const { offset, limit } = paginationSchema().parse(c.req.query());
|
||||
const suggestions = await renderV2Suggestions(c, { offset, limit }, signal);
|
||||
return paginatedList(c, { offset, limit }, suggestions);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ async function getTrendingLinks(conf: DittoConf, relay: NStore): Promise<Trendin
|
|||
|
||||
const trendingStatusesController: AppController = async (c) => {
|
||||
const { conf, relay } = c.var;
|
||||
const { limit, offset, until } = paginationSchema.parse(c.req.query());
|
||||
const { limit, offset, until } = paginationSchema().parse(c.req.query());
|
||||
|
||||
const [label] = await relay.query([{
|
||||
kinds: [1985],
|
||||
|
|
|
|||
|
|
@ -153,6 +153,11 @@ function connectStream(socket: WebSocket, ip: string | undefined, opts: ConnectS
|
|||
async function handleReq([_, subId, ...filters]: NostrClientREQ): Promise<void> {
|
||||
if (rateLimited(limiters.req)) return;
|
||||
|
||||
if (controllers.size > 20) {
|
||||
send(['CLOSED', subId, 'error: too many subscriptions']);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
controllers.get(subId)?.abort();
|
||||
controllers.set(subId, controller);
|
||||
|
|
@ -166,6 +171,10 @@ function connectStream(socket: WebSocket, ip: string | undefined, opts: ConnectS
|
|||
} else {
|
||||
const [verb, , ...rest] = msg;
|
||||
send([verb, subId, ...rest] as NostrRelayMsg);
|
||||
|
||||
if (verb === 'CLOSED') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
@ -177,6 +186,7 @@ function connectStream(socket: WebSocket, ip: string | undefined, opts: ConnectS
|
|||
send(['CLOSED', subId, 'error: something went wrong']);
|
||||
}
|
||||
} finally {
|
||||
controllers.get(subId)?.abort();
|
||||
controllers.delete(subId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
packages/ditto/routes/customEmojisRoute.test.ts
Normal file
77
packages/ditto/routes/customEmojisRoute.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { TestApp } from '@ditto/mastoapi/test';
|
||||
import { NSecSigner } from '@nostrify/nostrify';
|
||||
import { genEvent } from '@nostrify/nostrify/test';
|
||||
import { assertEquals } from '@std/assert';
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
|
||||
import route from './customEmojisRoute.ts';
|
||||
|
||||
Deno.test('customEmojisRoute', async (t) => {
|
||||
await using test = new TestApp(route);
|
||||
const { relay } = test.var;
|
||||
|
||||
await t.step('unauth', async () => {
|
||||
const response = await test.api.get('/');
|
||||
const body = await response.json();
|
||||
|
||||
assertEquals(response.status, 200);
|
||||
assertEquals(body, []);
|
||||
});
|
||||
|
||||
const sk = generateSecretKey();
|
||||
const user = test.user({ relay, signer: new NSecSigner(sk) });
|
||||
const pubkey = await user.signer.getPublicKey();
|
||||
|
||||
await t.step('no emojis', async () => {
|
||||
const response = await test.api.get('/');
|
||||
const body = await response.json();
|
||||
|
||||
assertEquals(response.status, 200);
|
||||
assertEquals(body, []);
|
||||
});
|
||||
|
||||
await t.step('with emoji packs', async () => {
|
||||
const pack = genEvent({
|
||||
kind: 30030,
|
||||
tags: [
|
||||
['d', 'soapbox'],
|
||||
['emoji', 'soapbox', 'https://soapbox.pub/favicon.ico'],
|
||||
['emoji', 'ditto', 'https://ditto.pub/favicon.ico'],
|
||||
],
|
||||
}, sk);
|
||||
|
||||
const list = genEvent({
|
||||
kind: 10030,
|
||||
tags: [
|
||||
['a', `30030:${pubkey}:soapbox`],
|
||||
['emoji', 'gleasonator', 'https://gleasonator.dev/favicon.ico'],
|
||||
],
|
||||
}, sk);
|
||||
|
||||
await relay.event(pack);
|
||||
await relay.event(list);
|
||||
|
||||
const response = await test.api.get('/');
|
||||
const body = await response.json();
|
||||
|
||||
assertEquals(response.status, 200);
|
||||
assertEquals(body, [{
|
||||
shortcode: 'gleasonator',
|
||||
url: 'https://gleasonator.dev/favicon.ico',
|
||||
static_url: 'https://gleasonator.dev/favicon.ico',
|
||||
visible_in_picker: true,
|
||||
}, {
|
||||
shortcode: 'soapbox',
|
||||
url: 'https://soapbox.pub/favicon.ico',
|
||||
static_url: 'https://soapbox.pub/favicon.ico',
|
||||
visible_in_picker: true,
|
||||
category: 'soapbox',
|
||||
}, {
|
||||
shortcode: 'ditto',
|
||||
url: 'https://ditto.pub/favicon.ico',
|
||||
static_url: 'https://ditto.pub/favicon.ico',
|
||||
visible_in_picker: true,
|
||||
category: 'soapbox',
|
||||
}]);
|
||||
});
|
||||
});
|
||||
37
packages/ditto/routes/customEmojisRoute.ts
Normal file
37
packages/ditto/routes/customEmojisRoute.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { userMiddleware } from '@ditto/mastoapi/middleware';
|
||||
import { DittoRoute } from '@ditto/mastoapi/router';
|
||||
|
||||
import { getCustomEmojis } from '@/utils/custom-emoji.ts';
|
||||
|
||||
const route = new DittoRoute();
|
||||
|
||||
interface MastodonCustomEmoji {
|
||||
shortcode: string;
|
||||
url: string;
|
||||
static_url: string;
|
||||
visible_in_picker: boolean;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
route.get('/', userMiddleware({ required: false }), async (c) => {
|
||||
const { user } = c.var;
|
||||
|
||||
if (!user) {
|
||||
return c.json([]);
|
||||
}
|
||||
|
||||
const pubkey = await user.signer.getPublicKey();
|
||||
const emojis = await getCustomEmojis(pubkey, c.var);
|
||||
|
||||
return c.json([...emojis.entries()].map(([shortcode, data]): MastodonCustomEmoji => {
|
||||
return {
|
||||
shortcode,
|
||||
url: data.url.toString(),
|
||||
static_url: data.url.toString(),
|
||||
visible_in_picker: true,
|
||||
category: data.category,
|
||||
};
|
||||
}));
|
||||
});
|
||||
|
||||
export default route;
|
||||
105
packages/ditto/routes/pleromaStatusesRoute.test.ts
Normal file
105
packages/ditto/routes/pleromaStatusesRoute.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { DittoConf } from '@ditto/conf';
|
||||
import { DittoPolyPg } from '@ditto/db';
|
||||
import { TestApp } from '@ditto/mastoapi/test';
|
||||
import { genEvent, MockRelay } from '@nostrify/nostrify/test';
|
||||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import { DittoPgStore } from '@/storages/DittoPgStore.ts';
|
||||
import { DittoRelayStore } from '@/storages/DittoRelayStore.ts';
|
||||
|
||||
import route from './pleromaStatusesRoute.ts';
|
||||
|
||||
import type { MastodonStatus } from '@ditto/mastoapi/types';
|
||||
|
||||
Deno.test('Emoji reactions', async (t) => {
|
||||
await using test = createTestApp();
|
||||
const { relay } = test.var;
|
||||
|
||||
const mario = test.createUser();
|
||||
const luigi = test.createUser();
|
||||
|
||||
const note = genEvent({ kind: 1 });
|
||||
await relay.event(note);
|
||||
|
||||
await relay.event(genEvent({ kind: 10030, tags: [['emoji', 'ditto', 'https://ditto.pub/favicon.ico']] }, luigi.sk));
|
||||
|
||||
await t.step('PUT /:id/reactions/:emoji', async () => {
|
||||
test.user(mario);
|
||||
|
||||
const response = await test.api.put(`/${note.id}/reactions/🚀`);
|
||||
const json = await response.json();
|
||||
|
||||
assertEquals(response.status, 200);
|
||||
assertEquals(json.pleroma.emoji_reactions, [{ name: '🚀', me: true, count: 1 }]);
|
||||
});
|
||||
|
||||
await t.step('PUT /:id/reactions/:emoji (custom emoji)', async () => {
|
||||
test.user(luigi);
|
||||
|
||||
const response = await test.api.put(`/${note.id}/reactions/:ditto:`);
|
||||
const json: MastodonStatus = await response.json();
|
||||
|
||||
assertEquals(
|
||||
json.pleroma.emoji_reactions.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[
|
||||
{ name: '🚀', me: false, count: 1 },
|
||||
{ name: 'ditto', me: true, count: 1, url: 'https://ditto.pub/favicon.ico' },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
await t.step('GET /:id/reactions', async () => {
|
||||
test.user(mario);
|
||||
|
||||
const response = await test.api.get(`/${note.id}/reactions`);
|
||||
const json = await response.json();
|
||||
|
||||
(json as MastodonStatus['pleroma']['emoji_reactions']).sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const [
|
||||
{ accounts: [marioAccount] },
|
||||
{ accounts: [luigiAccount] },
|
||||
] = json;
|
||||
|
||||
assertEquals(response.status, 200);
|
||||
|
||||
assertEquals(json, [
|
||||
{ name: '🚀', me: true, count: 1, accounts: [marioAccount] },
|
||||
{ name: 'ditto', me: false, count: 1, accounts: [luigiAccount], url: 'https://ditto.pub/favicon.ico' },
|
||||
]);
|
||||
});
|
||||
|
||||
await t.step('DELETE /:id/reactions/:emoji', async () => {
|
||||
test.user(mario);
|
||||
|
||||
const response = await test.api.delete(`/${note.id}/reactions/🚀`);
|
||||
const json = await response.json();
|
||||
|
||||
assertEquals(response.status, 200);
|
||||
|
||||
assertEquals(json.pleroma.emoji_reactions, [
|
||||
{ name: 'ditto', me: false, count: 1, url: 'https://ditto.pub/favicon.ico' },
|
||||
]);
|
||||
});
|
||||
|
||||
await t.step('DELETE /:id/reactions/:emoji (custom emoji)', async () => {
|
||||
test.user(luigi);
|
||||
|
||||
const response = await test.api.delete(`/${note.id}/reactions/:ditto:`);
|
||||
const json = await response.json();
|
||||
|
||||
assertEquals(response.status, 200);
|
||||
assertEquals(json.pleroma.emoji_reactions, []);
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: modify `TestApp` itself to avoid this boilerplate.
|
||||
function createTestApp(): TestApp {
|
||||
const conf = new DittoConf(Deno.env);
|
||||
const db = new DittoPolyPg(conf.databaseUrl);
|
||||
const pool = new MockRelay();
|
||||
const store = new DittoPgStore({ conf, db, notify: false });
|
||||
const relay = new DittoRelayStore({ conf, db, pool, relay: store });
|
||||
|
||||
return new TestApp(route, { conf, db, relay });
|
||||
}
|
||||
221
packages/ditto/routes/pleromaStatusesRoute.ts
Normal file
221
packages/ditto/routes/pleromaStatusesRoute.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { paginationMiddleware, userMiddleware } from '@ditto/mastoapi/middleware';
|
||||
import { DittoRoute } from '@ditto/mastoapi/router';
|
||||
|
||||
import { DittoEvent } from '@/interfaces/DittoEvent.ts';
|
||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||
import { createEvent } from '@/utils/api.ts';
|
||||
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||
import { HTTPException } from '@hono/hono/http-exception';
|
||||
|
||||
import { getCustomEmojis, parseEmojiInput } from '@/utils/custom-emoji.ts';
|
||||
|
||||
const route = new DittoRoute();
|
||||
|
||||
/*
|
||||
* React to a status.
|
||||
* https://docs.pleroma.social/backend/development/API/pleroma_api/#put-apiv1pleromastatusesidreactionsemoji
|
||||
*/
|
||||
route.put('/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), async (c) => {
|
||||
const { relay, user, conf, signal } = c.var;
|
||||
|
||||
const params = c.req.param();
|
||||
const result = parseEmojiParam(params.emoji);
|
||||
const pubkey = await user.signer.getPublicKey();
|
||||
|
||||
const [event] = await relay.query([{ ids: [params.id] }], { signal });
|
||||
if (!event) {
|
||||
return c.json({ error: 'Event not found' }, 404);
|
||||
}
|
||||
|
||||
const tags: string[][] = [
|
||||
['e', event.id, conf.relay, event.pubkey],
|
||||
['p', event.pubkey, conf.relay],
|
||||
];
|
||||
|
||||
if (result.type === 'custom') {
|
||||
const emojis = await getCustomEmojis(pubkey, c.var);
|
||||
const emoji = emojis.get(result.shortcode);
|
||||
|
||||
if (!emoji) {
|
||||
return c.json({ error: 'Custom emoji not found' }, 404);
|
||||
}
|
||||
|
||||
tags.push(['emoji', result.shortcode, emoji.url.href]);
|
||||
}
|
||||
|
||||
let content: string;
|
||||
|
||||
switch (result.type) {
|
||||
case 'native':
|
||||
content = result.native;
|
||||
break;
|
||||
case 'custom':
|
||||
content = `:${result.shortcode}:`;
|
||||
break;
|
||||
}
|
||||
|
||||
await createEvent({ kind: 7, content, tags }, c);
|
||||
await hydrateEvents({ ...c.var, events: [event] });
|
||||
|
||||
const status = await renderStatus(relay, event, { viewerPubkey: pubkey });
|
||||
return c.json(status);
|
||||
});
|
||||
|
||||
/*
|
||||
* Delete reactions to a status.
|
||||
* https://docs.pleroma.social/backend/development/API/pleroma_api/#delete-apiv1pleromastatusesidreactionsemoji
|
||||
*/
|
||||
route.delete('/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), async (c) => {
|
||||
const { relay, user, signal } = c.var;
|
||||
|
||||
const params = c.req.param();
|
||||
const pubkey = await user.signer.getPublicKey();
|
||||
|
||||
const [event] = await relay.query([{ ids: [params.id] }], { signal });
|
||||
|
||||
if (!event) {
|
||||
return c.json({ error: 'Status not found' }, 404);
|
||||
}
|
||||
|
||||
const events = await relay.query([
|
||||
{ kinds: [7], authors: [pubkey], '#e': [params.id] },
|
||||
], { signal });
|
||||
|
||||
const e = new Set<string>();
|
||||
|
||||
for (const { id, content } of events) {
|
||||
if (content === params.emoji || content === `:${params.emoji}:`) {
|
||||
e.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
if (!e.size) {
|
||||
return c.json({ error: 'Reaction not found' }, 404);
|
||||
}
|
||||
|
||||
await createEvent({
|
||||
kind: 5,
|
||||
tags: [...e].map((id) => ['e', id]),
|
||||
}, c);
|
||||
|
||||
await hydrateEvents({ ...c.var, events: [event] });
|
||||
|
||||
const status = await renderStatus(relay, event, { viewerPubkey: pubkey });
|
||||
return c.json(status);
|
||||
});
|
||||
|
||||
/*
|
||||
* Get an object of emoji to account mappings with accounts that reacted to the post.
|
||||
* https://docs.pleroma.social/backend/development/API/pleroma_api/#get-apiv1pleromastatusesidreactions
|
||||
*/
|
||||
route.get(
|
||||
'/:id{[0-9a-f]{64}}/reactions/:emoji?',
|
||||
paginationMiddleware({ limit: 100 }),
|
||||
userMiddleware({ required: false }),
|
||||
async (c) => {
|
||||
const { relay, user, pagination, paginate } = c.var;
|
||||
|
||||
const params = c.req.param();
|
||||
const result = params.emoji ? parseEmojiParam(params.emoji) : undefined;
|
||||
const pubkey = await user?.signer.getPublicKey();
|
||||
|
||||
const events = await relay.query([{ kinds: [7], '#e': [params.id], ...pagination }])
|
||||
.then((events) =>
|
||||
events.filter((event) => {
|
||||
if (!result) return true;
|
||||
|
||||
switch (result.type) {
|
||||
case 'native':
|
||||
return event.content === result.native;
|
||||
case 'custom':
|
||||
return event.content === `:${result.shortcode}:`;
|
||||
}
|
||||
})
|
||||
)
|
||||
.then((events) => hydrateEvents({ ...c.var, events }));
|
||||
|
||||
/** Events grouped by emoji key. */
|
||||
const byEmojiKey = events.reduce((acc, event) => {
|
||||
const result = parseEmojiInput(event.content);
|
||||
|
||||
if (!result || result.type === 'basic') {
|
||||
return acc;
|
||||
}
|
||||
|
||||
let url: URL | undefined;
|
||||
|
||||
if (result.type === 'custom') {
|
||||
const tag = event.tags.find(([name, value]) => name === 'emoji' && value === result.shortcode);
|
||||
try {
|
||||
url = new URL(tag![2]);
|
||||
} catch {
|
||||
return acc;
|
||||
}
|
||||
}
|
||||
|
||||
let key: string;
|
||||
switch (result.type) {
|
||||
case 'native':
|
||||
key = result.native;
|
||||
break;
|
||||
case 'custom':
|
||||
key = `${result.shortcode}:${url}`;
|
||||
break;
|
||||
}
|
||||
|
||||
acc[key] = acc[key] || [];
|
||||
acc[key].push(event);
|
||||
|
||||
return acc;
|
||||
}, {} as Record<string, DittoEvent[]>);
|
||||
|
||||
const results = await Promise.all(
|
||||
Object.entries(byEmojiKey).map(async ([key, events]) => {
|
||||
let name: string = key;
|
||||
let url: string | undefined;
|
||||
|
||||
// Custom emojis: `<shortcode>:<url>`
|
||||
try {
|
||||
const [shortcode, ...rest] = key.split(':');
|
||||
|
||||
url = new URL(rest.join(':')).toString();
|
||||
name = shortcode;
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
count: events.length,
|
||||
me: pubkey && events.some((event) => event.pubkey === pubkey),
|
||||
accounts: await Promise.all(
|
||||
events.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)),
|
||||
),
|
||||
url,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return paginate(events, results);
|
||||
},
|
||||
);
|
||||
|
||||
/** Determine if the input is a native or custom emoji, returning a structured object or throwing an error. */
|
||||
function parseEmojiParam(input: string):
|
||||
| { type: 'native'; native: string }
|
||||
| { type: 'custom'; shortcode: string } {
|
||||
if (/^\w+$/.test(input)) {
|
||||
input = `:${input}:`; // Pleroma API supports the `emoji` param with or without colons.
|
||||
}
|
||||
|
||||
const result = parseEmojiInput(input);
|
||||
|
||||
if (!result || result.type === 'basic') {
|
||||
throw new HTTPException(400, { message: 'Invalid emoji' });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default route;
|
||||
|
|
@ -16,7 +16,7 @@ Deno.test('req streaming', async () => {
|
|||
const controller = new AbortController();
|
||||
|
||||
const promise = (async () => {
|
||||
for await (const msg of relay.req([{ since: 0 }], { signal: controller.signal })) {
|
||||
for await (const msg of relay.req([{ limit: 0 }], { signal: controller.signal })) {
|
||||
msgs.push(msg);
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export async function createTestDB(opts?: { pure?: boolean }) {
|
|||
conf,
|
||||
timeout: conf.db.timeouts.default,
|
||||
pure: opts?.pure ?? false,
|
||||
notify: true,
|
||||
notify: false,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
10
packages/ditto/utils/custom-emoji.test.ts
Normal file
10
packages/ditto/utils/custom-emoji.test.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import { parseEmojiInput } from './custom-emoji.ts';
|
||||
|
||||
Deno.test('parseEmojiInput', () => {
|
||||
assertEquals(parseEmojiInput('+'), { type: 'basic', value: '+' });
|
||||
assertEquals(parseEmojiInput('🚀'), { type: 'native', native: '🚀' });
|
||||
assertEquals(parseEmojiInput(':ditto:'), { type: 'custom', shortcode: 'ditto' });
|
||||
assertEquals(parseEmojiInput('x'), undefined);
|
||||
});
|
||||
93
packages/ditto/utils/custom-emoji.ts
Normal file
93
packages/ditto/utils/custom-emoji.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import type { NostrFilter, NRelay } from '@nostrify/nostrify';
|
||||
|
||||
interface GetCustomEmojisOpts {
|
||||
relay: NRelay;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export async function getCustomEmojis(
|
||||
pubkey: string,
|
||||
opts: GetCustomEmojisOpts,
|
||||
): Promise<Map<string, { url: URL; category?: string }>> {
|
||||
const { relay, signal } = opts;
|
||||
|
||||
const emojis = new Map<string, { url: URL; category?: string }>();
|
||||
|
||||
const [emojiList] = await relay.query([{ kinds: [10030], authors: [pubkey] }], { signal });
|
||||
|
||||
if (!emojiList) {
|
||||
return emojis;
|
||||
}
|
||||
|
||||
const a = new Set<string>();
|
||||
|
||||
for (const tag of emojiList.tags) {
|
||||
if (tag[0] === 'emoji') {
|
||||
const [, shortcode, url] = tag;
|
||||
|
||||
if (!emojis.has(shortcode)) {
|
||||
try {
|
||||
emojis.set(shortcode, { url: new URL(url) });
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tag[0] === 'a') {
|
||||
a.add(tag[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const filters: NostrFilter[] = [];
|
||||
|
||||
for (const addr of a) {
|
||||
const match = addr.match(/^30030:([0-9a-f]{64}):(.+)$/);
|
||||
|
||||
if (match) {
|
||||
const [, pubkey, d] = match;
|
||||
filters.push({ kinds: [30030], authors: [pubkey], '#d': [d] });
|
||||
}
|
||||
}
|
||||
|
||||
if (!filters.length) {
|
||||
return emojis;
|
||||
}
|
||||
|
||||
for (const event of await relay.query(filters, { signal })) {
|
||||
const d = event.tags.find(([name]) => name === 'd')?.[1];
|
||||
|
||||
for (const [t, shortcode, url] of event.tags) {
|
||||
if (t === 'emoji' && /^\w+$/.test(shortcode) && !emojis.has(shortcode)) {
|
||||
try {
|
||||
emojis.set(shortcode, { url: new URL(url), category: d });
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return emojis;
|
||||
}
|
||||
|
||||
/** Determine if the input is a native or custom emoji, returning a structured object or throwing an error. */
|
||||
export function parseEmojiInput(input: string):
|
||||
| { type: 'basic'; value: '+' | '-' }
|
||||
| { type: 'native'; native: string }
|
||||
| { type: 'custom'; shortcode: string }
|
||||
| undefined {
|
||||
if (input === '+' || input === '-') {
|
||||
return { type: 'basic', value: input };
|
||||
}
|
||||
|
||||
if (/^\p{RGI_Emoji}$/v.test(input)) {
|
||||
return { type: 'native', native: input };
|
||||
}
|
||||
|
||||
const match = input.match(/^:(\w+):$/);
|
||||
if (match) {
|
||||
const [, shortcode] = match;
|
||||
return { type: 'custom', shortcode };
|
||||
}
|
||||
}
|
||||
|
|
@ -141,16 +141,24 @@ Deno.test('updateStats with kind 7 increments reactions count', async () => {
|
|||
const { kysely, relay } = test;
|
||||
|
||||
const note = genEvent({ kind: 1 });
|
||||
await updateStats({ ...test, event: note });
|
||||
await relay.event(note);
|
||||
|
||||
await updateStats({ ...test, event: genEvent({ kind: 7, content: '+', tags: [['e', note.id]] }) });
|
||||
await updateStats({ ...test, event: genEvent({ kind: 7, content: '😂', tags: [['e', note.id]] }) });
|
||||
|
||||
await updateStats({
|
||||
...test,
|
||||
event: genEvent({
|
||||
kind: 7,
|
||||
content: ':ditto:',
|
||||
tags: [['e', note.id], ['emoji', 'ditto', 'https://ditto.pub/favicon.ico']],
|
||||
}),
|
||||
});
|
||||
|
||||
const stats = await getEventStats(kysely, note.id);
|
||||
|
||||
assertEquals(stats!.reactions, JSON.stringify({ '+': 1, '😂': 1 }));
|
||||
assertEquals(stats!.reactions_count, 2);
|
||||
assertEquals(stats!.reactions, JSON.stringify({ '+': 1, '😂': 1, 'ditto:https://ditto.pub/favicon.ico': 1 }));
|
||||
assertEquals(stats!.reactions_count, 3);
|
||||
});
|
||||
|
||||
Deno.test('updateStats with kind 5 decrements reactions count', async () => {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { z } from 'zod';
|
|||
import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts';
|
||||
|
||||
import type { DittoConf } from '@ditto/conf';
|
||||
import { parseEmojiInput } from '@/utils/custom-emoji.ts';
|
||||
|
||||
interface UpdateStatsOpts {
|
||||
conf: DittoConf;
|
||||
|
|
@ -154,14 +155,39 @@ async function handleEvent7(opts: UpdateStatsOpts): Promise<void> {
|
|||
const { kysely, event, x = 1 } = opts;
|
||||
|
||||
const id = event.tags.findLast(([name]) => name === 'e')?.[1];
|
||||
const emoji = event.content;
|
||||
const result = parseEmojiInput(event.content);
|
||||
|
||||
if (!id || !result) return;
|
||||
|
||||
let url: URL | undefined;
|
||||
|
||||
if (result.type === 'custom') {
|
||||
const tag = event.tags.find(([name, value]) => name === 'emoji' && value === result.shortcode);
|
||||
try {
|
||||
url = new URL(tag![2]);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let key: string;
|
||||
switch (result.type) {
|
||||
case 'basic':
|
||||
key = result.value;
|
||||
break;
|
||||
case 'native':
|
||||
key = result.native;
|
||||
break;
|
||||
case 'custom':
|
||||
key = `${result.shortcode}:${url}`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (id && emoji && (['+', '-'].includes(emoji) || /^\p{RGI_Emoji}$/v.test(emoji))) {
|
||||
await updateEventStats(kysely, id, ({ reactions }) => {
|
||||
const data: Record<string, number> = JSON.parse(reactions);
|
||||
|
||||
// Increment or decrement the emoji count.
|
||||
data[emoji] = (data[emoji] ?? 0) + x;
|
||||
data[key] = (data[key] ?? 0) + x;
|
||||
|
||||
// Remove reactions with a count of 0 or less.
|
||||
for (const key of Object.keys(data)) {
|
||||
|
|
@ -179,7 +205,6 @@ async function handleEvent7(opts: UpdateStatsOpts): Promise<void> {
|
|||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Update stats for kind 9735 event. */
|
||||
async function handleEvent9735(opts: UpdateStatsOpts): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?:
|
|||
}
|
||||
|
||||
async function renderAccounts(c: AppContext, pubkeys: string[]) {
|
||||
const { offset, limit } = paginationSchema.parse(c.req.query());
|
||||
const { offset, limit } = paginationSchema().parse(c.req.query());
|
||||
const authors = pubkeys.reverse().slice(offset, offset + limit);
|
||||
|
||||
const { relay, signal } = c.var;
|
||||
|
|
|
|||
|
|
@ -91,11 +91,32 @@ async function renderStatus(
|
|||
const subject = event.tags.find(([name]) => name === 'subject');
|
||||
|
||||
/** Pleroma emoji reactions object. */
|
||||
const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [emoji, count]) => {
|
||||
if (['+', '-'].includes(emoji)) return acc;
|
||||
acc.push({ name: emoji, count, me: reactionEvent?.content === emoji });
|
||||
const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [key, count]) => {
|
||||
if (['+', '-'].includes(key)) return acc; // skip basic reactions (treat as likes/dislikes in Mastodon API)
|
||||
|
||||
// Custom emoji reactions: `<shortcode>:<url>`
|
||||
try {
|
||||
const [shortcode, ...rest] = key.split(':');
|
||||
const url = new URL(rest.join(':'));
|
||||
const tag = reactionEvent?.tags.find((t) => t[0] === 'emoji' && t[1] === shortcode && t[2] === url.href);
|
||||
|
||||
acc.push({
|
||||
name: shortcode,
|
||||
me: reactionEvent?.content === `:${shortcode}:` && !!tag,
|
||||
url: url.href,
|
||||
count,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, [] as { name: string; count: number; me: boolean }[]);
|
||||
} catch {
|
||||
// fallthrough
|
||||
}
|
||||
|
||||
// Native emojis: `🚀`
|
||||
acc.push({ name: key, count, me: reactionEvent?.content === key });
|
||||
|
||||
return acc;
|
||||
}, [] as { name: string; count: number; me: boolean; url?: string }[]);
|
||||
|
||||
const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { paginated, paginatedList } from '../pagination/paginate.ts';
|
||||
import { paginationSchema } from '../pagination/schema.ts';
|
||||
import { paginationSchema, type PaginationSchemaOpts } from '../pagination/schema.ts';
|
||||
|
||||
import type { DittoMiddleware } from '@ditto/mastoapi/router';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
|
@ -19,19 +19,26 @@ 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;
|
||||
|
||||
interface PaginationMiddlewareOpts extends PaginationSchemaOpts {
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/** 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',
|
||||
opts: PaginationMiddlewareOpts & { type: 'list' },
|
||||
): DittoMiddleware<{ pagination: ListPagination; paginate: ListPaginateFn }>;
|
||||
export function paginationMiddleware(
|
||||
type?: string,
|
||||
opts?: PaginationMiddlewareOpts,
|
||||
): DittoMiddleware<{ pagination: Pagination; paginate: PaginateFn }>;
|
||||
export function paginationMiddleware(
|
||||
opts: PaginationMiddlewareOpts = {},
|
||||
): DittoMiddleware<{ pagination?: Pagination | ListPagination; paginate: PaginateFn | ListPaginateFn }> {
|
||||
return async (c, next) => {
|
||||
const { relay } = c.var;
|
||||
|
||||
const pagination = paginationSchema.parse(c.req.query());
|
||||
const pagination = paginationSchema(opts).parse(c.req.query());
|
||||
|
||||
const {
|
||||
max_id: maxId,
|
||||
|
|
@ -59,7 +66,7 @@ export function paginationMiddleware(
|
|||
}
|
||||
}
|
||||
|
||||
if (type === 'list') {
|
||||
if (opts.type === 'list') {
|
||||
c.set('pagination', {
|
||||
limit: pagination.limit,
|
||||
offset: pagination.offset,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,18 @@ Deno.test('no user 401', async () => {
|
|||
assertEquals(response.status, 401);
|
||||
});
|
||||
|
||||
Deno.test('no user required false', async () => {
|
||||
await using app = new TestApp();
|
||||
|
||||
app
|
||||
.use(userMiddleware({ required: false }))
|
||||
.get('/', (c) => c.text('ok'));
|
||||
|
||||
const response = await app.request('/');
|
||||
|
||||
assertEquals(response.status, 200);
|
||||
});
|
||||
|
||||
Deno.test('unsupported signer 400', async () => {
|
||||
await using app = new TestApp();
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ interface UserMiddlewareOpts {
|
|||
enc?: 'nip04' | 'nip44';
|
||||
role?: string;
|
||||
verify?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export function userMiddleware(): DittoMiddleware<{ user: User }>;
|
||||
|
|
@ -19,13 +20,14 @@ export function userMiddleware(): DittoMiddleware<{ user: User }>;
|
|||
export function userMiddleware(
|
||||
opts: UserMiddlewareOpts & { enc: 'nip44' },
|
||||
): DittoMiddleware<{ user: User<Nip44Signer> }>;
|
||||
export function userMiddleware(opts: UserMiddlewareOpts & { required: false }): DittoMiddleware<{ user?: User }>;
|
||||
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;
|
||||
const { enc, role, verify, required = true } = opts;
|
||||
|
||||
if (!user) {
|
||||
if (!user && required) {
|
||||
throw new HTTPException(401, { message: 'Authorization required' });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { assertEquals } from '@std/assert';
|
|||
import { paginationSchema } from './schema.ts';
|
||||
|
||||
Deno.test('paginationSchema', () => {
|
||||
const pagination = paginationSchema.parse({
|
||||
const pagination = paginationSchema().parse({
|
||||
limit: '10',
|
||||
offset: '20',
|
||||
max_id: '1',
|
||||
|
|
@ -21,3 +21,8 @@ Deno.test('paginationSchema', () => {
|
|||
until: 4,
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test('paginationSchema with custom limit', () => {
|
||||
const pagination = paginationSchema({ limit: 100 }).parse({});
|
||||
assertEquals(pagination.limit, 100);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,8 +9,20 @@ export interface Pagination {
|
|||
offset: number;
|
||||
}
|
||||
|
||||
export interface PaginationSchemaOpts {
|
||||
limit?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/** Schema to parse pagination query params. */
|
||||
export const paginationSchema: z.ZodType<Pagination> = z.object({
|
||||
export function paginationSchema(opts: PaginationSchemaOpts = {}): z.ZodType<Pagination> {
|
||||
let { limit = 20, max = 40 } = opts;
|
||||
|
||||
if (limit > max) {
|
||||
max = limit;
|
||||
}
|
||||
|
||||
return z.object({
|
||||
max_id: z.string().transform((val) => {
|
||||
if (!val.includes('-')) return val;
|
||||
return val.split('-')[1];
|
||||
|
|
@ -18,6 +30,7 @@ export const paginationSchema: z.ZodType<Pagination> = z.object({
|
|||
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)),
|
||||
limit: z.coerce.number().catch(limit).transform((value) => Math.min(Math.max(value, 0), max)),
|
||||
offset: z.coerce.number().nonnegative().catch(0),
|
||||
}) as z.ZodType<Pagination>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,10 +95,11 @@ export class TestApp extends DittoApp implements AsyncDisposable {
|
|||
return user;
|
||||
}
|
||||
|
||||
createUser(sk?: Uint8Array): User {
|
||||
createUser(sk: Uint8Array = generateSecretKey()): User & { sk: Uint8Array } {
|
||||
return {
|
||||
relay: this.opts.relay,
|
||||
signer: new NSecSigner(sk ?? generateSecretKey()),
|
||||
signer: new NSecSigner(sk),
|
||||
sk,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -110,9 +111,19 @@ export class TestApp extends DittoApp implements AsyncDisposable {
|
|||
return await this.request(path, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
},
|
||||
put: async (path: string, body?: unknown): Promise<Response> => {
|
||||
return await this.request(path, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
},
|
||||
delete: async (path: string): Promise<Response> => {
|
||||
return await this.request(path, { method: 'DELETE' });
|
||||
},
|
||||
};
|
||||
|
||||
async [Symbol.asyncDispose](): Promise<void> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue