mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Merge branch 'media' into 'develop'
Media uploads Closes #50 See merge request soapbox-pub/ditto!37
This commit is contained in:
commit
35b91812fc
29 changed files with 788 additions and 162 deletions
|
|
@ -29,6 +29,7 @@ import {
|
||||||
import { appCredentialsController, createAppController } from './controllers/api/apps.ts';
|
import { appCredentialsController, createAppController } from './controllers/api/apps.ts';
|
||||||
import { emptyArrayController, emptyObjectController } from './controllers/api/fallback.ts';
|
import { emptyArrayController, emptyObjectController } from './controllers/api/fallback.ts';
|
||||||
import { instanceController } from './controllers/api/instance.ts';
|
import { instanceController } from './controllers/api/instance.ts';
|
||||||
|
import { mediaController } from './controllers/api/media.ts';
|
||||||
import { notificationsController } from './controllers/api/notifications.ts';
|
import { notificationsController } from './controllers/api/notifications.ts';
|
||||||
import { createTokenController, oauthAuthorizeController, oauthController } from './controllers/api/oauth.ts';
|
import { createTokenController, oauthAuthorizeController, oauthController } from './controllers/api/oauth.ts';
|
||||||
import { frontendConfigController, updateConfigController } from './controllers/api/pleroma.ts';
|
import { frontendConfigController, updateConfigController } from './controllers/api/pleroma.ts';
|
||||||
|
|
@ -56,7 +57,7 @@ import { nodeInfoController, nodeInfoSchemaController } from './controllers/well
|
||||||
import { nostrController } from './controllers/well-known/nostr.ts';
|
import { nostrController } from './controllers/well-known/nostr.ts';
|
||||||
import { webfingerController } from './controllers/well-known/webfinger.ts';
|
import { webfingerController } from './controllers/well-known/webfinger.ts';
|
||||||
import { auth19, requirePubkey } from './middleware/auth19.ts';
|
import { auth19, requirePubkey } from './middleware/auth19.ts';
|
||||||
import { auth98, requireAdmin } from './middleware/auth98.ts';
|
import { auth98, requireRole } from './middleware/auth98.ts';
|
||||||
|
|
||||||
interface AppEnv extends HonoEnv {
|
interface AppEnv extends HonoEnv {
|
||||||
Variables: {
|
Variables: {
|
||||||
|
|
@ -121,6 +122,9 @@ 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.post('/api/v1/media', requireRole('user', { validatePayload: false }), mediaController);
|
||||||
|
app.post('/api/v2/media', requireRole('user', { validatePayload: false }), mediaController);
|
||||||
|
|
||||||
app.get('/api/v1/timelines/home', requirePubkey, homeTimelineController);
|
app.get('/api/v1/timelines/home', requirePubkey, homeTimelineController);
|
||||||
app.get('/api/v1/timelines/public', publicTimelineController);
|
app.get('/api/v1/timelines/public', publicTimelineController);
|
||||||
app.get('/api/v1/timelines/tag/:hashtag', hashtagTimelineController);
|
app.get('/api/v1/timelines/tag/:hashtag', hashtagTimelineController);
|
||||||
|
|
@ -137,7 +141,7 @@ app.get('/api/v1/trends', trendingTagsController);
|
||||||
app.get('/api/v1/notifications', requirePubkey, notificationsController);
|
app.get('/api/v1/notifications', requirePubkey, notificationsController);
|
||||||
app.get('/api/v1/favourites', requirePubkey, favouritesController);
|
app.get('/api/v1/favourites', requirePubkey, favouritesController);
|
||||||
|
|
||||||
app.post('/api/v1/pleroma/admin/config', requireAdmin, updateConfigController);
|
app.post('/api/v1/pleroma/admin/config', requireRole('admin'), updateConfigController);
|
||||||
|
|
||||||
// Not (yet) implemented.
|
// Not (yet) implemented.
|
||||||
app.get('/api/v1/bookmarks', emptyArrayController);
|
app.get('/api/v1/bookmarks', emptyArrayController);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { dotenv, getPublicKey, nip19, secp } from '@/deps.ts';
|
import { dotenv, getPublicKey, nip19, secp, z } from '@/deps.ts';
|
||||||
|
|
||||||
/** Load environment config from `.env` */
|
/** Load environment config from `.env` */
|
||||||
await dotenv.load({
|
await dotenv.load({
|
||||||
|
|
@ -42,7 +42,7 @@ const Conf = {
|
||||||
const { protocol, host } = Conf.url;
|
const { protocol, host } = Conf.url;
|
||||||
return `${protocol === 'https:' ? 'wss:' : 'ws:'}//${host}/relay`;
|
return `${protocol === 'https:' ? 'wss:' : 'ws:'}//${host}/relay`;
|
||||||
},
|
},
|
||||||
/** Domain of the Ditto server, including the protocol. */
|
/** Origin of the Ditto server, including the protocol and port. */
|
||||||
get localDomain() {
|
get localDomain() {
|
||||||
return Deno.env.get('LOCAL_DOMAIN') || 'http://localhost:8000';
|
return Deno.env.get('LOCAL_DOMAIN') || 'http://localhost:8000';
|
||||||
},
|
},
|
||||||
|
|
@ -58,22 +58,96 @@ const Conf = {
|
||||||
get adminEmail() {
|
get adminEmail() {
|
||||||
return Deno.env.get('ADMIN_EMAIL') || 'webmaster@localhost';
|
return Deno.env.get('ADMIN_EMAIL') || 'webmaster@localhost';
|
||||||
},
|
},
|
||||||
|
/** S3 media storage configuration. */
|
||||||
|
s3: {
|
||||||
|
get endPoint() {
|
||||||
|
return Deno.env.get('S3_ENDPOINT')!;
|
||||||
|
},
|
||||||
|
get region() {
|
||||||
|
return Deno.env.get('S3_REGION')!;
|
||||||
|
},
|
||||||
|
get accessKey() {
|
||||||
|
return Deno.env.get('S3_ACCESS_KEY');
|
||||||
|
},
|
||||||
|
get secretKey() {
|
||||||
|
return Deno.env.get('S3_SECRET_KEY');
|
||||||
|
},
|
||||||
|
get bucket() {
|
||||||
|
return Deno.env.get('S3_BUCKET');
|
||||||
|
},
|
||||||
|
get pathStyle() {
|
||||||
|
return optionalBooleanSchema.parse(Deno.env.get('S3_PATH_STYLE'));
|
||||||
|
},
|
||||||
|
get port() {
|
||||||
|
return optionalNumberSchema.parse(Deno.env.get('S3_PORT'));
|
||||||
|
},
|
||||||
|
get sessionToken() {
|
||||||
|
return Deno.env.get('S3_SESSION_TOKEN');
|
||||||
|
},
|
||||||
|
get useSSL() {
|
||||||
|
return optionalBooleanSchema.parse(Deno.env.get('S3_USE_SSL'));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/** IPFS uploader configuration. */
|
||||||
|
ipfs: {
|
||||||
|
/** Base URL for private IPFS API calls. */
|
||||||
|
get apiUrl() {
|
||||||
|
return Deno.env.get('IPFS_API_URL') || 'http://localhost:5001';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
/** Module to upload files with. */
|
||||||
|
get uploader() {
|
||||||
|
return Deno.env.get('DITTO_UPLOADER');
|
||||||
|
},
|
||||||
|
/** Media base URL for uploads. */
|
||||||
|
get mediaDomain() {
|
||||||
|
const value = Deno.env.get('MEDIA_DOMAIN');
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
const url = Conf.url;
|
||||||
|
url.host = `media.${url.host}`;
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
/** Max upload size for files in number of bytes. Default 100MiB. */
|
||||||
|
get maxUploadSize() {
|
||||||
|
return Number(Deno.env.get('MAX_UPLOAD_SIZE') || 100 * 1024 * 1024);
|
||||||
|
},
|
||||||
/** Domain of the Ditto server as a `URL` object, for easily grabbing the `hostname`, etc. */
|
/** Domain of the Ditto server as a `URL` object, for easily grabbing the `hostname`, etc. */
|
||||||
get url() {
|
get url() {
|
||||||
return new URL(Conf.localDomain);
|
return new URL(Conf.localDomain);
|
||||||
},
|
},
|
||||||
/** Merges the path with the localDomain. */
|
/** Merges the path with the localDomain. */
|
||||||
local(path: string): string {
|
local(path: string): string {
|
||||||
const url = new URL(path.startsWith('/') ? path : new URL(path).pathname, Conf.localDomain);
|
return mergePaths(Conf.localDomain, path);
|
||||||
|
|
||||||
if (!path.startsWith('/')) {
|
|
||||||
// Copy query parameters from the original URL to the new URL
|
|
||||||
const originalUrl = new URL(path);
|
|
||||||
url.search = originalUrl.search;
|
|
||||||
}
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const optionalBooleanSchema = z
|
||||||
|
.enum(['true', 'false'])
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value !== undefined ? value === 'true' : undefined);
|
||||||
|
|
||||||
|
const optionalNumberSchema = z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value !== undefined ? Number(value) : undefined);
|
||||||
|
|
||||||
|
function mergePaths(base: string, path: string) {
|
||||||
|
const url = new URL(
|
||||||
|
path.startsWith('/') ? path : new URL(path).pathname,
|
||||||
|
base,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!path.startsWith('/')) {
|
||||||
|
// Copy query parameters from the original URL to the new URL
|
||||||
|
const originalUrl = new URL(path);
|
||||||
|
url.search = originalUrl.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export { Conf };
|
export { Conf };
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { type AppController } from '@/app.ts';
|
||||||
import { type Filter, findReplyTag, z } from '@/deps.ts';
|
import { type Filter, findReplyTag, z } from '@/deps.ts';
|
||||||
import * as mixer from '@/mixer.ts';
|
import * as mixer from '@/mixer.ts';
|
||||||
import { getAuthor, getFollowedPubkeys, getFollows, syncUser } from '@/queries.ts';
|
import { getAuthor, getFollowedPubkeys, getFollows, syncUser } from '@/queries.ts';
|
||||||
import { booleanParamSchema } from '@/schema.ts';
|
import { booleanParamSchema, fileSchema } 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 { isFollowing, lookupAccount, Time } from '@/utils.ts';
|
import { isFollowing, lookupAccount, Time } from '@/utils.ts';
|
||||||
|
|
@ -113,8 +113,6 @@ const accountStatusesController: AppController = async (c) => {
|
||||||
return paginated(c, events, statuses);
|
return paginated(c, events, statuses);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fileSchema = z.custom<File>((value) => value instanceof File);
|
|
||||||
|
|
||||||
const updateCredentialsSchema = z.object({
|
const updateCredentialsSchema = z.object({
|
||||||
display_name: z.string().optional(),
|
display_name: z.string().optional(),
|
||||||
note: z.string().optional(),
|
note: z.string().optional(),
|
||||||
|
|
|
||||||
52
src/controllers/api/media.ts
Normal file
52
src/controllers/api/media.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { AppController } from '@/app.ts';
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
import { insertUnattachedMedia } from '@/db/unattached-media.ts';
|
||||||
|
import { z } from '@/deps.ts';
|
||||||
|
import { fileSchema } from '@/schema.ts';
|
||||||
|
import { configUploader as uploader } from '@/uploaders/config.ts';
|
||||||
|
import { parseBody } from '@/utils/web.ts';
|
||||||
|
import { renderAttachment } from '@/views/attachment.ts';
|
||||||
|
|
||||||
|
const uploadSchema = fileSchema
|
||||||
|
.refine((file) => !!file.type, 'File type is required.')
|
||||||
|
.refine((file) => file.size <= Conf.maxUploadSize, 'File size is too large.');
|
||||||
|
|
||||||
|
const mediaBodySchema = z.object({
|
||||||
|
file: uploadSchema,
|
||||||
|
thumbnail: uploadSchema.optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
focus: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mediaController: AppController = async (c) => {
|
||||||
|
const result = mediaBodySchema.safeParse(await parseBody(c.req.raw));
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return c.json({ error: 'Bad request.', schema: result.error }, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { file, description } = result.data;
|
||||||
|
const { cid } = await uploader.upload(file);
|
||||||
|
|
||||||
|
const url = new URL(`/ipfs/${cid}`, Conf.mediaDomain).toString();
|
||||||
|
|
||||||
|
const media = await insertUnattachedMedia({
|
||||||
|
pubkey: c.get('pubkey')!,
|
||||||
|
url,
|
||||||
|
data: {
|
||||||
|
name: file.name,
|
||||||
|
mime: file.type,
|
||||||
|
size: file.size,
|
||||||
|
description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(renderAttachment(media));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return c.json({ error: 'Failed to upload file.' }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { mediaController };
|
||||||
|
|
@ -4,6 +4,7 @@ import { getAncestors, getDescendants, getEvent } from '@/queries.ts';
|
||||||
import { toStatus } from '@/transformers/nostr-to-mastoapi.ts';
|
import { toStatus } from '@/transformers/nostr-to-mastoapi.ts';
|
||||||
import { createEvent, paginationSchema, parseBody } from '@/utils/web.ts';
|
import { createEvent, paginationSchema, parseBody } from '@/utils/web.ts';
|
||||||
import { renderEventAccounts } from '@/views.ts';
|
import { renderEventAccounts } from '@/views.ts';
|
||||||
|
import { getUnattachedMediaByIds } from '@/db/unattached-media.ts';
|
||||||
|
|
||||||
const createStatusSchema = z.object({
|
const createStatusSchema = z.object({
|
||||||
in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(),
|
in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(),
|
||||||
|
|
@ -40,45 +41,49 @@ const createStatusController: AppController = async (c) => {
|
||||||
const body = await parseBody(c.req.raw);
|
const body = await parseBody(c.req.raw);
|
||||||
const result = createStatusSchema.safeParse(body);
|
const result = createStatusSchema.safeParse(body);
|
||||||
|
|
||||||
if (result.success) {
|
if (!result.success) {
|
||||||
const { data } = result;
|
|
||||||
|
|
||||||
if (data.visibility !== 'public') {
|
|
||||||
return c.json({ error: 'Only posting publicly is supported.' }, 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.poll) {
|
|
||||||
return c.json({ error: 'Polls are not yet supported.' }, 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.media_ids?.length) {
|
|
||||||
return c.json({ error: 'Media uploads are not yet supported.' }, 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags: string[][] = [];
|
|
||||||
|
|
||||||
if (data.in_reply_to_id) {
|
|
||||||
tags.push(['e', data.in_reply_to_id, 'reply']);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.sensitive && data.spoiler_text) {
|
|
||||||
tags.push(['content-warning', data.spoiler_text]);
|
|
||||||
} else if (data.sensitive) {
|
|
||||||
tags.push(['content-warning']);
|
|
||||||
} else if (data.spoiler_text) {
|
|
||||||
tags.push(['subject', data.spoiler_text]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = await createEvent({
|
|
||||||
kind: 1,
|
|
||||||
content: data.status ?? '',
|
|
||||||
tags,
|
|
||||||
}, c);
|
|
||||||
|
|
||||||
return c.json(await toStatus(event, c.get('pubkey')));
|
|
||||||
} else {
|
|
||||||
return c.json({ error: 'Bad request', schema: result.error }, 400);
|
return c.json({ error: 'Bad request', schema: result.error }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { data } = result;
|
||||||
|
|
||||||
|
if (data.visibility !== 'public') {
|
||||||
|
return c.json({ error: 'Only posting publicly is supported.' }, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.poll) {
|
||||||
|
return c.json({ error: 'Polls are not yet supported.' }, 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags: string[][] = [];
|
||||||
|
|
||||||
|
if (data.in_reply_to_id) {
|
||||||
|
tags.push(['e', data.in_reply_to_id, 'reply']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.sensitive && data.spoiler_text) {
|
||||||
|
tags.push(['content-warning', data.spoiler_text]);
|
||||||
|
} else if (data.sensitive) {
|
||||||
|
tags.push(['content-warning']);
|
||||||
|
} else if (data.spoiler_text) {
|
||||||
|
tags.push(['subject', data.spoiler_text]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.media_ids?.length) {
|
||||||
|
const media = await getUnattachedMediaByIds(data.media_ids)
|
||||||
|
.then((media) => media.filter(({ pubkey }) => pubkey === c.get('pubkey')))
|
||||||
|
.then((media) => media.map(({ url, data }) => ['media', url, data]));
|
||||||
|
|
||||||
|
tags.push(...media);
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await createEvent({
|
||||||
|
kind: 1,
|
||||||
|
content: data.status ?? '',
|
||||||
|
tags,
|
||||||
|
}, c);
|
||||||
|
|
||||||
|
return c.json(await toStatus(event, c.get('pubkey')));
|
||||||
};
|
};
|
||||||
|
|
||||||
const contextController: AppController = async (c) => {
|
const contextController: AppController = async (c) => {
|
||||||
|
|
|
||||||
26
src/cron.ts
26
src/cron.ts
|
|
@ -1,6 +1,9 @@
|
||||||
import * as eventsDB from '@/db/events.ts';
|
import * as eventsDB from '@/db/events.ts';
|
||||||
|
import { deleteUnattachedMediaByUrl, getUnattachedMedia } from '@/db/unattached-media.ts';
|
||||||
import { cron } from '@/deps.ts';
|
import { cron } from '@/deps.ts';
|
||||||
import { Time } from '@/utils/time.ts';
|
import { Time } from '@/utils/time.ts';
|
||||||
|
import { configUploader as uploader } from '@/uploaders/config.ts';
|
||||||
|
import { cidFromUrl } from '@/utils/ipfs.ts';
|
||||||
|
|
||||||
/** Clean up old remote events. */
|
/** Clean up old remote events. */
|
||||||
async function cleanupEvents() {
|
async function cleanupEvents() {
|
||||||
|
|
@ -14,6 +17,29 @@ async function cleanupEvents() {
|
||||||
console.log(`Cleaned up ${result?.numDeletedRows ?? 0} old remote events.`);
|
console.log(`Cleaned up ${result?.numDeletedRows ?? 0} old remote events.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete files that aren't attached to any events. */
|
||||||
|
async function cleanupMedia() {
|
||||||
|
console.log('Deleting orphaned media files...');
|
||||||
|
|
||||||
|
const until = new Date(Date.now() - Time.minutes(15));
|
||||||
|
const media = await getUnattachedMedia(until);
|
||||||
|
|
||||||
|
for (const { url } of media) {
|
||||||
|
const cid = cidFromUrl(new URL(url))!;
|
||||||
|
try {
|
||||||
|
await uploader.delete(cid);
|
||||||
|
await deleteUnattachedMediaByUrl(url);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to delete file ${url}`);
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Removed ${media?.length ?? 0} orphaned media files.`);
|
||||||
|
}
|
||||||
|
|
||||||
await cleanupEvents();
|
await cleanupEvents();
|
||||||
|
await cleanupMedia();
|
||||||
|
|
||||||
cron.every15Minute(cleanupEvents);
|
cron.every15Minute(cleanupEvents);
|
||||||
|
cron.every15Minute(cleanupMedia);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface DittoDB {
|
||||||
tags: TagRow;
|
tags: TagRow;
|
||||||
users: UserRow;
|
users: UserRow;
|
||||||
relays: RelayRow;
|
relays: RelayRow;
|
||||||
|
unattached_media: UnattachedMediaRow;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventRow {
|
interface EventRow {
|
||||||
|
|
@ -46,6 +47,14 @@ interface RelayRow {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UnattachedMediaRow {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
url: string;
|
||||||
|
data: string;
|
||||||
|
uploaded_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
const db = new Kysely<DittoDB>({
|
const db = new Kysely<DittoDB>({
|
||||||
dialect: new DenoSqliteDialect({
|
dialect: new DenoSqliteDialect({
|
||||||
database: new Sqlite(Conf.dbPath),
|
database: new Sqlite(Conf.dbPath),
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,12 @@ import { insertUser } from '@/db/users.ts';
|
||||||
|
|
||||||
Deno.test('count filters', async () => {
|
Deno.test('count filters', async () => {
|
||||||
assertEquals(await countFilters([{ kinds: [1] }]), 0);
|
assertEquals(await countFilters([{ kinds: [1] }]), 0);
|
||||||
await insertEvent(event55920b75);
|
await insertEvent(event55920b75, { user: undefined });
|
||||||
assertEquals(await countFilters([{ kinds: [1] }]), 1);
|
assertEquals(await countFilters([{ kinds: [1] }]), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('insert and filter events', async () => {
|
Deno.test('insert and filter events', async () => {
|
||||||
await insertEvent(event55920b75);
|
await insertEvent(event55920b75, { user: undefined });
|
||||||
|
|
||||||
assertEquals(await getFilters([{ kinds: [1] }]), [event55920b75]);
|
assertEquals(await getFilters([{ kinds: [1] }]), [event55920b75]);
|
||||||
assertEquals(await getFilters([{ kinds: [3] }]), []);
|
assertEquals(await getFilters([{ kinds: [3] }]), []);
|
||||||
|
|
@ -24,14 +24,14 @@ Deno.test('insert and filter events', async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('delete events', async () => {
|
Deno.test('delete events', async () => {
|
||||||
await insertEvent(event55920b75);
|
await insertEvent(event55920b75, { user: undefined });
|
||||||
assertEquals(await getFilters([{ kinds: [1] }]), [event55920b75]);
|
assertEquals(await getFilters([{ kinds: [1] }]), [event55920b75]);
|
||||||
await deleteFilters([{ kinds: [1] }]);
|
await deleteFilters([{ kinds: [1] }]);
|
||||||
assertEquals(await getFilters([{ kinds: [1] }]), []);
|
assertEquals(await getFilters([{ kinds: [1] }]), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test('query events with local filter', async () => {
|
Deno.test('query events with local filter', async () => {
|
||||||
await insertEvent(event55920b75);
|
await insertEvent(event55920b75, { user: undefined });
|
||||||
|
|
||||||
assertEquals(await getFilters([{}]), [event55920b75]);
|
assertEquals(await getFilters([{}]), [event55920b75]);
|
||||||
assertEquals(await getFilters([{ local: true }]), []);
|
assertEquals(await getFilters([{ local: true }]), []);
|
||||||
|
|
|
||||||
113
src/db/events.ts
113
src/db/events.ts
|
|
@ -1,58 +1,67 @@
|
||||||
import { db, type TagRow } from '@/db.ts';
|
import { db } from '@/db.ts';
|
||||||
import { type Event, type Insertable, SqliteError } from '@/deps.ts';
|
import { type Event, SqliteError } from '@/deps.ts';
|
||||||
|
import { isParameterizedReplaceableKind } from '@/kinds.ts';
|
||||||
|
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
||||||
|
import { EventData } from '@/types.ts';
|
||||||
|
import { isNostrId, isURL } from '@/utils.ts';
|
||||||
|
|
||||||
import type { DittoFilter, GetFiltersOpts } from '@/filter.ts';
|
import type { DittoFilter, GetFiltersOpts } from '@/filter.ts';
|
||||||
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
|
||||||
|
|
||||||
type TagCondition = ({ event, count }: { event: Event; count: number }) => boolean;
|
/** Function to decide whether or not to index a tag. */
|
||||||
|
type TagCondition = ({ event, count, value }: {
|
||||||
|
event: Event;
|
||||||
|
data: EventData;
|
||||||
|
count: number;
|
||||||
|
value: string;
|
||||||
|
}) => boolean;
|
||||||
|
|
||||||
/** Conditions for when to index certain tags. */
|
/** Conditions for when to index certain tags. */
|
||||||
const tagConditions: Record<string, TagCondition> = {
|
const tagConditions: Record<string, TagCondition> = {
|
||||||
'd': ({ event, count }) => 30000 <= event.kind && event.kind < 40000 && count === 0,
|
'd': ({ event, count }) => count === 0 && isParameterizedReplaceableKind(event.kind),
|
||||||
'e': ({ count }) => count < 15,
|
'e': ({ count, value }) => count < 15 && isNostrId(value),
|
||||||
'p': ({ event, count }) => event.kind === 3 || count < 15,
|
'media': ({ count, value, data }) => (data.user || count < 4) && isURL(value),
|
||||||
'proxy': ({ count }) => count === 0,
|
'p': ({ event, count, value }) => (count < 15 || event.kind === 3) && isNostrId(value),
|
||||||
'q': ({ event, count }) => event.kind === 1 && count === 0,
|
'proxy': ({ count, value }) => count === 0 && isURL(value),
|
||||||
't': ({ count }) => count < 5,
|
'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value),
|
||||||
|
't': ({ count, value }) => count < 5 && value.length < 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Insert an event (and its tags) into the database. */
|
/** Insert an event (and its tags) into the database. */
|
||||||
function insertEvent(event: Event): Promise<void> {
|
function insertEvent(event: Event, data: EventData): Promise<void> {
|
||||||
return db.transaction().execute(async (trx) => {
|
return db.transaction().execute(async (trx) => {
|
||||||
await trx.insertInto('events')
|
/** Insert the event into the database. */
|
||||||
.values({
|
async function addEvent() {
|
||||||
...event,
|
await trx.insertInto('events')
|
||||||
tags: JSON.stringify(event.tags),
|
.values({ ...event, tags: JSON.stringify(event.tags) })
|
||||||
})
|
.execute();
|
||||||
.execute();
|
}
|
||||||
|
|
||||||
const searchContent = buildSearchContent(event);
|
/** Add search data to the FTS table. */
|
||||||
if (searchContent) {
|
async function indexSearch() {
|
||||||
|
const searchContent = buildSearchContent(event);
|
||||||
|
if (!searchContent) return;
|
||||||
await trx.insertInto('events_fts')
|
await trx.insertInto('events_fts')
|
||||||
.values({ id: event.id, content: searchContent.substring(0, 1000) })
|
.values({ id: event.id, content: searchContent.substring(0, 1000) })
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
const tagCounts: Record<string, number> = {};
|
/** Index event tags depending on the conditions defined above. */
|
||||||
const tags = event.tags.reduce<Insertable<TagRow>[]>((results, [name, value]) => {
|
async function indexTags() {
|
||||||
tagCounts[name] = (tagCounts[name] || 0) + 1;
|
const tags = filterIndexableTags(event, data);
|
||||||
|
const rows = tags.map(([tag, value]) => ({ event_id: event.id, tag, value }));
|
||||||
|
|
||||||
if (value && tagConditions[name]?.({ event, count: tagCounts[name] - 1 })) {
|
if (!tags.length) return;
|
||||||
results.push({
|
|
||||||
event_id: event.id,
|
|
||||||
tag: name,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (tags.length) {
|
|
||||||
await trx.insertInto('tags')
|
await trx.insertInto('tags')
|
||||||
.values(tags)
|
.values(rows)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run the queries.
|
||||||
|
await Promise.all([
|
||||||
|
addEvent(),
|
||||||
|
indexTags(),
|
||||||
|
indexSearch(),
|
||||||
|
]);
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
// Don't throw for duplicate events.
|
// Don't throw for duplicate events.
|
||||||
if (error instanceof SqliteError && error.code === 19) {
|
if (error instanceof SqliteError && error.code === 19) {
|
||||||
|
|
@ -181,6 +190,40 @@ async function countFilters<K extends number>(filters: DittoFilter<K>[]): Promis
|
||||||
return Number(count);
|
return Number(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Return only the tags that should be indexed. */
|
||||||
|
function filterIndexableTags(event: Event, data: EventData): string[][] {
|
||||||
|
const tagCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
function getCount(name: string) {
|
||||||
|
return tagCounts[name] || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function incrementCount(name: string) {
|
||||||
|
tagCounts[name] = getCount(name) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkCondition(name: string, value: string, condition: TagCondition) {
|
||||||
|
return condition({
|
||||||
|
event,
|
||||||
|
data,
|
||||||
|
count: getCount(name),
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return event.tags.reduce<string[][]>((results, tag) => {
|
||||||
|
const [name, value] = tag;
|
||||||
|
const condition = tagConditions[name] as TagCondition | undefined;
|
||||||
|
|
||||||
|
if (value && condition && value.length < 200 && checkCondition(name, value, condition)) {
|
||||||
|
results.push(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
incrementCount(name);
|
||||||
|
return results;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
/** Build a search index from the event. */
|
/** Build a search index from the event. */
|
||||||
function buildSearchContent(event: Event): string {
|
function buildSearchContent(event: Event): string {
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
|
|
|
||||||
34
src/db/migrations/007_unattached_media.ts
Normal file
34
src/db/migrations/007_unattached_media.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { Kysely, sql } from '@/deps.ts';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.createTable('unattached_media')
|
||||||
|
.addColumn('id', 'text', (c) => c.primaryKey())
|
||||||
|
.addColumn('pubkey', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('url', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('data', 'text', (c) => c.notNull())
|
||||||
|
.addColumn('uploaded_at', 'datetime', (c) => c.notNull().defaultTo(sql`CURRENT_TIMESTAMP`))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('unattached_media_id')
|
||||||
|
.on('unattached_media')
|
||||||
|
.column('id')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('unattached_media_pubkey')
|
||||||
|
.on('unattached_media')
|
||||||
|
.column('pubkey')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await db.schema
|
||||||
|
.createIndex('unattached_media_url')
|
||||||
|
.on('unattached_media')
|
||||||
|
.column('url')
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('unattached_media').execute();
|
||||||
|
}
|
||||||
77
src/db/unattached-media.ts
Normal file
77
src/db/unattached-media.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { db } from '@/db.ts';
|
||||||
|
import { uuid62 } from '@/deps.ts';
|
||||||
|
import { type MediaData } from '@/schemas/nostr.ts';
|
||||||
|
|
||||||
|
interface UnattachedMedia {
|
||||||
|
id: string;
|
||||||
|
pubkey: string;
|
||||||
|
url: string;
|
||||||
|
data: MediaData;
|
||||||
|
uploaded_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add unattached media into the database. */
|
||||||
|
async function insertUnattachedMedia(media: Omit<UnattachedMedia, 'id' | 'uploaded_at'>) {
|
||||||
|
const result = {
|
||||||
|
id: uuid62.v4(),
|
||||||
|
uploaded_at: new Date(),
|
||||||
|
...media,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.insertInto('unattached_media')
|
||||||
|
.values({ ...result, data: JSON.stringify(media.data) })
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Select query for unattached media. */
|
||||||
|
function selectUnattachedMediaQuery() {
|
||||||
|
return db.selectFrom('unattached_media')
|
||||||
|
.select([
|
||||||
|
'unattached_media.id',
|
||||||
|
'unattached_media.pubkey',
|
||||||
|
'unattached_media.url',
|
||||||
|
'unattached_media.data',
|
||||||
|
'unattached_media.uploaded_at',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find attachments that exist but aren't attached to any events. */
|
||||||
|
function getUnattachedMedia(until: Date) {
|
||||||
|
return selectUnattachedMediaQuery()
|
||||||
|
.leftJoin('tags', 'unattached_media.url', 'tags.value')
|
||||||
|
.where('uploaded_at', '<', until)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete unattached media by URL. */
|
||||||
|
function deleteUnattachedMediaByUrl(url: string) {
|
||||||
|
return db.deleteFrom('unattached_media')
|
||||||
|
.where('url', '=', url)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get unattached media by IDs. */
|
||||||
|
function getUnattachedMediaByIds(ids: string[]) {
|
||||||
|
return selectUnattachedMediaQuery()
|
||||||
|
.where('id', 'in', ids)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete rows as an event with media is being created. */
|
||||||
|
function deleteAttachedMedia(pubkey: string, urls: string[]) {
|
||||||
|
return db.deleteFrom('unattached_media')
|
||||||
|
.where('pubkey', '=', pubkey)
|
||||||
|
.where('url', 'in', urls)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
deleteAttachedMedia,
|
||||||
|
deleteUnattachedMediaByUrl,
|
||||||
|
getUnattachedMedia,
|
||||||
|
getUnattachedMediaByIds,
|
||||||
|
insertUnattachedMedia,
|
||||||
|
type UnattachedMedia,
|
||||||
|
};
|
||||||
|
|
@ -66,5 +66,8 @@ export {
|
||||||
export { DenoSqliteDialect } from 'https://gitlab.com/soapbox-pub/kysely-deno-sqlite/-/raw/v1.0.1/mod.ts';
|
export { DenoSqliteDialect } from 'https://gitlab.com/soapbox-pub/kysely-deno-sqlite/-/raw/v1.0.1/mod.ts';
|
||||||
export { default as tldts } from 'npm:tldts@^6.0.14';
|
export { default as tldts } from 'npm:tldts@^6.0.14';
|
||||||
export * as cron from 'https://deno.land/x/deno_cron@v1.0.0/cron.ts';
|
export * as cron from 'https://deno.land/x/deno_cron@v1.0.0/cron.ts';
|
||||||
|
export { S3Client } from 'https://deno.land/x/s3_lite_client@0.6.1/mod.ts';
|
||||||
|
export { default as IpfsHash } from 'npm:ipfs-only-hash@^4.0.0';
|
||||||
|
export { default as uuid62 } from 'npm:uuid62@^1.0.2';
|
||||||
|
|
||||||
export type * as TypeFest from 'npm:type-fest@^4.3.0';
|
export type * as TypeFest from 'npm:type-fest@^4.3.0';
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import { type AppContext, type AppMiddleware } from '@/app.ts';
|
import { type AppContext, type AppMiddleware } from '@/app.ts';
|
||||||
import { HTTPException } from '@/deps.ts';
|
import { HTTPException } from '@/deps.ts';
|
||||||
import { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts } from '@/utils/nip98.ts';
|
import {
|
||||||
|
buildAuthEventTemplate,
|
||||||
|
parseAuthRequest,
|
||||||
|
type ParseAuthRequestOpts,
|
||||||
|
validateAuthEvent,
|
||||||
|
} from '@/utils/nip98.ts';
|
||||||
import { localRequest } from '@/utils/web.ts';
|
import { localRequest } from '@/utils/web.ts';
|
||||||
import { signNostrConnect } from '@/sign.ts';
|
import { signEvent } from '@/sign.ts';
|
||||||
import { findUser } from '@/db/users.ts';
|
import { findUser, User } from '@/db/users.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NIP-98 auth.
|
* NIP-98 auth.
|
||||||
|
|
@ -23,26 +28,47 @@ function auth98(opts: ParseAuthRequestOpts = {}): AppMiddleware {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Require the user to prove they're an admin before invoking the controller. */
|
type UserRole = 'user' | 'admin';
|
||||||
const requireAdmin: AppMiddleware = async (c, next) => {
|
|
||||||
const header = c.req.headers.get('x-nostr-sign');
|
|
||||||
const proof = c.get('proof') || header ? await obtainProof(c) : undefined;
|
|
||||||
const user = proof ? await findUser({ pubkey: proof.pubkey }) : undefined;
|
|
||||||
|
|
||||||
if (proof && user?.admin) {
|
/** Require the user to prove their role before invoking the controller. */
|
||||||
c.set('pubkey', proof.pubkey);
|
function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware {
|
||||||
c.set('proof', proof);
|
return async (c, next) => {
|
||||||
await next();
|
const header = c.req.headers.get('x-nostr-sign');
|
||||||
} else {
|
const proof = c.get('proof') || header ? await obtainProof(c, opts) : undefined;
|
||||||
throw new HTTPException(401);
|
const user = proof ? await findUser({ pubkey: proof.pubkey }) : undefined;
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Get the proof over Nostr Connect. */
|
if (proof && user && matchesRole(user, role)) {
|
||||||
async function obtainProof(c: AppContext) {
|
c.set('pubkey', proof.pubkey);
|
||||||
const req = localRequest(c);
|
c.set('proof', proof);
|
||||||
const event = await buildAuthEventTemplate(req);
|
await next();
|
||||||
return signNostrConnect(event, c);
|
} else {
|
||||||
|
throw new HTTPException(401);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export { auth98, requireAdmin };
|
/** Check whether the user fulfills the role. */
|
||||||
|
function matchesRole(user: User, role: UserRole): boolean {
|
||||||
|
switch (role) {
|
||||||
|
case 'user':
|
||||||
|
return true;
|
||||||
|
case 'admin':
|
||||||
|
return user.admin;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the proof over Nostr Connect. */
|
||||||
|
async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) {
|
||||||
|
const req = localRequest(c);
|
||||||
|
const reqEvent = await buildAuthEventTemplate(req, opts);
|
||||||
|
const resEvent = await signEvent(reqEvent, c);
|
||||||
|
const result = await validateAuthEvent(req, resEvent, opts);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { auth98, requireRole };
|
||||||
|
|
|
||||||
16
src/note.ts
16
src/note.ts
|
|
@ -1,5 +1,6 @@
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import { linkify, linkifyStr, mime, nip19, nip21 } from '@/deps.ts';
|
import { linkify, linkifyStr, mime, nip19, nip21 } from '@/deps.ts';
|
||||||
|
import { type DittoAttachment } from '@/views/attachment.ts';
|
||||||
|
|
||||||
linkify.registerCustomProtocol('nostr', true);
|
linkify.registerCustomProtocol('nostr', true);
|
||||||
linkify.registerCustomProtocol('wss');
|
linkify.registerCustomProtocol('wss');
|
||||||
|
|
@ -52,13 +53,8 @@ function parseNoteContent(content: string): ParsedNoteContent {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MediaLink {
|
function getMediaLinks(links: Link[]): DittoAttachment[] {
|
||||||
url: string;
|
return links.reduce<DittoAttachment[]>((acc, link) => {
|
||||||
mimeType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMediaLinks(links: Link[]): MediaLink[] {
|
|
||||||
return links.reduce<MediaLink[]>((acc, link) => {
|
|
||||||
const mimeType = getUrlMimeType(link.href);
|
const mimeType = getUrlMimeType(link.href);
|
||||||
if (!mimeType) return acc;
|
if (!mimeType) return acc;
|
||||||
|
|
||||||
|
|
@ -67,7 +63,9 @@ function getMediaLinks(links: Link[]): MediaLink[] {
|
||||||
if (['audio', 'image', 'video'].includes(baseType)) {
|
if (['audio', 'image', 'video'].includes(baseType)) {
|
||||||
acc.push({
|
acc.push({
|
||||||
url: link.href,
|
url: link.href,
|
||||||
mimeType,
|
data: {
|
||||||
|
mime: mimeType,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,4 +108,4 @@ function getDecodedPubkey(decoded: nip19.DecodeResult): string | undefined {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { getMediaLinks, type MediaLink, parseNoteContent };
|
export { getMediaLinks, parseNoteContent };
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import * as eventsDB from '@/db/events.ts';
|
import * as eventsDB from '@/db/events.ts';
|
||||||
import { addRelays } from '@/db/relays.ts';
|
import { addRelays } from '@/db/relays.ts';
|
||||||
|
import { deleteAttachedMedia } from '@/db/unattached-media.ts';
|
||||||
import { findUser } from '@/db/users.ts';
|
import { findUser } from '@/db/users.ts';
|
||||||
import { type Event, LRUCache } from '@/deps.ts';
|
import { type Event, LRUCache } from '@/deps.ts';
|
||||||
import { isEphemeralKind } from '@/kinds.ts';
|
import { isEphemeralKind } from '@/kinds.ts';
|
||||||
|
|
@ -27,6 +28,7 @@ async function handleEvent(event: Event): Promise<void> {
|
||||||
processDeletions(event),
|
processDeletions(event),
|
||||||
trackRelays(event),
|
trackRelays(event),
|
||||||
trackHashtags(event),
|
trackHashtags(event),
|
||||||
|
processMedia(event, data),
|
||||||
streamOut(event, data),
|
streamOut(event, data),
|
||||||
broadcast(event, data),
|
broadcast(event, data),
|
||||||
]);
|
]);
|
||||||
|
|
@ -64,7 +66,7 @@ async function storeEvent(event: Event, data: EventData): Promise<void> {
|
||||||
if (deletion) {
|
if (deletion) {
|
||||||
return Promise.reject(new RelayError('blocked', 'event was deleted'));
|
return Promise.reject(new RelayError('blocked', 'event was deleted'));
|
||||||
} else {
|
} else {
|
||||||
await eventsDB.insertEvent(event).catch(console.warn);
|
await eventsDB.insertEvent(event, data).catch(console.warn);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return Promise.reject(new RelayError('blocked', 'only registered users can post'));
|
return Promise.reject(new RelayError('blocked', 'only registered users can post'));
|
||||||
|
|
@ -120,6 +122,14 @@ function trackRelays(event: Event) {
|
||||||
return addRelays([...relays]);
|
return addRelays([...relays]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Delete unattached media entries that are attached to the event. */
|
||||||
|
function processMedia({ tags, pubkey }: Event, { user }: EventData) {
|
||||||
|
if (user) {
|
||||||
|
const urls = getTagSet(tags, 'media');
|
||||||
|
return deleteAttachedMedia(pubkey, [...urls]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Determine if the event is being received in a timely manner. */
|
/** Determine if the event is being received in a timely manner. */
|
||||||
const isFresh = (event: Event): boolean => eventAge(event) < Time.seconds(10);
|
const isFresh = (event: Event): boolean => eventAge(event) < Time.seconds(10);
|
||||||
|
|
||||||
|
|
|
||||||
22
src/precheck.ts
Normal file
22
src/precheck.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
|
||||||
|
/** Ensure the media URL is not on the same host as the local domain. */
|
||||||
|
function checkMediaHost() {
|
||||||
|
const { url, mediaDomain } = Conf;
|
||||||
|
const mediaUrl = new URL(mediaDomain);
|
||||||
|
|
||||||
|
if (url.host === mediaUrl.host) {
|
||||||
|
throw new PrecheckError('For security reasons, MEDIA_DOMAIN cannot be on the same host as LOCAL_DOMAIN.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error class for precheck errors. */
|
||||||
|
class PrecheckError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(`${message}\nTo disable this check, set DITTO_PRECHECK="false"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Deno.env.get('DITTO_PRECHECK') !== 'false') {
|
||||||
|
checkMediaHost();
|
||||||
|
}
|
||||||
|
|
@ -48,4 +48,16 @@ const safeUrlSchema = z.string().max(2048).url();
|
||||||
/** https://github.com/colinhacks/zod/issues/1630#issuecomment-1365983831 */
|
/** https://github.com/colinhacks/zod/issues/1630#issuecomment-1365983831 */
|
||||||
const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value === 'true');
|
const booleanParamSchema = z.enum(['true', 'false']).transform((value) => value === 'true');
|
||||||
|
|
||||||
export { booleanParamSchema, decode64Schema, emojiTagSchema, filteredArray, hashtagSchema, jsonSchema, safeUrlSchema };
|
/** Schema for `File` objects. */
|
||||||
|
const fileSchema = z.custom<File>((value) => value instanceof File);
|
||||||
|
|
||||||
|
export {
|
||||||
|
booleanParamSchema,
|
||||||
|
decode64Schema,
|
||||||
|
emojiTagSchema,
|
||||||
|
fileSchema,
|
||||||
|
filteredArray,
|
||||||
|
hashtagSchema,
|
||||||
|
jsonSchema,
|
||||||
|
safeUrlSchema,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -73,9 +73,27 @@ const metaContentSchema = z.object({
|
||||||
lud16: z.string().optional().catch(undefined),
|
lud16: z.string().optional().catch(undefined),
|
||||||
}).partial().passthrough();
|
}).partial().passthrough();
|
||||||
|
|
||||||
|
/** Media data schema from `"media"` tags. */
|
||||||
|
const mediaDataSchema = z.object({
|
||||||
|
blurhash: z.string().optional().catch(undefined),
|
||||||
|
cid: z.string().optional().catch(undefined),
|
||||||
|
description: z.string().max(200).optional().catch(undefined),
|
||||||
|
height: z.number().int().positive().optional().catch(undefined),
|
||||||
|
mime: z.string().optional().catch(undefined),
|
||||||
|
name: z.string().optional().catch(undefined),
|
||||||
|
size: z.number().int().positive().optional().catch(undefined),
|
||||||
|
width: z.number().int().positive().optional().catch(undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Media data from `"media"` tags. */
|
||||||
|
type MediaData = z.infer<typeof mediaDataSchema>;
|
||||||
|
|
||||||
/** Parses kind 0 content from a JSON string. */
|
/** Parses kind 0 content from a JSON string. */
|
||||||
const jsonMetaContentSchema = jsonSchema.pipe(metaContentSchema).catch({});
|
const jsonMetaContentSchema = jsonSchema.pipe(metaContentSchema).catch({});
|
||||||
|
|
||||||
|
/** Parses media data from a JSON string. */
|
||||||
|
const jsonMediaDataSchema = jsonSchema.pipe(mediaDataSchema).catch({});
|
||||||
|
|
||||||
/** NIP-11 Relay Information Document. */
|
/** NIP-11 Relay Information Document. */
|
||||||
const relayInfoDocSchema = z.object({
|
const relayInfoDocSchema = z.object({
|
||||||
name: z.string().transform((val) => val.slice(0, 30)).optional().catch(undefined),
|
name: z.string().transform((val) => val.slice(0, 30)).optional().catch(undefined),
|
||||||
|
|
@ -102,7 +120,10 @@ export {
|
||||||
type ClientREQ,
|
type ClientREQ,
|
||||||
connectResponseSchema,
|
connectResponseSchema,
|
||||||
filterSchema,
|
filterSchema,
|
||||||
|
jsonMediaDataSchema,
|
||||||
jsonMetaContentSchema,
|
jsonMetaContentSchema,
|
||||||
|
type MediaData,
|
||||||
|
mediaDataSchema,
|
||||||
metaContentSchema,
|
metaContentSchema,
|
||||||
nostrIdSchema,
|
nostrIdSchema,
|
||||||
relayInfoDocSchema,
|
relayInfoDocSchema,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import './precheck.ts';
|
||||||
import app from './app.ts';
|
import app from './app.ts';
|
||||||
|
|
||||||
Deno.serve(app.fetch);
|
Deno.serve(app.fetch);
|
||||||
|
|
|
||||||
|
|
@ -99,4 +99,4 @@ async function signAdminEvent<K extends number = number>(event: EventTemplate<K>
|
||||||
return finishEvent(event, Conf.seckey);
|
return finishEvent(event, Conf.seckey);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { signAdminEvent, signEvent, signNostrConnect };
|
export { signAdminEvent, signEvent };
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@ import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ad
|
||||||
|
|
||||||
import { Conf } from '@/config.ts';
|
import { Conf } from '@/config.ts';
|
||||||
import * as eventsDB from '@/db/events.ts';
|
import * as eventsDB from '@/db/events.ts';
|
||||||
import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, z } from '@/deps.ts';
|
import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl } from '@/deps.ts';
|
||||||
import { getMediaLinks, type MediaLink, parseNoteContent } from '@/note.ts';
|
import { getMediaLinks, parseNoteContent } from '@/note.ts';
|
||||||
import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.ts';
|
import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.ts';
|
||||||
import { emojiTagSchema, filteredArray } from '@/schema.ts';
|
import { emojiTagSchema, filteredArray } from '@/schema.ts';
|
||||||
import { jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
import { jsonMediaDataSchema, jsonMetaContentSchema } from '@/schemas/nostr.ts';
|
||||||
import { isFollowing, type Nip05, nostrDate, parseNip05, Time } from '@/utils.ts';
|
import { isFollowing, type Nip05, nostrDate, parseNip05, Time } from '@/utils.ts';
|
||||||
import { verifyNip05Cached } from '@/utils/nip05.ts';
|
import { verifyNip05Cached } from '@/utils/nip05.ts';
|
||||||
import { findUser } from '@/db/users.ts';
|
import { findUser } from '@/db/users.ts';
|
||||||
|
import { DittoAttachment, renderAttachment } from '@/views/attachment.ts';
|
||||||
|
|
||||||
const DEFAULT_AVATAR = 'https://gleasonator.com/images/avi.png';
|
const DEFAULT_AVATAR = 'https://gleasonator.com/images/avi.png';
|
||||||
const DEFAULT_BANNER = 'https://gleasonator.com/images/banner.png';
|
const DEFAULT_BANNER = 'https://gleasonator.com/images/banner.png';
|
||||||
|
|
@ -118,7 +119,6 @@ async function toStatus(event: Event<1>, viewerPubkey?: string) {
|
||||||
];
|
];
|
||||||
|
|
||||||
const { html, links, firstUrl } = parseNoteContent(event.content);
|
const { html, links, firstUrl } = parseNoteContent(event.content);
|
||||||
const mediaLinks = getMediaLinks(links);
|
|
||||||
|
|
||||||
const [mentions, card, repliesCount, reblogsCount, favouritesCount, [repostEvent], [reactionEvent]] = await Promise
|
const [mentions, card, repliesCount, reblogsCount, favouritesCount, [repostEvent], [reactionEvent]] = await Promise
|
||||||
.all([
|
.all([
|
||||||
|
|
@ -140,6 +140,14 @@ async function toStatus(event: Event<1>, viewerPubkey?: string) {
|
||||||
const cw = event.tags.find(isCWTag);
|
const cw = event.tags.find(isCWTag);
|
||||||
const subject = event.tags.find((tag) => tag[0] === 'subject');
|
const subject = event.tags.find((tag) => tag[0] === 'subject');
|
||||||
|
|
||||||
|
const mediaLinks = getMediaLinks(links);
|
||||||
|
|
||||||
|
const mediaTags: DittoAttachment[] = event.tags
|
||||||
|
.filter((tag) => tag[0] === 'media')
|
||||||
|
.map(([_, url, json]) => ({ url, data: jsonMediaDataSchema.parse(json) }));
|
||||||
|
|
||||||
|
const media = [...mediaLinks, ...mediaTags];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
account,
|
account,
|
||||||
|
|
@ -161,7 +169,7 @@ async function toStatus(event: Event<1>, viewerPubkey?: string) {
|
||||||
bookmarked: false,
|
bookmarked: false,
|
||||||
reblog: null,
|
reblog: null,
|
||||||
application: null,
|
application: null,
|
||||||
media_attachments: mediaLinks.map(renderAttachment),
|
media_attachments: media.map(renderAttachment),
|
||||||
mentions,
|
mentions,
|
||||||
tags: [],
|
tags: [],
|
||||||
emojis: toEmojis(event),
|
emojis: toEmojis(event),
|
||||||
|
|
@ -185,24 +193,6 @@ function buildInlineRecipients(mentions: Mention[]): string {
|
||||||
return `<span class="recipients-inline">${elements.join(' ')} </span>`;
|
return `<span class="recipients-inline">${elements.join(' ')} </span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachmentTypeSchema = z.enum(['image', 'video', 'gifv', 'audio', 'unknown']).catch('unknown');
|
|
||||||
|
|
||||||
function renderAttachment({ url, mimeType }: MediaLink) {
|
|
||||||
const [baseType, _subType] = mimeType.split('/');
|
|
||||||
const type = attachmentTypeSchema.parse(baseType);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: url,
|
|
||||||
type,
|
|
||||||
url,
|
|
||||||
preview_url: url,
|
|
||||||
remote_url: null,
|
|
||||||
meta: {},
|
|
||||||
description: '',
|
|
||||||
blurhash: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PreviewCard {
|
interface PreviewCard {
|
||||||
url: string;
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
|
||||||
30
src/uploaders/config.ts
Normal file
30
src/uploaders/config.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
|
||||||
|
import { ipfsUploader } from './ipfs.ts';
|
||||||
|
import { s3Uploader } from './s3.ts';
|
||||||
|
|
||||||
|
import type { Uploader } from './types.ts';
|
||||||
|
|
||||||
|
/** Meta-uploader determined from configuration. */
|
||||||
|
const configUploader: Uploader = {
|
||||||
|
upload(file) {
|
||||||
|
return uploader().upload(file);
|
||||||
|
},
|
||||||
|
delete(cid) {
|
||||||
|
return uploader().delete(cid);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Get the uploader module based on configuration. */
|
||||||
|
function uploader() {
|
||||||
|
switch (Conf.uploader) {
|
||||||
|
case 's3':
|
||||||
|
return s3Uploader;
|
||||||
|
case 'ipfs':
|
||||||
|
return ipfsUploader;
|
||||||
|
default:
|
||||||
|
return ipfsUploader;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { configUploader };
|
||||||
50
src/uploaders/ipfs.ts
Normal file
50
src/uploaders/ipfs.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
import { z } from '@/deps.ts';
|
||||||
|
|
||||||
|
import type { Uploader } from './types.ts';
|
||||||
|
|
||||||
|
/** Response schema for POST `/api/v0/add`. */
|
||||||
|
const ipfsAddResponseSchema = z.object({
|
||||||
|
Name: z.string(),
|
||||||
|
Hash: z.string(),
|
||||||
|
Size: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPFS uploader. It expects an IPFS node up and running.
|
||||||
|
* It will try to connect to `http://localhost:5001` by default,
|
||||||
|
* and upload the file using the REST API.
|
||||||
|
*/
|
||||||
|
const ipfsUploader: Uploader = {
|
||||||
|
async upload(file) {
|
||||||
|
const url = new URL('/api/v0/add', Conf.ipfs.apiUrl);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { Hash } = ipfsAddResponseSchema.parse(await response.json());
|
||||||
|
|
||||||
|
return {
|
||||||
|
cid: Hash,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async delete(cid) {
|
||||||
|
const url = new URL('/api/v0/pin/rm', Conf.ipfs.apiUrl);
|
||||||
|
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set('arg', cid);
|
||||||
|
|
||||||
|
url.search = query.toString();
|
||||||
|
|
||||||
|
await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ipfsUploader };
|
||||||
33
src/uploaders/s3.ts
Normal file
33
src/uploaders/s3.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { Conf } from '@/config.ts';
|
||||||
|
import { IpfsHash, S3Client } from '@/deps.ts';
|
||||||
|
|
||||||
|
import type { Uploader } from './types.ts';
|
||||||
|
|
||||||
|
const s3 = new S3Client({ ...Conf.s3 });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* S3-compatible uploader for AWS, Wasabi, DigitalOcean Spaces, and more.
|
||||||
|
* Files are named by their IPFS CID and exposed at `/ipfs/<cid>`, letting it
|
||||||
|
* take advantage of IPFS features while not really using IPFS.
|
||||||
|
*/
|
||||||
|
const s3Uploader: Uploader = {
|
||||||
|
async upload(file) {
|
||||||
|
const cid = await IpfsHash.of(file.stream()) as string;
|
||||||
|
|
||||||
|
await s3.putObject(`ipfs/${cid}`, file.stream(), {
|
||||||
|
metadata: {
|
||||||
|
'Content-Type': file.type,
|
||||||
|
'x-amz-acl': 'public-read',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
cid,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async delete(cid) {
|
||||||
|
await s3.deleteObject(`ipfs/${cid}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export { s3Uploader };
|
||||||
15
src/uploaders/types.ts
Normal file
15
src/uploaders/types.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
/** Modular uploader interface, to support uploading to different backends. */
|
||||||
|
interface Uploader {
|
||||||
|
/** Upload the file to the backend. */
|
||||||
|
upload(file: File): Promise<UploadResult>;
|
||||||
|
/** Delete the file from the backend. */
|
||||||
|
delete(cid: string): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return value from the uploader after uploading a file. */
|
||||||
|
interface UploadResult {
|
||||||
|
/** IPFS CID for the file. */
|
||||||
|
cid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Uploader };
|
||||||
18
src/utils.ts
18
src/utils.ts
|
|
@ -1,6 +1,7 @@
|
||||||
import { type Event, type EventTemplate, getEventHash, nip19, z } from '@/deps.ts';
|
import { type Event, type EventTemplate, getEventHash, nip19, z } from '@/deps.ts';
|
||||||
import { getAuthor } from '@/queries.ts';
|
import { getAuthor } from '@/queries.ts';
|
||||||
import { lookupNip05Cached } from '@/utils/nip05.ts';
|
import { lookupNip05Cached } from '@/utils/nip05.ts';
|
||||||
|
import { nostrIdSchema } from '@/schemas/nostr.ts';
|
||||||
|
|
||||||
/** Get the current time in Nostr format. */
|
/** Get the current time in Nostr format. */
|
||||||
const nostrNow = (): number => Math.floor(Date.now() / 1000);
|
const nostrNow = (): number => Math.floor(Date.now() / 1000);
|
||||||
|
|
@ -111,6 +112,21 @@ function eventMatchesTemplate(event: Event, template: EventTemplate): boolean {
|
||||||
return getEventHash(event) === getEventHash({ pubkey: event.pubkey, ...template });
|
return getEventHash(event) === getEventHash({ pubkey: event.pubkey, ...template });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Test whether the value is a Nostr ID. */
|
||||||
|
function isNostrId(value: unknown): boolean {
|
||||||
|
return nostrIdSchema.safeParse(value).success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test whether the value is a URL. */
|
||||||
|
function isURL(value: unknown): boolean {
|
||||||
|
try {
|
||||||
|
new URL(value as string);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
bech32ToPubkey,
|
bech32ToPubkey,
|
||||||
dedupeEvents,
|
dedupeEvents,
|
||||||
|
|
@ -119,7 +135,9 @@ export {
|
||||||
eventMatchesTemplate,
|
eventMatchesTemplate,
|
||||||
findTag,
|
findTag,
|
||||||
isFollowing,
|
isFollowing,
|
||||||
|
isNostrId,
|
||||||
isRelay,
|
isRelay,
|
||||||
|
isURL,
|
||||||
lookupAccount,
|
lookupAccount,
|
||||||
type Nip05,
|
type Nip05,
|
||||||
nostrDate,
|
nostrDate,
|
||||||
|
|
|
||||||
27
src/utils/ipfs.ts
Normal file
27
src/utils/ipfs.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
/** https://docs.ipfs.tech/how-to/address-ipfs-on-web/#path-gateway */
|
||||||
|
const IPFS_PATH_REGEX = /^\/ipfs\/([^/]+)/;
|
||||||
|
/** https://docs.ipfs.tech/how-to/address-ipfs-on-web/#subdomain-gateway */
|
||||||
|
const IPFS_HOST_REGEX = /^([^/]+)\.ipfs\./;
|
||||||
|
|
||||||
|
/** Get IPFS CID out of a path. */
|
||||||
|
function cidFromPath(path: string) {
|
||||||
|
return path.match(IPFS_PATH_REGEX)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get IPFS CID out of a host. */
|
||||||
|
function cidFromHost(host: string) {
|
||||||
|
return host.match(IPFS_HOST_REGEX)?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get IPFS CID out of a URL. */
|
||||||
|
function cidFromUrl({ protocol, hostname, pathname }: URL) {
|
||||||
|
switch (protocol) {
|
||||||
|
case 'ipfs:':
|
||||||
|
return hostname;
|
||||||
|
case 'http:':
|
||||||
|
case 'https:':
|
||||||
|
return cidFromPath(pathname) || cidFromHost(hostname);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { cidFromUrl };
|
||||||
|
|
@ -15,13 +15,21 @@ interface ParseAuthRequestOpts {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse the auth event from a Request, returning a zod SafeParse type. */
|
/** Parse the auth event from a Request, returning a zod SafeParse type. */
|
||||||
function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) {
|
// deno-lint-ignore require-await
|
||||||
const { maxAge = Time.minutes(1), validatePayload = true } = opts;
|
async function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) {
|
||||||
|
|
||||||
const header = req.headers.get('authorization');
|
const header = req.headers.get('authorization');
|
||||||
const base64 = header?.match(/^Nostr (.+)$/)?.[1];
|
const base64 = header?.match(/^Nostr (.+)$/)?.[1];
|
||||||
|
const result = decode64EventSchema.safeParse(base64);
|
||||||
|
|
||||||
const schema = decode64EventSchema
|
if (!result.success) return result;
|
||||||
|
return validateAuthEvent(req, result.data, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare the auth event with the request, returning a zod SafeParse type. */
|
||||||
|
function validateAuthEvent(req: Request, event: Event, opts: ParseAuthRequestOpts = {}) {
|
||||||
|
const { maxAge = Time.minutes(1), validatePayload = true } = opts;
|
||||||
|
|
||||||
|
const schema = signedEventSchema
|
||||||
.refine((event): event is Event<27235> => event.kind === 27235, 'Event must be kind 27235')
|
.refine((event): event is Event<27235> => event.kind === 27235, 'Event must be kind 27235')
|
||||||
.refine((event) => eventAge(event) < maxAge, 'Event expired')
|
.refine((event) => eventAge(event) < maxAge, 'Event expired')
|
||||||
.refine((event) => tagValue(event, 'method') === req.method, 'Event method does not match HTTP request method')
|
.refine((event) => tagValue(event, 'method') === req.method, 'Event method does not match HTTP request method')
|
||||||
|
|
@ -35,22 +43,28 @@ function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) {
|
||||||
.then((hash) => hash === tagValue(event, 'payload'));
|
.then((hash) => hash === tagValue(event, 'payload'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return schema.safeParseAsync(base64);
|
return schema.safeParseAsync(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create an auth EventTemplate from a Request. */
|
/** Create an auth EventTemplate from a Request. */
|
||||||
async function buildAuthEventTemplate(req: Request): Promise<EventTemplate<27235>> {
|
async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts = {}): Promise<EventTemplate<27235>> {
|
||||||
|
const { validatePayload = true } = opts;
|
||||||
const { method, url } = req;
|
const { method, url } = req;
|
||||||
const payload = await req.clone().text().then(sha256);
|
|
||||||
|
const tags = [
|
||||||
|
['method', method],
|
||||||
|
['u', url],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (validatePayload) {
|
||||||
|
const payload = await req.clone().text().then(sha256);
|
||||||
|
tags.push(['payload', payload]);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind: 27235,
|
kind: 27235,
|
||||||
content: '',
|
content: '',
|
||||||
tags: [
|
tags,
|
||||||
['method', method],
|
|
||||||
['u', url],
|
|
||||||
['payload', payload],
|
|
||||||
],
|
|
||||||
created_at: nostrNow(),
|
created_at: nostrNow(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -60,4 +74,4 @@ function tagValue(event: Event, tagName: string): string | undefined {
|
||||||
return findTag(event.tags, tagName)?.[1];
|
return findTag(event.tags, tagName)?.[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
export { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts };
|
export { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts, validateAuthEvent };
|
||||||
|
|
|
||||||
34
src/views/attachment.ts
Normal file
34
src/views/attachment.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { UnattachedMedia } from '@/db/unattached-media.ts';
|
||||||
|
import { type TypeFest } from '@/deps.ts';
|
||||||
|
|
||||||
|
type DittoAttachment = TypeFest.SetOptional<UnattachedMedia, 'id' | 'pubkey' | 'uploaded_at'>;
|
||||||
|
|
||||||
|
function renderAttachment(media: DittoAttachment) {
|
||||||
|
const { id, data, url } = media;
|
||||||
|
return {
|
||||||
|
id: id ?? url ?? data.cid,
|
||||||
|
type: getAttachmentType(data.mime ?? ''),
|
||||||
|
url,
|
||||||
|
preview_url: url,
|
||||||
|
remote_url: null,
|
||||||
|
description: data.description ?? '',
|
||||||
|
blurhash: data.blurhash || null,
|
||||||
|
cid: data.cid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MIME to Mastodon API `Attachment` type. */
|
||||||
|
function getAttachmentType(mime: string): string {
|
||||||
|
const [type] = mime.split('/');
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'image':
|
||||||
|
case 'video':
|
||||||
|
case 'audio':
|
||||||
|
return type;
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { type DittoAttachment, renderAttachment };
|
||||||
Loading…
Add table
Reference in a new issue