Merge branch 'cleanup' into 'develop'
Cleanup See merge request soapbox-pub/ditto!26
This commit is contained in:
commit
07ccc1934a
10
src/app.ts
10
src/app.ts
|
@ -38,7 +38,11 @@ import {
|
||||||
statusController,
|
statusController,
|
||||||
} from './controllers/api/statuses.ts';
|
} from './controllers/api/statuses.ts';
|
||||||
import { streamingController } from './controllers/api/streaming.ts';
|
import { streamingController } from './controllers/api/streaming.ts';
|
||||||
import { hashtagTimelineController, homeController, publicController } from './controllers/api/timelines.ts';
|
import {
|
||||||
|
hashtagTimelineController,
|
||||||
|
homeTimelineController,
|
||||||
|
publicTimelineController,
|
||||||
|
} from './controllers/api/timelines.ts';
|
||||||
import { trendingTagsController } from './controllers/api/trends.ts';
|
import { trendingTagsController } from './controllers/api/trends.ts';
|
||||||
import { indexController } from './controllers/site.ts';
|
import { indexController } from './controllers/site.ts';
|
||||||
import { hostMetaController } from './controllers/well-known/host-meta.ts';
|
import { hostMetaController } from './controllers/well-known/host-meta.ts';
|
||||||
|
@ -107,8 +111,8 @@ app.get('/api/v1/statuses/:id{[0-9a-f]{64}}', statusController);
|
||||||
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/favourite', favouriteController);
|
app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/favourite', favouriteController);
|
||||||
app.post('/api/v1/statuses', requirePubkey, createStatusController);
|
app.post('/api/v1/statuses', requirePubkey, createStatusController);
|
||||||
|
|
||||||
app.get('/api/v1/timelines/home', requirePubkey, homeController);
|
app.get('/api/v1/timelines/home', requirePubkey, homeTimelineController);
|
||||||
app.get('/api/v1/timelines/public', publicController);
|
app.get('/api/v1/timelines/public', publicTimelineController);
|
||||||
app.get('/api/v1/timelines/tag/:hashtag', hashtagTimelineController);
|
app.get('/api/v1/timelines/tag/:hashtag', hashtagTimelineController);
|
||||||
|
|
||||||
app.get('/api/v1/preferences', preferencesController);
|
app.get('/api/v1/preferences', preferencesController);
|
||||||
|
|
|
@ -6,7 +6,7 @@ import { booleanParamSchema } from '@/schema.ts';
|
||||||
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
||||||
import { toAccount, toRelationship, toStatus } from '@/transformers/nostr-to-mastoapi.ts';
|
import { toAccount, toRelationship, toStatus } from '@/transformers/nostr-to-mastoapi.ts';
|
||||||
import { eventDateComparator, isFollowing, lookupAccount } from '@/utils.ts';
|
import { eventDateComparator, isFollowing, lookupAccount } from '@/utils.ts';
|
||||||
import { buildLinkHeader, paginationSchema, parseBody } from '@/utils/web.ts';
|
import { paginated, paginationSchema, parseBody } from '@/utils/web.ts';
|
||||||
import { createEvent } from '@/utils/web.ts';
|
import { createEvent } from '@/utils/web.ts';
|
||||||
|
|
||||||
const createAccountController: AppController = (c) => {
|
const createAccountController: AppController = (c) => {
|
||||||
|
@ -110,9 +110,7 @@ const accountStatusesController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const statuses = await Promise.all(events.map(toStatus));
|
const statuses = await Promise.all(events.map(toStatus));
|
||||||
|
return paginated(c, events, statuses);
|
||||||
const link = buildLinkHeader(c.req.url, events);
|
|
||||||
return c.json(statuses, 200, link ? { link } : undefined);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fileSchema = z.custom<File>((value) => value instanceof File);
|
const fileSchema = z.custom<File>((value) => value instanceof File);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Context } from '@/deps.ts';
|
import type { AppController } from '@/app.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apps are unnecessary cruft in Mastodon API, but necessary to make clients work.
|
* Apps are unnecessary cruft in Mastodon API, but necessary to make clients work.
|
||||||
|
@ -14,7 +14,7 @@ const FAKE_APP = {
|
||||||
vapid_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
vapid_key: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=',
|
||||||
};
|
};
|
||||||
|
|
||||||
async function createAppController(c: Context) {
|
const createAppController: AppController = async (c) => {
|
||||||
// TODO: Handle both formData and json. 422 on parsing error.
|
// TODO: Handle both formData and json. 422 on parsing error.
|
||||||
try {
|
try {
|
||||||
const { redirect_uris } = await c.req.json();
|
const { redirect_uris } = await c.req.json();
|
||||||
|
@ -26,10 +26,10 @@ async function createAppController(c: Context) {
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
return c.json(FAKE_APP);
|
return c.json(FAKE_APP);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function appCredentialsController(c: Context) {
|
const appCredentialsController: AppController = (c) => {
|
||||||
return c.json(FAKE_APP);
|
return c.json(FAKE_APP);
|
||||||
}
|
};
|
||||||
|
|
||||||
export { appCredentialsController, createAppController };
|
export { appCredentialsController, createAppController };
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { type AppController } from '@/app.ts';
|
import { type AppController } from '@/app.ts';
|
||||||
import * as mixer from '@/mixer.ts';
|
import * as mixer from '@/mixer.ts';
|
||||||
import { buildLinkHeader, paginationSchema } from '@/utils/web.ts';
|
import { paginated, paginationSchema } from '@/utils/web.ts';
|
||||||
import { toNotification } from '@/transformers/nostr-to-mastoapi.ts';
|
import { toNotification } from '@/transformers/nostr-to-mastoapi.ts';
|
||||||
import { Time } from '@/utils.ts';
|
import { Time } from '@/utils.ts';
|
||||||
|
|
||||||
|
@ -14,9 +14,7 @@ const notificationsController: AppController = async (c) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const statuses = await Promise.all(events.map(toNotification));
|
const statuses = await Promise.all(events.map(toNotification));
|
||||||
|
return paginated(c, events, statuses);
|
||||||
const link = buildLinkHeader(c.req.url, events);
|
|
||||||
return c.json(statuses, 200, link ? { link } : undefined);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export { notificationsController };
|
export { notificationsController };
|
||||||
|
|
|
@ -1,64 +1,47 @@
|
||||||
import { z } from '@/deps.ts';
|
import { z } from '@/deps.ts';
|
||||||
|
import { type DittoFilter } from '@/filter.ts';
|
||||||
import * as mixer from '@/mixer.ts';
|
import * as mixer from '@/mixer.ts';
|
||||||
import { getFeed, getPublicFeed } from '@/queries.ts';
|
import { getFeedPubkeys } from '@/queries.ts';
|
||||||
import { booleanParamSchema } from '@/schema.ts';
|
import { booleanParamSchema } from '@/schema.ts';
|
||||||
import { toStatus } from '@/transformers/nostr-to-mastoapi.ts';
|
import { toStatus } from '@/transformers/nostr-to-mastoapi.ts';
|
||||||
import { buildLinkHeader, paginationSchema } from '@/utils/web.ts';
|
import { paginated, paginationSchema } from '@/utils/web.ts';
|
||||||
import { Time } from '@/utils.ts';
|
import { Time } from '@/utils.ts';
|
||||||
|
|
||||||
import type { AppController } from '@/app.ts';
|
import type { AppContext, AppController } from '@/app.ts';
|
||||||
|
|
||||||
const homeController: AppController = async (c) => {
|
const homeTimelineController: AppController = async (c) => {
|
||||||
const params = paginationSchema.parse(c.req.query());
|
const params = paginationSchema.parse(c.req.query());
|
||||||
const pubkey = c.get('pubkey')!;
|
const pubkey = c.get('pubkey')!;
|
||||||
|
const authors = await getFeedPubkeys(pubkey);
|
||||||
const events = await getFeed(pubkey, params);
|
return renderStatuses(c, [{ authors, kinds: [1], ...params }]);
|
||||||
if (!events.length) {
|
|
||||||
return c.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const statuses = (await Promise.all(events.map(toStatus))).filter(Boolean);
|
|
||||||
|
|
||||||
const link = buildLinkHeader(c.req.url, events);
|
|
||||||
return c.json(statuses, 200, link ? { link } : undefined);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const publicQuerySchema = z.object({
|
const publicQuerySchema = z.object({
|
||||||
local: booleanParamSchema.catch(false),
|
local: booleanParamSchema.catch(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
const publicController: AppController = async (c) => {
|
const publicTimelineController: AppController = (c) => {
|
||||||
const params = paginationSchema.parse(c.req.query());
|
const params = paginationSchema.parse(c.req.query());
|
||||||
const { local } = publicQuerySchema.parse(c.req.query());
|
const { local } = publicQuerySchema.parse(c.req.query());
|
||||||
|
return renderStatuses(c, [{ kinds: [1], local, ...params }]);
|
||||||
const events = await getPublicFeed(params, local);
|
|
||||||
if (!events.length) {
|
|
||||||
return c.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const statuses = (await Promise.all(events.map(toStatus))).filter(Boolean);
|
|
||||||
|
|
||||||
const link = buildLinkHeader(c.req.url, events);
|
|
||||||
return c.json(statuses, 200, link ? { link } : undefined);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hashtagTimelineController: AppController = async (c) => {
|
const hashtagTimelineController: AppController = (c) => {
|
||||||
const hashtag = c.req.param('hashtag')!;
|
const hashtag = c.req.param('hashtag')!;
|
||||||
const params = paginationSchema.parse(c.req.query());
|
const params = paginationSchema.parse(c.req.query());
|
||||||
|
return renderStatuses(c, [{ kinds: [1], '#t': [hashtag], ...params }]);
|
||||||
|
};
|
||||||
|
|
||||||
const events = await mixer.getFilters(
|
/** Render statuses for timelines. */
|
||||||
[{ kinds: [1], '#t': [hashtag], ...params }],
|
async function renderStatuses(c: AppContext, filters: DittoFilter<1>[]) {
|
||||||
{ timeout: Time.seconds(3) },
|
const events = await mixer.getFilters(filters, { timeout: Time.seconds(3) });
|
||||||
);
|
|
||||||
|
|
||||||
if (!events.length) {
|
if (!events.length) {
|
||||||
return c.json([]);
|
return c.json([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statuses = (await Promise.all(events.map(toStatus))).filter(Boolean);
|
const statuses = await Promise.all(events.map(toStatus));
|
||||||
|
return paginated(c, events, statuses);
|
||||||
|
}
|
||||||
|
|
||||||
const link = buildLinkHeader(c.req.url, events);
|
export { hashtagTimelineController, homeTimelineController, publicTimelineController };
|
||||||
return c.json(statuses, 200, link ? { link } : undefined);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { hashtagTimelineController, homeController, publicController };
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
|
import { findUser } from '@/db/users.ts';
|
||||||
import { nip19, z } from '@/deps.ts';
|
import { nip19, z } from '@/deps.ts';
|
||||||
|
|
||||||
import type { AppContext, AppController } from '@/app.ts';
|
import type { AppContext, AppController } from '@/app.ts';
|
||||||
import type { Webfinger } from '@/schemas/webfinger.ts';
|
import type { Webfinger } from '@/schemas/webfinger.ts';
|
||||||
import { findUser } from '@/db/users.ts';
|
|
||||||
|
|
||||||
const webfingerQuerySchema = z.object({
|
const webfingerQuerySchema = z.object({
|
||||||
resource: z.string().url(),
|
resource: z.string().url(),
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { db, type TagRow } from '@/db.ts';
|
import { db, type TagRow } from '@/db.ts';
|
||||||
import { type Event, type Insertable } from '@/deps.ts';
|
import { type Event, type Insertable, SqliteError } from '@/deps.ts';
|
||||||
|
|
||||||
import type { DittoFilter, GetFiltersOpts } from '@/filter.ts';
|
import type { DittoFilter, GetFiltersOpts } from '@/filter.ts';
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ function insertEvent(event: Event): Promise<void> {
|
||||||
...event,
|
...event,
|
||||||
tags: JSON.stringify(event.tags),
|
tags: JSON.stringify(event.tags),
|
||||||
})
|
})
|
||||||
.executeTakeFirst();
|
.execute();
|
||||||
|
|
||||||
const tagCounts: Record<string, number> = {};
|
const tagCounts: Record<string, number> = {};
|
||||||
const tags = event.tags.reduce<Insertable<TagRow>[]>((results, tag) => {
|
const tags = event.tags.reduce<Insertable<TagRow>[]>((results, tag) => {
|
||||||
|
@ -48,6 +48,13 @@ function insertEvent(event: Event): Promise<void> {
|
||||||
.values(tags)
|
.values(tags)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
// Don't throw for duplicate events.
|
||||||
|
if (error instanceof SqliteError && error.code === 19) {
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -51,7 +51,7 @@ export {
|
||||||
export { generateSeededRsa } from 'https://gitlab.com/soapbox-pub/seeded-rsa/-/raw/v1.0.0/mod.ts';
|
export { generateSeededRsa } from 'https://gitlab.com/soapbox-pub/seeded-rsa/-/raw/v1.0.0/mod.ts';
|
||||||
export * as secp from 'npm:@noble/secp256k1@^2.0.0';
|
export * as secp from 'npm:@noble/secp256k1@^2.0.0';
|
||||||
export { LRUCache } from 'npm:lru-cache@^10.0.0';
|
export { LRUCache } from 'npm:lru-cache@^10.0.0';
|
||||||
export { DB as Sqlite } from 'https://deno.land/x/sqlite@v3.7.3/mod.ts';
|
export { DB as Sqlite, SqliteError } from 'https://deno.land/x/sqlite@v3.7.3/mod.ts';
|
||||||
export * as dotenv from 'https://deno.land/std@0.198.0/dotenv/mod.ts';
|
export * as dotenv from 'https://deno.land/std@0.198.0/dotenv/mod.ts';
|
||||||
export {
|
export {
|
||||||
FileMigrationProvider,
|
FileMigrationProvider,
|
||||||
|
|
|
@ -2,7 +2,6 @@ import * as client from '@/client.ts';
|
||||||
import * as eventsDB from '@/db/events.ts';
|
import * as eventsDB from '@/db/events.ts';
|
||||||
import { type Event, type Filter, findReplyTag } from '@/deps.ts';
|
import { type Event, type Filter, findReplyTag } from '@/deps.ts';
|
||||||
import * as mixer from '@/mixer.ts';
|
import * as mixer from '@/mixer.ts';
|
||||||
import { type PaginationParams } from '@/utils/web.ts';
|
|
||||||
|
|
||||||
interface GetEventOpts<K extends number> {
|
interface GetEventOpts<K extends number> {
|
||||||
/** Timeout in milliseconds. */
|
/** Timeout in milliseconds. */
|
||||||
|
@ -53,24 +52,6 @@ async function getFeedPubkeys(pubkey: string): Promise<string[]> {
|
||||||
return [...authors, pubkey];
|
return [...authors, pubkey];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get events from people the user follows. */
|
|
||||||
async function getFeed(pubkey: string, params: PaginationParams): Promise<Event<1>[]> {
|
|
||||||
const authors = await getFeedPubkeys(pubkey);
|
|
||||||
|
|
||||||
const filter: Filter<1> = {
|
|
||||||
authors,
|
|
||||||
kinds: [1],
|
|
||||||
...params,
|
|
||||||
};
|
|
||||||
|
|
||||||
return mixer.getFilters([filter], { timeout: 5000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get a feed of all known text notes. */
|
|
||||||
function getPublicFeed(params: PaginationParams, local: boolean): Promise<Event<1>[]> {
|
|
||||||
return mixer.getFilters([{ kinds: [1], local, ...params }], { timeout: 5000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAncestors(event: Event<1>, result = [] as Event<1>[]): Promise<Event<1>[]> {
|
async function getAncestors(event: Event<1>, result = [] as Event<1>[]): Promise<Event<1>[]> {
|
||||||
if (result.length < 100) {
|
if (result.length < 100) {
|
||||||
const replyTag = findReplyTag(event);
|
const replyTag = findReplyTag(event);
|
||||||
|
@ -106,15 +87,4 @@ async function syncUser(pubkey: string): Promise<void> {
|
||||||
], { timeout: 5000 });
|
], { timeout: 5000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { getAncestors, getAuthor, getDescendants, getEvent, getFeedPubkeys, getFollows, isLocallyFollowed, syncUser };
|
||||||
getAncestors,
|
|
||||||
getAuthor,
|
|
||||||
getDescendants,
|
|
||||||
getEvent,
|
|
||||||
getFeed,
|
|
||||||
getFeedPubkeys,
|
|
||||||
getFollows,
|
|
||||||
getPublicFeed,
|
|
||||||
isLocallyFollowed,
|
|
||||||
syncUser,
|
|
||||||
};
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ function filteredArray<T extends z.ZodTypeAny>(schema: T) {
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Parses a JSON string into its native type. */
|
||||||
const jsonSchema = z.string().transform((value, ctx) => {
|
const jsonSchema = z.string().transform((value, ctx) => {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(value) as unknown;
|
return JSON.parse(value) as unknown;
|
||||||
|
@ -20,6 +21,7 @@ const jsonSchema = z.string().transform((value, ctx) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Parses a Nostr emoji tag. */
|
||||||
const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()]);
|
const emojiTagSchema = z.tuple([z.literal('emoji'), z.string(), z.string().url()]);
|
||||||
|
|
||||||
/** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */
|
/** https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem */
|
||||||
|
@ -34,6 +36,7 @@ const decode64Schema = z.string().transform((value, ctx) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Parses a hashtag, eg `#yolo`. */
|
||||||
const hashtagSchema = z.string().regex(/^\w{1,30}$/);
|
const hashtagSchema = z.string().regex(/^\w{1,30}$/);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -89,6 +89,22 @@ function buildLinkHeader(url: string, events: Event[]): string | undefined {
|
||||||
return `<${next}>; rel="next", <${prev}>; rel="prev"`;
|
return `<${next}>; rel="next", <${prev}>; rel="prev"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Entity = { id: string };
|
||||||
|
type HeaderRecord = Record<string, string | string[]>;
|
||||||
|
|
||||||
|
/** Return results with pagination headers. */
|
||||||
|
function paginated(c: AppContext, events: Event[], entities: (Entity | undefined)[], headers: HeaderRecord = {}) {
|
||||||
|
const link = buildLinkHeader(c.req.url, events);
|
||||||
|
|
||||||
|
if (link) {
|
||||||
|
headers.link = link;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out undefined entities.
|
||||||
|
const results = entities.filter((entity): entity is Entity => Boolean(entity));
|
||||||
|
return c.json(results, 200, headers);
|
||||||
|
}
|
||||||
|
|
||||||
/** JSON-LD context. */
|
/** JSON-LD context. */
|
||||||
type LDContext = (string | Record<string, string | Record<string, string>>)[];
|
type LDContext = (string | Record<string, string | Record<string, string>>)[];
|
||||||
|
|
||||||
|
@ -107,12 +123,4 @@ function activityJson<T, P extends string>(c: Context<any, P>, object: T) {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { activityJson, createAdminEvent, createEvent, paginated, type PaginationParams, paginationSchema, parseBody };
|
||||||
activityJson,
|
|
||||||
buildLinkHeader,
|
|
||||||
createAdminEvent,
|
|
||||||
createEvent,
|
|
||||||
type PaginationParams,
|
|
||||||
paginationSchema,
|
|
||||||
parseBody,
|
|
||||||
};
|
|
||||||
|
|
Loading…
Reference in New Issue