From 3fc60c78d2125a3d2379d9f5223ee2532959ec2b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 6 Sep 2023 17:55:46 -0500 Subject: [PATCH 01/28] Add a mediaController for s3 uploads --- src/config.ts | 42 ++++++++++++++++++++++++++++++++- src/controllers/api/accounts.ts | 4 +--- src/controllers/api/media.ts | 35 +++++++++++++++++++++++++++ src/deps.ts | 1 + src/schema.ts | 14 ++++++++++- 5 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 src/controllers/api/media.ts diff --git a/src/config.ts b/src/config.ts index dc870b16..b1af464c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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` */ await dotenv.load({ @@ -7,6 +7,16 @@ await dotenv.load({ examplePath: null, }); +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); + /** Application-wide configuration. */ const Conf = { /** Ditto admin secret key in nip19 format. This is the way it's configured by an admin. */ @@ -58,6 +68,36 @@ const Conf = { get adminEmail() { 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')); + }, + }, /** Domain of the Ditto server as a `URL` object, for easily grabbing the `hostname`, etc. */ get url() { return new URL(Conf.localDomain); diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index e7e7a709..1147c6c3 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -2,7 +2,7 @@ import { type AppController } from '@/app.ts'; import { type Filter, findReplyTag, z } from '@/deps.ts'; import * as mixer from '@/mixer.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 { toAccount, toRelationship, toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { isFollowing, lookupAccount, Time } from '@/utils.ts'; @@ -113,8 +113,6 @@ const accountStatusesController: AppController = async (c) => { return paginated(c, events, statuses); }; -const fileSchema = z.custom((value) => value instanceof File); - const updateCredentialsSchema = z.object({ display_name: z.string().optional(), note: z.string().optional(), diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts new file mode 100644 index 00000000..176bb7ab --- /dev/null +++ b/src/controllers/api/media.ts @@ -0,0 +1,35 @@ +import { AppController } from '@/app.ts'; +import { Conf } from '@/config.ts'; +import { S3Client, z } from '@/deps.ts'; +import { fileSchema } from '@/schema.ts'; +import { parseBody } from '@/utils/web.ts'; + +const s3 = new S3Client({ ...Conf.s3 }); + +const mediaBodySchema = z.object({ + file: fileSchema.refine((file) => !!file.type), + thumbnail: fileSchema.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); + } + + const { file } = result.data; + + try { + await s3.putObject('test', file.stream()); + } catch (e) { + console.error(e); + return c.json({ error: 'Failed to upload file.' }, 500); + } + + return c.json({}); +}; + +export { mediaController }; diff --git a/src/deps.ts b/src/deps.ts index f4efc62c..f1aad9a2 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -66,5 +66,6 @@ export { 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 * 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 type * as TypeFest from 'npm:type-fest@^4.3.0'; diff --git a/src/schema.ts b/src/schema.ts index d32251bf..a29191f4 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -48,4 +48,16 @@ const safeUrlSchema = z.string().max(2048).url(); /** https://github.com/colinhacks/zod/issues/1630#issuecomment-1365983831 */ 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((value) => value instanceof File); + +export { + booleanParamSchema, + decode64Schema, + emojiTagSchema, + fileSchema, + filteredArray, + hashtagSchema, + jsonSchema, + safeUrlSchema, +}; From 4622b52f78c67db4951a1a1b208d1c9338b8ea49 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 6 Sep 2023 22:10:28 -0500 Subject: [PATCH 02/28] mediaController: get cid, fix permissions, host on /ipfs --- src/controllers/api/media.ts | 15 ++++++++++++--- src/deps.ts | 1 + 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index 176bb7ab..db8299aa 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -1,6 +1,6 @@ import { AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { S3Client, z } from '@/deps.ts'; +import { IpfsHash, S3Client, z } from '@/deps.ts'; import { fileSchema } from '@/schema.ts'; import { parseBody } from '@/utils/web.ts'; @@ -21,15 +21,24 @@ const mediaController: AppController = async (c) => { } const { file } = result.data; + const cid = await IpfsHash.of(file.stream()) as string; try { - await s3.putObject('test', file.stream()); + await s3.putObject(`ipfs/${cid}`, file.stream(), { + metadata: { + 'Content-Type': file.type, + 'x-amz-acl': 'public-read', + }, + }); } catch (e) { console.error(e); return c.json({ error: 'Failed to upload file.' }, 500); } - return c.json({}); + return c.json({ + id: cid, + type: file.type, + }); }; export { mediaController }; diff --git a/src/deps.ts b/src/deps.ts index f1aad9a2..5b4547b6 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -67,5 +67,6 @@ export { DenoSqliteDialect } from 'https://gitlab.com/soapbox-pub/kysely-deno-sq 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 { 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 type * as TypeFest from 'npm:type-fest@^4.3.0'; From c4af44d582d5dca1bda9d590167896afe14247d8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 7 Sep 2023 16:59:05 -0500 Subject: [PATCH 03/28] Move s3 to separate uploader module, add ipfs uploader --- src/controllers/api/media.ts | 25 ++++++++----------------- src/uploaders/ipfs.ts | 27 +++++++++++++++++++++++++++ src/uploaders/s3.ts | 23 +++++++++++++++++++++++ src/uploaders/types.ts | 7 +++++++ 4 files changed, 65 insertions(+), 17 deletions(-) create mode 100644 src/uploaders/ipfs.ts create mode 100644 src/uploaders/s3.ts create mode 100644 src/uploaders/types.ts diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index db8299aa..3df4f989 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -1,10 +1,8 @@ import { AppController } from '@/app.ts'; -import { Conf } from '@/config.ts'; -import { IpfsHash, S3Client, z } from '@/deps.ts'; +import { z } from '@/deps.ts'; import { fileSchema } from '@/schema.ts'; import { parseBody } from '@/utils/web.ts'; - -const s3 = new S3Client({ ...Conf.s3 }); +import { s3Uploader } from '@/uploaders/s3.ts'; const mediaBodySchema = z.object({ file: fileSchema.refine((file) => !!file.type), @@ -20,25 +18,18 @@ const mediaController: AppController = async (c) => { return c.json({ error: 'Bad request.', schema: result.error }, 422); } - const { file } = result.data; - const cid = await IpfsHash.of(file.stream()) as string; - try { - await s3.putObject(`ipfs/${cid}`, file.stream(), { - metadata: { - 'Content-Type': file.type, - 'x-amz-acl': 'public-read', - }, + const { file } = result.data; + const { cid } = await s3Uploader(file); + + return c.json({ + id: cid, + type: file.type, }); } catch (e) { console.error(e); return c.json({ error: 'Failed to upload file.' }, 500); } - - return c.json({ - id: cid, - type: file.type, - }); }; export { mediaController }; diff --git a/src/uploaders/ipfs.ts b/src/uploaders/ipfs.ts new file mode 100644 index 00000000..c391df9e --- /dev/null +++ b/src/uploaders/ipfs.ts @@ -0,0 +1,27 @@ +import { z } from '@/deps.ts'; + +import type { Uploader } from './types.ts'; + +const ipfsAddResultSchema = z.object({ + Name: z.string(), + Hash: z.string(), + Size: z.string(), +}); + +const ipfsUploader: Uploader = async (file) => { + const formData = new FormData(); + formData.append('file', file); + + const response = await fetch('http://localhost:5001/api/v0/add', { + method: 'POST', + body: formData, + }); + + const { Hash } = ipfsAddResultSchema.parse(await response.json()); + + return { + cid: Hash, + }; +}; + +export { ipfsUploader }; diff --git a/src/uploaders/s3.ts b/src/uploaders/s3.ts new file mode 100644 index 00000000..b96712e2 --- /dev/null +++ b/src/uploaders/s3.ts @@ -0,0 +1,23 @@ +import { Conf } from '@/config.ts'; +import { IpfsHash, S3Client } from '@/deps.ts'; + +import type { Uploader } from './types.ts'; + +const s3 = new S3Client({ ...Conf.s3 }); + +const s3Uploader: Uploader = async (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, + }; +}; + +export { s3Uploader }; diff --git a/src/uploaders/types.ts b/src/uploaders/types.ts new file mode 100644 index 00000000..a60968fb --- /dev/null +++ b/src/uploaders/types.ts @@ -0,0 +1,7 @@ +interface UploadResult { + cid: string; +} + +type Uploader = (file: File) => Promise; + +export type { Uploader, UploadResult }; From c40f10539d02e49a6313054469753a5542bcd034 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 7 Sep 2023 19:22:28 -0500 Subject: [PATCH 04/28] ipfs: make API URL configurable, reorganize config --- src/config.ts | 51 +++++++++++++++++++++++++++---------------- src/uploaders/ipfs.ts | 5 ++++- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/src/config.ts b/src/config.ts index b1af464c..64a356bd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,16 +7,6 @@ await dotenv.load({ examplePath: null, }); -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); - /** Application-wide configuration. */ const Conf = { /** Ditto admin secret key in nip19 format. This is the way it's configured by an admin. */ @@ -98,22 +88,45 @@ const Conf = { return optionalBooleanSchema.parse(Deno.env.get('S3_USE_SSL')); }, }, + ipfs: { + /** Base URL for private IPFS API calls. */ + get apiUrl() { + return Deno.env.get('IPFS_API_URL') || 'http://localhost:5001'; + }, + }, /** Domain of the Ditto server as a `URL` object, for easily grabbing the `hostname`, etc. */ get url() { return new URL(Conf.localDomain); }, /** Merges the path with the localDomain. */ local(path: string): string { - const url = new URL(path.startsWith('/') ? path : new URL(path).pathname, Conf.localDomain); - - 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(); + return mergePaths(Conf.localDomain, path); }, }; +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 }; diff --git a/src/uploaders/ipfs.ts b/src/uploaders/ipfs.ts index c391df9e..d31a36f3 100644 --- a/src/uploaders/ipfs.ts +++ b/src/uploaders/ipfs.ts @@ -1,3 +1,4 @@ +import { Conf } from '@/config.ts'; import { z } from '@/deps.ts'; import type { Uploader } from './types.ts'; @@ -9,10 +10,12 @@ const ipfsAddResultSchema = z.object({ }); const ipfsUploader: Uploader = async (file) => { + const url = new URL('/api/v0/add', Conf.ipfs.apiUrl); + const formData = new FormData(); formData.append('file', file); - const response = await fetch('http://localhost:5001/api/v0/add', { + const response = await fetch(url, { method: 'POST', body: formData, }); From 014b9f6d29956b6896337c77f55c9c5a686adc09 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 8 Sep 2023 15:01:30 -0500 Subject: [PATCH 05/28] uploaders: allow deleting files by CID --- src/controllers/api/media.ts | 2 +- src/uploaders/ipfs.ts | 46 ++++++++++++++++++++++++++---------- src/uploaders/s3.ts | 32 ++++++++++++++++--------- src/uploaders/types.ts | 14 ++++++++--- 4 files changed, 66 insertions(+), 28 deletions(-) diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index 3df4f989..c28d1d39 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -20,7 +20,7 @@ const mediaController: AppController = async (c) => { try { const { file } = result.data; - const { cid } = await s3Uploader(file); + const { cid } = await s3Uploader.upload(file); return c.json({ id: cid, diff --git a/src/uploaders/ipfs.ts b/src/uploaders/ipfs.ts index d31a36f3..e6a33cda 100644 --- a/src/uploaders/ipfs.ts +++ b/src/uploaders/ipfs.ts @@ -3,28 +3,48 @@ import { z } from '@/deps.ts'; import type { Uploader } from './types.ts'; -const ipfsAddResultSchema = z.object({ +/** Response schema for POST `/api/v0/add`. */ +const ipfsAddResponseSchema = z.object({ Name: z.string(), Hash: z.string(), Size: z.string(), }); -const ipfsUploader: Uploader = async (file) => { - const url = new URL('/api/v0/add', Conf.ipfs.apiUrl); +/** + * 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 formData = new FormData(); + formData.append('file', file); - const response = await fetch(url, { - method: 'POST', - body: formData, - }); + const response = await fetch(url, { + method: 'POST', + body: formData, + }); - const { Hash } = ipfsAddResultSchema.parse(await response.json()); + const { Hash } = ipfsAddResponseSchema.parse(await response.json()); - return { - cid: Hash, - }; + 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 }; diff --git a/src/uploaders/s3.ts b/src/uploaders/s3.ts index b96712e2..9242bee8 100644 --- a/src/uploaders/s3.ts +++ b/src/uploaders/s3.ts @@ -5,19 +5,29 @@ import type { Uploader } from './types.ts'; const s3 = new S3Client({ ...Conf.s3 }); -const s3Uploader: Uploader = async (file) => { - const cid = await IpfsHash.of(file.stream()) as string; +/** + * S3-compatible uploader for AWS, Wasabi, DigitalOcean Spaces, and more. + * Files are named by their IPFS CID and exposed at `/ipfs/`, 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', - }, - }); + await s3.putObject(`ipfs/${cid}`, file.stream(), { + metadata: { + 'Content-Type': file.type, + 'x-amz-acl': 'public-read', + }, + }); - return { - cid, - }; + return { + cid, + }; + }, + async delete(cid) { + await s3.deleteObject(`ipfs/${cid}`); + }, }; export { s3Uploader }; diff --git a/src/uploaders/types.ts b/src/uploaders/types.ts index a60968fb..80bf4311 100644 --- a/src/uploaders/types.ts +++ b/src/uploaders/types.ts @@ -1,7 +1,15 @@ +/** Modular uploader interface, to support uploading to different backends. */ +interface Uploader { + /** Upload the file to the backend. */ + upload(file: File): Promise; + /** Delete the file from the backend. */ + delete(cid: string): Promise; +} + +/** Return value from the uploader after uploading a file. */ interface UploadResult { + /** IPFS CID for the file. */ cid: string; } -type Uploader = (file: File) => Promise; - -export type { Uploader, UploadResult }; +export type { Uploader }; From 4f57ac0352e6b36ae57a521344924256d3fa6415 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 8 Sep 2023 15:20:57 -0500 Subject: [PATCH 06/28] uploads: make uploader and media URL configurable --- src/config.ts | 30 +++++++++++++++++++++++++++++- src/controllers/api/media.ts | 13 ++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index 64a356bd..1ac87452 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,8 @@ import { dotenv, getPublicKey, nip19, secp, z } from '@/deps.ts'; +import { ipfsUploader } from '@/uploaders/ipfs.ts'; +import { s3Uploader } from '@/uploaders/s3.ts'; + +import type { Uploader } from '@/uploaders/types.ts'; /** Load environment config from `.env` */ await dotenv.load({ @@ -42,7 +46,7 @@ const Conf = { const { protocol, host } = Conf.url; 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() { return Deno.env.get('LOCAL_DOMAIN') || 'http://localhost:8000'; }, @@ -88,12 +92,36 @@ const Conf = { 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(): Uploader { + switch (Deno.env.get('DITTO_UPLOADER')) { + case 's3': + return s3Uploader; + case 'ipfs': + return ipfsUploader; + default: + return ipfsUploader; + } + }, + /** 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; + }, /** Domain of the Ditto server as a `URL` object, for easily grabbing the `hostname`, etc. */ get url() { return new URL(Conf.localDomain); diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index c28d1d39..ee309615 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -1,8 +1,8 @@ import { AppController } from '@/app.ts'; +import { Conf } from '@/config.ts'; import { z } from '@/deps.ts'; import { fileSchema } from '@/schema.ts'; import { parseBody } from '@/utils/web.ts'; -import { s3Uploader } from '@/uploaders/s3.ts'; const mediaBodySchema = z.object({ file: fileSchema.refine((file) => !!file.type), @@ -19,12 +19,19 @@ const mediaController: AppController = async (c) => { } try { - const { file } = result.data; - const { cid } = await s3Uploader.upload(file); + const { file, description } = result.data; + const { cid } = await Conf.uploader.upload(file); + + const url = new URL(`/ipfs/${cid}`, Conf.mediaDomain).toString(); return c.json({ id: cid, type: file.type, + url, + preview_url: url, + remote_url: null, + description, + blurhash: null, }); } catch (e) { console.error(e); From 34acc990005dbba5283c37001b3c62010feec99a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 8 Sep 2023 15:48:00 -0500 Subject: [PATCH 07/28] Add a precheck file to throw when config is wrong --- src/precheck.ts | 22 ++++++++++++++++++++++ src/server.ts | 1 + 2 files changed, 23 insertions(+) create mode 100644 src/precheck.ts diff --git a/src/precheck.ts b/src/precheck.ts new file mode 100644 index 00000000..40ab2fdb --- /dev/null +++ b/src/precheck.ts @@ -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(); +} diff --git a/src/server.ts b/src/server.ts index 76cbc9f8..e3cf7caa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,3 +1,4 @@ +import './precheck.ts'; import app from './app.ts'; Deno.serve(app.fetch); From 8ae89462b759409d709ad7f1da0773e2a39a0c0a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 8 Sep 2023 16:04:55 -0500 Subject: [PATCH 08/28] Add a configUploader module to select the uploader based on config --- src/config.ts | 15 ++------------- src/controllers/api/media.ts | 3 ++- src/uploaders/config.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 14 deletions(-) create mode 100644 src/uploaders/config.ts diff --git a/src/config.ts b/src/config.ts index 1ac87452..de273e52 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,4 @@ import { dotenv, getPublicKey, nip19, secp, z } from '@/deps.ts'; -import { ipfsUploader } from '@/uploaders/ipfs.ts'; -import { s3Uploader } from '@/uploaders/s3.ts'; - -import type { Uploader } from '@/uploaders/types.ts'; /** Load environment config from `.env` */ await dotenv.load({ @@ -100,15 +96,8 @@ const Conf = { }, }, /** Module to upload files with. */ - get uploader(): Uploader { - switch (Deno.env.get('DITTO_UPLOADER')) { - case 's3': - return s3Uploader; - case 'ipfs': - return ipfsUploader; - default: - return ipfsUploader; - } + get uploader() { + return Deno.env.get('DITTO_UPLOADER'); }, /** Media base URL for uploads. */ get mediaDomain() { diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index ee309615..5b5128c8 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -2,6 +2,7 @@ import { AppController } from '@/app.ts'; import { Conf } from '@/config.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'; const mediaBodySchema = z.object({ @@ -20,7 +21,7 @@ const mediaController: AppController = async (c) => { try { const { file, description } = result.data; - const { cid } = await Conf.uploader.upload(file); + const { cid } = await uploader.upload(file); const url = new URL(`/ipfs/${cid}`, Conf.mediaDomain).toString(); diff --git a/src/uploaders/config.ts b/src/uploaders/config.ts new file mode 100644 index 00000000..b0adece1 --- /dev/null +++ b/src/uploaders/config.ts @@ -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 }; From acc18adffbdab79f6cdcd8b9f5b3459698aa4cb2 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 8 Sep 2023 16:15:34 -0500 Subject: [PATCH 09/28] media: return the appropriate Attachment type --- src/controllers/api/media.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index 5b5128c8..75b8bd84 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -27,7 +27,7 @@ const mediaController: AppController = async (c) => { return c.json({ id: cid, - type: file.type, + type: getAttachmentType(file.type), url, preview_url: url, remote_url: null, @@ -40,4 +40,18 @@ const mediaController: AppController = async (c) => { } }; +/** 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 { mediaController }; From 2c943872a816adb7a9816e749eace92c4218dd57 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 8 Sep 2023 16:48:27 -0500 Subject: [PATCH 10/28] media: enforce a filesize limit on uploads --- src/config.ts | 4 ++++ src/controllers/api/media.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index de273e52..c92ad05f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -111,6 +111,10 @@ const Conf = { 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. */ get url() { return new URL(Conf.localDomain); diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index 75b8bd84..aa9db272 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -5,9 +5,13 @@ import { fileSchema } from '@/schema.ts'; import { configUploader as uploader } from '@/uploaders/config.ts'; import { parseBody } from '@/utils/web.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: fileSchema.refine((file) => !!file.type), - thumbnail: fileSchema.optional(), + file: uploadSchema, + thumbnail: uploadSchema.optional(), description: z.string().optional(), focus: z.string().optional(), }); From 969d8bfe7f557786c8be940e7cb0ae7f25303b2f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 8 Sep 2023 17:00:07 -0500 Subject: [PATCH 11/28] Add media endpoints, require nip98 proof to upload them --- src/app.ts | 8 ++++++-- src/middleware/auth98.ts | 44 +++++++++++++++++++++++++++------------- 2 files changed, 36 insertions(+), 16 deletions(-) diff --git a/src/app.ts b/src/app.ts index 2a36a83e..24fdc981 100644 --- a/src/app.ts +++ b/src/app.ts @@ -29,6 +29,7 @@ import { import { appCredentialsController, createAppController } from './controllers/api/apps.ts'; import { emptyArrayController, emptyObjectController } from './controllers/api/fallback.ts'; import { instanceController } from './controllers/api/instance.ts'; +import { mediaController } from './controllers/api/media.ts'; import { notificationsController } from './controllers/api/notifications.ts'; import { createTokenController, oauthAuthorizeController, oauthController } from './controllers/api/oauth.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 { webfingerController } from './controllers/well-known/webfinger.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 { 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', requirePubkey, createStatusController); +app.post('/api/v1/media', requireRole('user'), mediaController); +app.post('/api/v2/media', requireRole('user'), mediaController); + app.get('/api/v1/timelines/home', requirePubkey, homeTimelineController); app.get('/api/v1/timelines/public', publicTimelineController); 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/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. app.get('/api/v1/bookmarks', emptyArrayController); diff --git a/src/middleware/auth98.ts b/src/middleware/auth98.ts index dad0d0ae..a64370a1 100644 --- a/src/middleware/auth98.ts +++ b/src/middleware/auth98.ts @@ -3,7 +3,7 @@ import { HTTPException } from '@/deps.ts'; import { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts } from '@/utils/nip98.ts'; import { localRequest } from '@/utils/web.ts'; import { signNostrConnect } from '@/sign.ts'; -import { findUser } from '@/db/users.ts'; +import { findUser, User } from '@/db/users.ts'; /** * NIP-98 auth. @@ -23,20 +23,36 @@ function auth98(opts: ParseAuthRequestOpts = {}): AppMiddleware { }; } -/** Require the user to prove they're an admin before invoking the controller. */ -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; +type UserRole = 'user' | 'admin'; - if (proof && user?.admin) { - c.set('pubkey', proof.pubkey); - c.set('proof', proof); - await next(); - } else { - throw new HTTPException(401); +/** Require the user to prove their role before invoking the controller. */ +function requireRole(role: UserRole): AppMiddleware { + return 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 && matchesRole(user, role)) { + c.set('pubkey', proof.pubkey); + c.set('proof', proof); + await next(); + } else { + throw new HTTPException(401); + } + }; +} + +/** 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) { @@ -45,4 +61,4 @@ async function obtainProof(c: AppContext) { return signNostrConnect(event, c); } -export { auth98, requireAdmin }; +export { auth98, requireRole }; From 527e276340ed86ee7bc52e4817a74670be4ec18d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 8 Sep 2023 18:22:38 -0500 Subject: [PATCH 12/28] Fix nip98 signing (validate proof), skip validating payload for media requests --- src/app.ts | 4 ++-- src/middleware/auth98.ts | 22 ++++++++++++++++------ src/utils/nip98.ts | 40 +++++++++++++++++++++++++++------------- 3 files changed, 45 insertions(+), 21 deletions(-) diff --git a/src/app.ts b/src/app.ts index 24fdc981..7d9750a2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -122,8 +122,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', requirePubkey, createStatusController); -app.post('/api/v1/media', requireRole('user'), mediaController); -app.post('/api/v2/media', requireRole('user'), mediaController); +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/public', publicTimelineController); diff --git a/src/middleware/auth98.ts b/src/middleware/auth98.ts index a64370a1..128d3513 100644 --- a/src/middleware/auth98.ts +++ b/src/middleware/auth98.ts @@ -1,6 +1,11 @@ import { type AppContext, type AppMiddleware } from '@/app.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 { signNostrConnect } from '@/sign.ts'; import { findUser, User } from '@/db/users.ts'; @@ -26,10 +31,10 @@ function auth98(opts: ParseAuthRequestOpts = {}): AppMiddleware { type UserRole = 'user' | 'admin'; /** Require the user to prove their role before invoking the controller. */ -function requireRole(role: UserRole): AppMiddleware { +function requireRole(role: UserRole, opts?: ParseAuthRequestOpts): AppMiddleware { return async (c, next) => { const header = c.req.headers.get('x-nostr-sign'); - const proof = c.get('proof') || header ? await obtainProof(c) : undefined; + const proof = c.get('proof') || header ? await obtainProof(c, opts) : undefined; const user = proof ? await findUser({ pubkey: proof.pubkey }) : undefined; if (proof && user && matchesRole(user, role)) { @@ -55,10 +60,15 @@ function matchesRole(user: User, role: UserRole): boolean { } /** Get the proof over Nostr Connect. */ -async function obtainProof(c: AppContext) { +async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) { const req = localRequest(c); - const event = await buildAuthEventTemplate(req); - return signNostrConnect(event, c); + const reqEvent = await buildAuthEventTemplate(req, opts); + const resEvent = await signNostrConnect(reqEvent, c); + const result = await validateAuthEvent(req, resEvent, opts); + + if (result.success) { + return result.data; + } } export { auth98, requireRole }; diff --git a/src/utils/nip98.ts b/src/utils/nip98.ts index e1cae5d6..5606d8b9 100644 --- a/src/utils/nip98.ts +++ b/src/utils/nip98.ts @@ -15,13 +15,21 @@ interface ParseAuthRequestOpts { } /** Parse the auth event from a Request, returning a zod SafeParse type. */ -function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) { - const { maxAge = Time.minutes(1), validatePayload = true } = opts; - +// deno-lint-ignore require-await +async function parseAuthRequest(req: Request, opts: ParseAuthRequestOpts = {}) { const header = req.headers.get('authorization'); 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) => eventAge(event) < maxAge, 'Event expired') .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')); } - return schema.safeParseAsync(base64); + return schema.safeParseAsync(event); } /** Create an auth EventTemplate from a Request. */ -async function buildAuthEventTemplate(req: Request): Promise> { +async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts = {}): Promise> { + const { validatePayload = true } = opts; 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 { kind: 27235, content: '', - tags: [ - ['method', method], - ['u', url], - ['payload', payload], - ], + tags, created_at: nostrNow(), }; } @@ -60,4 +74,4 @@ function tagValue(event: Event, tagName: string): string | undefined { return findTag(event.tags, tagName)?.[1]; } -export { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts }; +export { buildAuthEventTemplate, parseAuthRequest, type ParseAuthRequestOpts, validateAuthEvent }; From dcc9d05ee58ac69843fb50d7380acdb76b053eba Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 8 Sep 2023 18:29:26 -0500 Subject: [PATCH 13/28] auth98: fix nsec flow --- src/middleware/auth98.ts | 4 ++-- src/sign.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/middleware/auth98.ts b/src/middleware/auth98.ts index 128d3513..be973202 100644 --- a/src/middleware/auth98.ts +++ b/src/middleware/auth98.ts @@ -7,7 +7,7 @@ import { validateAuthEvent, } from '@/utils/nip98.ts'; import { localRequest } from '@/utils/web.ts'; -import { signNostrConnect } from '@/sign.ts'; +import { signEvent } from '@/sign.ts'; import { findUser, User } from '@/db/users.ts'; /** @@ -63,7 +63,7 @@ function matchesRole(user: User, role: UserRole): boolean { async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) { const req = localRequest(c); const reqEvent = await buildAuthEventTemplate(req, opts); - const resEvent = await signNostrConnect(reqEvent, c); + const resEvent = await signEvent(reqEvent, c); const result = await validateAuthEvent(req, resEvent, opts); if (result.success) { diff --git a/src/sign.ts b/src/sign.ts index e260af75..a97478a9 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -99,4 +99,4 @@ async function signAdminEvent(event: EventTemplate return finishEvent(event, Conf.seckey); } -export { signAdminEvent, signEvent, signNostrConnect }; +export { signAdminEvent, signEvent }; From b5a84627c838575b200b94d372641b160e789726 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 8 Sep 2023 18:45:00 -0500 Subject: [PATCH 14/28] statuses: enable posting with media attachments --- src/controllers/api/statuses.ts | 76 ++++++++++++++------------- src/note.ts | 2 +- src/transformers/nostr-to-mastoapi.ts | 11 ++-- 3 files changed, 48 insertions(+), 41 deletions(-) diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index f7839f8f..0ae754eb 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -1,4 +1,5 @@ import { type AppController } from '@/app.ts'; +import { Conf } from '@/config.ts'; import { type Event, ISO6391, z } from '@/deps.ts'; import { getAncestors, getDescendants, getEvent } from '@/queries.ts'; import { toStatus } from '@/transformers/nostr-to-mastoapi.ts'; @@ -40,45 +41,46 @@ const createStatusController: AppController = async (c) => { const body = await parseBody(c.req.raw); const result = createStatusSchema.safeParse(body); - 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 { + if (!result.success) { 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]); + } + + for (const cid of data.media_ids ?? []) { + const url = new URL(`/ipfs/${cid}`, Conf.mediaDomain).toString(); + tags.push(['media', url]); + } + + 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) => { diff --git a/src/note.ts b/src/note.ts index 82688b64..928c4361 100644 --- a/src/note.ts +++ b/src/note.ts @@ -54,7 +54,7 @@ function parseNoteContent(content: string): ParsedNoteContent { interface MediaLink { url: string; - mimeType: string; + mimeType?: string; } function getMediaLinks(links: Link[]): MediaLink[] { diff --git a/src/transformers/nostr-to-mastoapi.ts b/src/transformers/nostr-to-mastoapi.ts index aa0b2312..5a3d3a0a 100644 --- a/src/transformers/nostr-to-mastoapi.ts +++ b/src/transformers/nostr-to-mastoapi.ts @@ -118,7 +118,6 @@ async function toStatus(event: Event<1>, viewerPubkey?: string) { ]; const { html, links, firstUrl } = parseNoteContent(event.content); - const mediaLinks = getMediaLinks(links); const [mentions, card, repliesCount, reblogsCount, favouritesCount, [repostEvent], [reactionEvent]] = await Promise .all([ @@ -140,6 +139,12 @@ async function toStatus(event: Event<1>, viewerPubkey?: string) { const cw = event.tags.find(isCWTag); const subject = event.tags.find((tag) => tag[0] === 'subject'); + const mediaLinks = getMediaLinks(links); + + const media = event.tags + .filter((tag) => tag[0] === 'media') + .map((tag) => ({ url: tag[1], mimeType: tag[2] || undefined })); + return { id: event.id, account, @@ -161,7 +166,7 @@ async function toStatus(event: Event<1>, viewerPubkey?: string) { bookmarked: false, reblog: null, application: null, - media_attachments: mediaLinks.map(renderAttachment), + media_attachments: mediaLinks.concat(media).map(renderAttachment), mentions, tags: [], emojis: toEmojis(event), @@ -187,7 +192,7 @@ function buildInlineRecipients(mentions: Mention[]): string { const attachmentTypeSchema = z.enum(['image', 'video', 'gifv', 'audio', 'unknown']).catch('unknown'); -function renderAttachment({ url, mimeType }: MediaLink) { +function renderAttachment({ url, mimeType = '' }: MediaLink) { const [baseType, _subType] = mimeType.split('/'); const type = attachmentTypeSchema.parse(baseType); From fe08aaa2a34ec2eaed7919c396112faf9be972a4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 9 Sep 2023 15:29:17 -0500 Subject: [PATCH 15/28] db/events: use stricter tagConditions to not pollute the database --- src/db/events.ts | 24 +++++++++++++++--------- src/utils.ts | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/db/events.ts b/src/db/events.ts index b86e70d9..60f00615 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -1,19 +1,25 @@ import { db, type TagRow } from '@/db.ts'; import { type Event, type Insertable, SqliteError } from '@/deps.ts'; +import { isParameterizedReplaceableKind } from '@/kinds.ts'; +import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; +import { isNostrId, isURL } from '@/utils.ts'; import type { DittoFilter, GetFiltersOpts } from '@/filter.ts'; -import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; -type TagCondition = ({ event, count }: { event: Event; count: number }) => boolean; +type TagCondition = ({ event, count, value }: { + event: Event; + count: number; + value: string; +}) => boolean; /** Conditions for when to index certain tags. */ const tagConditions: Record = { - 'd': ({ event, count }) => 30000 <= event.kind && event.kind < 40000 && count === 0, - 'e': ({ count }) => count < 15, - 'p': ({ event, count }) => event.kind === 3 || count < 15, - 'proxy': ({ count }) => count === 0, - 'q': ({ event, count }) => event.kind === 1 && count === 0, - 't': ({ count }) => count < 5, + 'd': ({ event, count, value }) => isParameterizedReplaceableKind(event.kind) && count === 0 && value.length < 200, + 'e': ({ count, value }) => isNostrId(value) && count < 15, + 'p': ({ event, count, value }) => isNostrId(value) && (event.kind === 3 || count < 15), + 'proxy': ({ count, value }) => isURL(value) && count === 0 && value.length < 200, + 'q': ({ event, count, value }) => isNostrId(value) && event.kind === 1 && count === 0, + 't': ({ count, value }) => count < 5 && value.length < 50, }; /** Insert an event (and its tags) into the database. */ @@ -37,7 +43,7 @@ function insertEvent(event: Event): Promise { const tags = event.tags.reduce[]>((results, [name, value]) => { tagCounts[name] = (tagCounts[name] || 0) + 1; - if (value && tagConditions[name]?.({ event, count: tagCounts[name] - 1 })) { + if (value && tagConditions[name]?.({ event, count: tagCounts[name] - 1, value })) { results.push({ event_id: event.id, tag: name, diff --git a/src/utils.ts b/src/utils.ts index 18362989..11ef0ea5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,7 @@ import { type Event, type EventTemplate, getEventHash, nip19, z } from '@/deps.ts'; import { getAuthor } from '@/queries.ts'; import { lookupNip05Cached } from '@/utils/nip05.ts'; +import { nostrIdSchema } from '@/schemas/nostr.ts'; /** Get the current time in Nostr format. */ 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 }); } +/** 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 { bech32ToPubkey, dedupeEvents, @@ -119,7 +135,9 @@ export { eventMatchesTemplate, findTag, isFollowing, + isNostrId, isRelay, + isURL, lookupAccount, type Nip05, nostrDate, From 6e139985ac1c2b3953af6e4848c7f73616d97022 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 9 Sep 2023 15:31:15 -0500 Subject: [PATCH 16/28] db/events: add a baseline 200 character limit to tag values --- src/db/events.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/db/events.ts b/src/db/events.ts index 60f00615..ffbfef55 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -14,10 +14,10 @@ type TagCondition = ({ event, count, value }: { /** Conditions for when to index certain tags. */ const tagConditions: Record = { - 'd': ({ event, count, value }) => isParameterizedReplaceableKind(event.kind) && count === 0 && value.length < 200, + 'd': ({ event, count }) => isParameterizedReplaceableKind(event.kind) && count === 0, 'e': ({ count, value }) => isNostrId(value) && count < 15, 'p': ({ event, count, value }) => isNostrId(value) && (event.kind === 3 || count < 15), - 'proxy': ({ count, value }) => isURL(value) && count === 0 && value.length < 200, + 'proxy': ({ count, value }) => isURL(value) && count === 0, 'q': ({ event, count, value }) => isNostrId(value) && event.kind === 1 && count === 0, 't': ({ count, value }) => count < 5 && value.length < 50, }; @@ -43,7 +43,7 @@ function insertEvent(event: Event): Promise { const tags = event.tags.reduce[]>((results, [name, value]) => { tagCounts[name] = (tagCounts[name] || 0) + 1; - if (value && tagConditions[name]?.({ event, count: tagCounts[name] - 1, value })) { + if (value && value.length < 200 && tagConditions[name]?.({ event, count: tagCounts[name] - 1, value })) { results.push({ event_id: event.id, tag: name, From d24318fd0d23fa2d8d5fc14efbc567e991a7f867 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 9 Sep 2023 15:39:49 -0500 Subject: [PATCH 17/28] db/events: index "media" tags, rearrange conditionals to optimize processing --- src/db/events.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/db/events.ts b/src/db/events.ts index ffbfef55..9686bf90 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -14,11 +14,12 @@ type TagCondition = ({ event, count, value }: { /** Conditions for when to index certain tags. */ const tagConditions: Record = { - 'd': ({ event, count }) => isParameterizedReplaceableKind(event.kind) && count === 0, - 'e': ({ count, value }) => isNostrId(value) && count < 15, - 'p': ({ event, count, value }) => isNostrId(value) && (event.kind === 3 || count < 15), - 'proxy': ({ count, value }) => isURL(value) && count === 0, - 'q': ({ event, count, value }) => isNostrId(value) && event.kind === 1 && count === 0, + 'd': ({ event, count }) => count === 0 && isParameterizedReplaceableKind(event.kind), + 'e': ({ count, value }) => count < 15 && isNostrId(value), + 'media': ({ count, value }) => count < 4 && isURL(value), + 'p': ({ event, count, value }) => (count < 15 || event.kind === 3) && isNostrId(value), + 'proxy': ({ count, value }) => count === 0 && isURL(value), + 'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value), 't': ({ count, value }) => count < 5 && value.length < 50, }; From 96ff31719e991353a8c25cb46b4e906e40618ade Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 9 Sep 2023 15:48:03 -0500 Subject: [PATCH 18/28] db/events: pass EventData, index all media from local users --- src/db/events.test.ts | 8 ++++---- src/db/events.ts | 15 ++++++++++++--- src/pipeline.ts | 2 +- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/db/events.test.ts b/src/db/events.test.ts index a9ab016d..297813ae 100644 --- a/src/db/events.test.ts +++ b/src/db/events.test.ts @@ -6,12 +6,12 @@ import { insertUser } from '@/db/users.ts'; Deno.test('count filters', async () => { assertEquals(await countFilters([{ kinds: [1] }]), 0); - await insertEvent(event55920b75); + await insertEvent(event55920b75, { user: undefined }); assertEquals(await countFilters([{ kinds: [1] }]), 1); }); 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: [3] }]), []); @@ -24,14 +24,14 @@ Deno.test('insert and filter events', async () => { }); Deno.test('delete events', async () => { - await insertEvent(event55920b75); + await insertEvent(event55920b75, { user: undefined }); assertEquals(await getFilters([{ kinds: [1] }]), [event55920b75]); await deleteFilters([{ kinds: [1] }]); assertEquals(await getFilters([{ kinds: [1] }]), []); }); Deno.test('query events with local filter', async () => { - await insertEvent(event55920b75); + await insertEvent(event55920b75, { user: undefined }); assertEquals(await getFilters([{}]), [event55920b75]); assertEquals(await getFilters([{ local: true }]), []); diff --git a/src/db/events.ts b/src/db/events.ts index 9686bf90..0516092e 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -2,12 +2,14 @@ import { db, type TagRow } from '@/db.ts'; import { type Event, type Insertable, 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'; type TagCondition = ({ event, count, value }: { event: Event; + data: EventData; count: number; value: string; }) => boolean; @@ -16,7 +18,7 @@ type TagCondition = ({ event, count, value }: { const tagConditions: Record = { 'd': ({ event, count }) => count === 0 && isParameterizedReplaceableKind(event.kind), 'e': ({ count, value }) => count < 15 && isNostrId(value), - 'media': ({ count, value }) => count < 4 && isURL(value), + 'media': ({ count, value, data }) => (data.user || count < 4) && isURL(value), 'p': ({ event, count, value }) => (count < 15 || event.kind === 3) && isNostrId(value), 'proxy': ({ count, value }) => count === 0 && isURL(value), 'q': ({ event, count, value }) => count === 0 && event.kind === 1 && isNostrId(value), @@ -24,7 +26,7 @@ const tagConditions: Record = { }; /** Insert an event (and its tags) into the database. */ -function insertEvent(event: Event): Promise { +function insertEvent(event: Event, data: EventData): Promise { return db.transaction().execute(async (trx) => { await trx.insertInto('events') .values({ @@ -44,7 +46,14 @@ function insertEvent(event: Event): Promise { const tags = event.tags.reduce[]>((results, [name, value]) => { tagCounts[name] = (tagCounts[name] || 0) + 1; - if (value && value.length < 200 && tagConditions[name]?.({ event, count: tagCounts[name] - 1, value })) { + const shouldIndex = tagConditions[name]?.({ + event, + data, + count: tagCounts[name] - 1, + value, + }); + + if (value && value.length < 200 && shouldIndex) { results.push({ event_id: event.id, tag: name, diff --git a/src/pipeline.ts b/src/pipeline.ts index 38e32141..b5568178 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -64,7 +64,7 @@ async function storeEvent(event: Event, data: EventData): Promise { if (deletion) { return Promise.reject(new RelayError('blocked', 'event was deleted')); } else { - await eventsDB.insertEvent(event).catch(console.warn); + await eventsDB.insertEvent(event, data).catch(console.warn); } } else { return Promise.reject(new RelayError('blocked', 'only registered users can post')); From f8b2efb484b9dc04649b33b1f6bb1eb89ae87692 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 9 Sep 2023 16:08:30 -0500 Subject: [PATCH 19/28] db/events: refactor insertEvent into smaller functions --- src/db/events.ts | 82 +++++++++++++++++++++++++++++------------------- 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/src/db/events.ts b/src/db/events.ts index 0516092e..74553d57 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -1,5 +1,5 @@ -import { db, type TagRow } from '@/db.ts'; -import { type Event, type Insertable, SqliteError } from '@/deps.ts'; +import { db } from '@/db.ts'; +import { type Event, SqliteError } from '@/deps.ts'; import { isParameterizedReplaceableKind } from '@/kinds.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { EventData } from '@/types.ts'; @@ -7,6 +7,7 @@ import { isNostrId, isURL } from '@/utils.ts'; import type { DittoFilter, GetFiltersOpts } from '@/filter.ts'; +/** Function to decide whether or not to index a tag. */ type TagCondition = ({ event, count, value }: { event: Event; data: EventData; @@ -28,47 +29,39 @@ const tagConditions: Record = { /** Insert an event (and its tags) into the database. */ function insertEvent(event: Event, data: EventData): Promise { return db.transaction().execute(async (trx) => { - await trx.insertInto('events') - .values({ - ...event, - tags: JSON.stringify(event.tags), - }) - .execute(); + /** Insert the event into the database. */ + async function addEvent() { + await trx.insertInto('events') + .values({ ...event, tags: JSON.stringify(event.tags) }) + .execute(); + } - const searchContent = buildSearchContent(event); - if (searchContent) { + /** Add search data to the FTS table. */ + async function indexSearch() { + const searchContent = buildSearchContent(event); + if (!searchContent) return; await trx.insertInto('events_fts') .values({ id: event.id, content: searchContent.substring(0, 1000) }) .execute(); } - const tagCounts: Record = {}; - const tags = event.tags.reduce[]>((results, [name, value]) => { - tagCounts[name] = (tagCounts[name] || 0) + 1; + /** Index event tags depending on the conditions defined above. */ + async function indexTags() { + const tags = filterIndexableTags(event, data); + const rows = tags.map(([tag, value]) => ({ event_id: event.id, tag, value })); - const shouldIndex = tagConditions[name]?.({ - event, - data, - count: tagCounts[name] - 1, - value, - }); - - if (value && value.length < 200 && shouldIndex) { - results.push({ - event_id: event.id, - tag: name, - value, - }); - } - - return results; - }, []); - - if (tags.length) { + if (!tags.length) return; await trx.insertInto('tags') - .values(tags) + .values(rows) .execute(); } + + // Run the queries. + await Promise.all([ + addEvent(), + indexTags(), + indexSearch(), + ]); }).catch((error) => { // Don't throw for duplicate events. if (error instanceof SqliteError && error.code === 19) { @@ -197,6 +190,29 @@ async function countFilters(filters: DittoFilter[]): Promis return Number(count); } +/** Return only the tags that should be indexed. */ +function filterIndexableTags(event: Event, data: EventData): string[][] { + const tagCounts: Record = {}; + + return event.tags.reduce((results, tag) => { + const [name, value] = tag; + tagCounts[name] = (tagCounts[name] || 0) + 1; + + const shouldIndex = tagConditions[name]?.({ + event, + data, + count: tagCounts[name] - 1, + value, + }); + + if (value && value.length < 200 && shouldIndex) { + results.push(tag); + } + + return results; + }, []); +} + /** Build a search index from the event. */ function buildSearchContent(event: Event): string { switch (event.kind) { From c6b20e68f6dc94738240ae349c16c14ebd5f9f50 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 9 Sep 2023 16:24:53 -0500 Subject: [PATCH 20/28] db/events: refactor filterIndexableTags function --- src/db/events.ts | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/db/events.ts b/src/db/events.ts index 74553d57..6dade288 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -194,21 +194,32 @@ async function countFilters(filters: DittoFilter[]): Promis function filterIndexableTags(event: Event, data: EventData): string[][] { const tagCounts: Record = {}; - return event.tags.reduce((results, tag) => { - const [name, value] = tag; - tagCounts[name] = (tagCounts[name] || 0) + 1; + function getCount(name: string) { + return tagCounts[name] || 0; + } - const shouldIndex = tagConditions[name]?.({ + function incrementCount(name: string) { + tagCounts[name] = getCount(name) + 1; + } + + function checkCondition(name: string, value: string, condition: TagCondition) { + return condition({ event, data, - count: tagCounts[name] - 1, + count: getCount(name), value, }); + } - if (value && value.length < 200 && shouldIndex) { + return event.tags.reduce((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; }, []); } From c88b174d020028097b5b6889b13824e79cb9673b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 9 Sep 2023 17:41:30 -0500 Subject: [PATCH 21/28] Add unattached_media table, insert one when uploading a file --- src/controllers/api/media.ts | 12 ++++++++ src/db.ts | 9 ++++++ src/db/migrations/007_unattached_media.ts | 34 +++++++++++++++++++++++ src/db/unattached-media.ts | 30 ++++++++++++++++++++ src/deps.ts | 1 + 5 files changed, 86 insertions(+) create mode 100644 src/db/migrations/007_unattached_media.ts create mode 100644 src/db/unattached-media.ts diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index aa9db272..0d5115c3 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -1,5 +1,6 @@ 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'; @@ -27,6 +28,17 @@ const mediaController: AppController = async (c) => { const { file, description } = result.data; const { cid } = await uploader.upload(file); + await insertUnattachedMedia({ + pukey: c.get('pubkey')!, + cid, + data: { + name: file.name, + mime: file.type, + size: file.size, + description, + }, + }); + const url = new URL(`/ipfs/${cid}`, Conf.mediaDomain).toString(); return c.json({ diff --git a/src/db.ts b/src/db.ts index e371ac94..eee165a5 100644 --- a/src/db.ts +++ b/src/db.ts @@ -10,6 +10,7 @@ interface DittoDB { tags: TagRow; users: UserRow; relays: RelayRow; + unattached_media: UnattachedMediaRow; } interface EventRow { @@ -46,6 +47,14 @@ interface RelayRow { active: boolean; } +interface UnattachedMediaRow { + id: string; + pukey: string; + cid: string; + data: string; + uploaded_at: Date; +} + const db = new Kysely({ dialect: new DenoSqliteDialect({ database: new Sqlite(Conf.dbPath), diff --git a/src/db/migrations/007_unattached_media.ts b/src/db/migrations/007_unattached_media.ts new file mode 100644 index 00000000..ed37c5c3 --- /dev/null +++ b/src/db/migrations/007_unattached_media.ts @@ -0,0 +1,34 @@ +import { Kysely, sql } from '@/deps.ts'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('unattached_media') + .addColumn('id', 'text', (c) => c.primaryKey()) + .addColumn('pukey', 'text', (c) => c.notNull()) + .addColumn('cid', '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_pukey') + .on('unattached_media') + .column('pukey') + .execute(); + + await db.schema + .createIndex('unattached_media_cid') + .on('unattached_media') + .column('cid') + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('unattached_media').execute(); +} diff --git a/src/db/unattached-media.ts b/src/db/unattached-media.ts new file mode 100644 index 00000000..0018157d --- /dev/null +++ b/src/db/unattached-media.ts @@ -0,0 +1,30 @@ +import { db } from '@/db.ts'; +import { uuid62 } from '@/deps.ts'; + +interface UnattachedMedia { + id: string; + pukey: string; + cid: string; + data: { + name?: string; + mime?: string; + width?: number; + height?: number; + size?: number; + description?: string; + }; + uploaded_at: Date; +} + +function insertUnattachedMedia(media: Omit) { + return db.insertInto('unattached_media') + .values({ + id: uuid62.v4(), + uploaded_at: new Date(), + ...media, + data: JSON.stringify(media.data), + }) + .execute(); +} + +export { insertUnattachedMedia }; diff --git a/src/deps.ts b/src/deps.ts index 5b4547b6..727bba16 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -68,5 +68,6 @@ 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 { 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'; From e8053ba07225226091e9019a0da8c8c5c679c6d5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 9 Sep 2023 18:12:54 -0500 Subject: [PATCH 22/28] unattached-media: cid --> url --- src/controllers/api/media.ts | 6 +++--- src/db.ts | 2 +- src/db/migrations/007_unattached_media.ts | 6 +++--- src/db/unattached-media.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index 0d5115c3..955cd754 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -28,9 +28,11 @@ const mediaController: AppController = async (c) => { const { file, description } = result.data; const { cid } = await uploader.upload(file); + const url = new URL(`/ipfs/${cid}`, Conf.mediaDomain).toString(); + await insertUnattachedMedia({ pukey: c.get('pubkey')!, - cid, + url, data: { name: file.name, mime: file.type, @@ -39,8 +41,6 @@ const mediaController: AppController = async (c) => { }, }); - const url = new URL(`/ipfs/${cid}`, Conf.mediaDomain).toString(); - return c.json({ id: cid, type: getAttachmentType(file.type), diff --git a/src/db.ts b/src/db.ts index eee165a5..cbf4bb1b 100644 --- a/src/db.ts +++ b/src/db.ts @@ -50,7 +50,7 @@ interface RelayRow { interface UnattachedMediaRow { id: string; pukey: string; - cid: string; + url: string; data: string; uploaded_at: Date; } diff --git a/src/db/migrations/007_unattached_media.ts b/src/db/migrations/007_unattached_media.ts index ed37c5c3..971c5dbf 100644 --- a/src/db/migrations/007_unattached_media.ts +++ b/src/db/migrations/007_unattached_media.ts @@ -5,7 +5,7 @@ export async function up(db: Kysely): Promise { .createTable('unattached_media') .addColumn('id', 'text', (c) => c.primaryKey()) .addColumn('pukey', 'text', (c) => c.notNull()) - .addColumn('cid', '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(); @@ -23,9 +23,9 @@ export async function up(db: Kysely): Promise { .execute(); await db.schema - .createIndex('unattached_media_cid') + .createIndex('unattached_media_url') .on('unattached_media') - .column('cid') + .column('url') .execute(); } diff --git a/src/db/unattached-media.ts b/src/db/unattached-media.ts index 0018157d..b13474c5 100644 --- a/src/db/unattached-media.ts +++ b/src/db/unattached-media.ts @@ -4,7 +4,7 @@ import { uuid62 } from '@/deps.ts'; interface UnattachedMedia { id: string; pukey: string; - cid: string; + url: string; data: { name?: string; mime?: string; From 46b9deffce707093149e404a28c8cbad759ced13 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 9 Sep 2023 19:13:42 -0500 Subject: [PATCH 23/28] utils: add ipfs module --- src/utils/ipfs.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/utils/ipfs.ts diff --git a/src/utils/ipfs.ts b/src/utils/ipfs.ts new file mode 100644 index 00000000..071f2cc7 --- /dev/null +++ b/src/utils/ipfs.ts @@ -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 }; \ No newline at end of file From 0b867afd8ed284487211383249dd198fa8882e51 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 9 Sep 2023 19:27:39 -0500 Subject: [PATCH 24/28] unattached-media: delete orphaned attachments after 15 minutes --- src/cron.ts | 26 ++++++++++++++++++++++++++ src/db/unattached-media.ts | 23 ++++++++++++++++++++++- src/utils/ipfs.ts | 2 +- 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/cron.ts b/src/cron.ts index a08fdf64..b29ab19b 100644 --- a/src/cron.ts +++ b/src/cron.ts @@ -1,6 +1,9 @@ import * as eventsDB from '@/db/events.ts'; +import { deleteUnattachedMediaByUrl, getUnattachedMedia } from '@/db/unattached-media.ts'; import { cron } from '@/deps.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. */ async function cleanupEvents() { @@ -14,6 +17,29 @@ async function cleanupEvents() { 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 cleanupMedia(); cron.every15Minute(cleanupEvents); +cron.every15Minute(cleanupMedia); diff --git a/src/db/unattached-media.ts b/src/db/unattached-media.ts index b13474c5..409b365c 100644 --- a/src/db/unattached-media.ts +++ b/src/db/unattached-media.ts @@ -27,4 +27,25 @@ function insertUnattachedMedia(media: Omit { }); return c.json({ - id: cid, + id: media.id, type: getAttachmentType(file.type), url, preview_url: url, diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 0ae754eb..4756df05 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -1,10 +1,10 @@ import { type AppController } from '@/app.ts'; -import { Conf } from '@/config.ts'; import { type Event, ISO6391, z } from '@/deps.ts'; import { getAncestors, getDescendants, getEvent } from '@/queries.ts'; import { toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { createEvent, paginationSchema, parseBody } from '@/utils/web.ts'; import { renderEventAccounts } from '@/views.ts'; +import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; const createStatusSchema = z.object({ in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(), @@ -69,9 +69,12 @@ const createStatusController: AppController = async (c) => { tags.push(['subject', data.spoiler_text]); } - for (const cid of data.media_ids ?? []) { - const url = new URL(`/ipfs/${cid}`, Conf.mediaDomain).toString(); - tags.push(['media', url]); + 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({ diff --git a/src/db.ts b/src/db.ts index cbf4bb1b..abbe9825 100644 --- a/src/db.ts +++ b/src/db.ts @@ -49,7 +49,7 @@ interface RelayRow { interface UnattachedMediaRow { id: string; - pukey: string; + pubkey: string; url: string; data: string; uploaded_at: Date; diff --git a/src/db/migrations/007_unattached_media.ts b/src/db/migrations/007_unattached_media.ts index 971c5dbf..a2b36a21 100644 --- a/src/db/migrations/007_unattached_media.ts +++ b/src/db/migrations/007_unattached_media.ts @@ -4,7 +4,7 @@ export async function up(db: Kysely): Promise { await db.schema .createTable('unattached_media') .addColumn('id', 'text', (c) => c.primaryKey()) - .addColumn('pukey', 'text', (c) => c.notNull()) + .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`)) @@ -17,9 +17,9 @@ export async function up(db: Kysely): Promise { .execute(); await db.schema - .createIndex('unattached_media_pukey') + .createIndex('unattached_media_pubkey') .on('unattached_media') - .column('pukey') + .column('pubkey') .execute(); await db.schema diff --git a/src/db/unattached-media.ts b/src/db/unattached-media.ts index 409b365c..f58931b5 100644 --- a/src/db/unattached-media.ts +++ b/src/db/unattached-media.ts @@ -3,7 +3,7 @@ import { uuid62 } from '@/deps.ts'; interface UnattachedMedia { id: string; - pukey: string; + pubkey: string; url: string; data: { name?: string; @@ -16,15 +16,18 @@ interface UnattachedMedia { uploaded_at: Date; } -function insertUnattachedMedia(media: Omit) { - return db.insertInto('unattached_media') - .values({ - id: uuid62.v4(), - uploaded_at: new Date(), - ...media, - data: JSON.stringify(media.data), - }) +async function insertUnattachedMedia(media: Omit) { + 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; } /** Find attachments that exist but aren't attached to any events. */ @@ -32,7 +35,7 @@ function getUnattachedMedia(until: Date) { return db.selectFrom('unattached_media') .select([ 'unattached_media.id', - 'unattached_media.pukey', + 'unattached_media.pubkey', 'unattached_media.url', 'unattached_media.data', 'unattached_media.uploaded_at', @@ -48,4 +51,17 @@ function deleteUnattachedMediaByUrl(url: string) { .execute(); } -export { deleteUnattachedMediaByUrl, getUnattachedMedia, insertUnattachedMedia }; +function getUnattachedMediaByIds(ids: string[]) { + return db.selectFrom('unattached_media') + .select([ + 'unattached_media.id', + 'unattached_media.pubkey', + 'unattached_media.url', + 'unattached_media.data', + 'unattached_media.uploaded_at', + ]) + .where('id', 'in', ids) + .execute(); +} + +export { deleteUnattachedMediaByUrl, getUnattachedMedia, getUnattachedMediaByIds, insertUnattachedMedia }; diff --git a/src/transformers/nostr-to-mastoapi.ts b/src/transformers/nostr-to-mastoapi.ts index 5a3d3a0a..4dd7da53 100644 --- a/src/transformers/nostr-to-mastoapi.ts +++ b/src/transformers/nostr-to-mastoapi.ts @@ -143,7 +143,7 @@ async function toStatus(event: Event<1>, viewerPubkey?: string) { const media = event.tags .filter((tag) => tag[0] === 'media') - .map((tag) => ({ url: tag[1], mimeType: tag[2] || undefined })); + .map(([_, url]) => ({ url })); return { id: event.id, From 43499f2dfd7e757f060ffeca412f9fc55af37420 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 9 Sep 2023 21:33:12 -0500 Subject: [PATCH 26/28] media: add attachment view, unify types --- src/controllers/api/media.ts | 25 ++------------------ src/db/unattached-media.ts | 18 +++++++------- src/note.ts | 16 ++++++------- src/schemas/nostr.ts | 21 +++++++++++++++++ src/transformers/nostr-to-mastoapi.ts | 33 +++++++------------------- src/views/attachment.ts | 34 +++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 65 deletions(-) create mode 100644 src/views/attachment.ts diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index 99884289..1e310e1a 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -5,6 +5,7 @@ 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.') @@ -41,33 +42,11 @@ const mediaController: AppController = async (c) => { }, }); - return c.json({ - id: media.id, - type: getAttachmentType(file.type), - url, - preview_url: url, - remote_url: null, - description, - blurhash: null, - }); + return c.json(renderAttachment(media)); } catch (e) { console.error(e); return c.json({ error: 'Failed to upload file.' }, 500); } }; -/** 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 { mediaController }; diff --git a/src/db/unattached-media.ts b/src/db/unattached-media.ts index f58931b5..f3050a2c 100644 --- a/src/db/unattached-media.ts +++ b/src/db/unattached-media.ts @@ -1,18 +1,12 @@ 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: { - name?: string; - mime?: string; - width?: number; - height?: number; - size?: number; - description?: string; - }; + data: MediaData; uploaded_at: Date; } @@ -64,4 +58,10 @@ function getUnattachedMediaByIds(ids: string[]) { .execute(); } -export { deleteUnattachedMediaByUrl, getUnattachedMedia, getUnattachedMediaByIds, insertUnattachedMedia }; +export { + deleteUnattachedMediaByUrl, + getUnattachedMedia, + getUnattachedMediaByIds, + insertUnattachedMedia, + type UnattachedMedia, +}; diff --git a/src/note.ts b/src/note.ts index 928c4361..92baf521 100644 --- a/src/note.ts +++ b/src/note.ts @@ -1,5 +1,6 @@ import { Conf } from '@/config.ts'; import { linkify, linkifyStr, mime, nip19, nip21 } from '@/deps.ts'; +import { type DittoAttachment } from '@/views/attachment.ts'; linkify.registerCustomProtocol('nostr', true); linkify.registerCustomProtocol('wss'); @@ -52,13 +53,8 @@ function parseNoteContent(content: string): ParsedNoteContent { }; } -interface MediaLink { - url: string; - mimeType?: string; -} - -function getMediaLinks(links: Link[]): MediaLink[] { - return links.reduce((acc, link) => { +function getMediaLinks(links: Link[]): DittoAttachment[] { + return links.reduce((acc, link) => { const mimeType = getUrlMimeType(link.href); if (!mimeType) return acc; @@ -67,7 +63,9 @@ function getMediaLinks(links: Link[]): MediaLink[] { if (['audio', 'image', 'video'].includes(baseType)) { acc.push({ 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 }; diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 12948046..c097935c 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -73,9 +73,27 @@ const metaContentSchema = z.object({ lud16: z.string().optional().catch(undefined), }).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; + /** Parses kind 0 content from a JSON string. */ const jsonMetaContentSchema = jsonSchema.pipe(metaContentSchema).catch({}); +/** Parses media data from a JSON string. */ +const jsonMediaDataSchema = jsonSchema.pipe(mediaDataSchema).catch({}); + /** NIP-11 Relay Information Document. */ const relayInfoDocSchema = z.object({ name: z.string().transform((val) => val.slice(0, 30)).optional().catch(undefined), @@ -102,7 +120,10 @@ export { type ClientREQ, connectResponseSchema, filterSchema, + jsonMediaDataSchema, jsonMetaContentSchema, + type MediaData, + mediaDataSchema, metaContentSchema, nostrIdSchema, relayInfoDocSchema, diff --git a/src/transformers/nostr-to-mastoapi.ts b/src/transformers/nostr-to-mastoapi.ts index 4dd7da53..5fc02a89 100644 --- a/src/transformers/nostr-to-mastoapi.ts +++ b/src/transformers/nostr-to-mastoapi.ts @@ -2,14 +2,15 @@ import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ad import { Conf } from '@/config.ts'; import * as eventsDB from '@/db/events.ts'; -import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, z } from '@/deps.ts'; -import { getMediaLinks, type MediaLink, parseNoteContent } from '@/note.ts'; +import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl } from '@/deps.ts'; +import { getMediaLinks, parseNoteContent } from '@/note.ts'; import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.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 { verifyNip05Cached } from '@/utils/nip05.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_BANNER = 'https://gleasonator.com/images/banner.png'; @@ -141,9 +142,11 @@ async function toStatus(event: Event<1>, viewerPubkey?: string) { const mediaLinks = getMediaLinks(links); - const media = event.tags + const mediaTags: DittoAttachment[] = event.tags .filter((tag) => tag[0] === 'media') - .map(([_, url]) => ({ url })); + .map(([_, url, json]) => ({ url, data: jsonMediaDataSchema.parse(json) })); + + const media = [...mediaLinks, ...mediaTags]; return { id: event.id, @@ -166,7 +169,7 @@ async function toStatus(event: Event<1>, viewerPubkey?: string) { bookmarked: false, reblog: null, application: null, - media_attachments: mediaLinks.concat(media).map(renderAttachment), + media_attachments: media.map(renderAttachment), mentions, tags: [], emojis: toEmojis(event), @@ -190,24 +193,6 @@ function buildInlineRecipients(mentions: Mention[]): string { return `${elements.join(' ')} `; } -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 { url: string; title: string; diff --git a/src/views/attachment.ts b/src/views/attachment.ts new file mode 100644 index 00000000..38ddb376 --- /dev/null +++ b/src/views/attachment.ts @@ -0,0 +1,34 @@ +import { UnattachedMedia } from '@/db/unattached-media.ts'; +import { type TypeFest } from '@/deps.ts'; + +type DittoAttachment = TypeFest.SetOptional; + +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 }; From 0d343fa1904fc6a6493306278d0a7441c2433416 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 9 Sep 2023 22:03:02 -0500 Subject: [PATCH 27/28] db/unattached-media: refactor queries, DRY --- src/db/unattached-media.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/db/unattached-media.ts b/src/db/unattached-media.ts index f3050a2c..6e77e0db 100644 --- a/src/db/unattached-media.ts +++ b/src/db/unattached-media.ts @@ -10,6 +10,7 @@ interface UnattachedMedia { uploaded_at: Date; } +/** Add unattached media into the database. */ async function insertUnattachedMedia(media: Omit) { const result = { id: uuid62.v4(), @@ -24,8 +25,8 @@ async function insertUnattachedMedia(media: Omit Date: Sat, 9 Sep 2023 22:27:16 -0500 Subject: [PATCH 28/28] pipeline: delete unattached-media rows when authoring an event --- src/db/unattached-media.ts | 9 +++++++++ src/pipeline.ts | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/db/unattached-media.ts b/src/db/unattached-media.ts index 6e77e0db..ae9d8826 100644 --- a/src/db/unattached-media.ts +++ b/src/db/unattached-media.ts @@ -59,7 +59,16 @@ function getUnattachedMediaByIds(ids: string[]) { .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, diff --git a/src/pipeline.ts b/src/pipeline.ts index b5568178..5a80b00f 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -1,6 +1,7 @@ import { Conf } from '@/config.ts'; import * as eventsDB from '@/db/events.ts'; import { addRelays } from '@/db/relays.ts'; +import { deleteAttachedMedia } from '@/db/unattached-media.ts'; import { findUser } from '@/db/users.ts'; import { type Event, LRUCache } from '@/deps.ts'; import { isEphemeralKind } from '@/kinds.ts'; @@ -27,6 +28,7 @@ async function handleEvent(event: Event): Promise { processDeletions(event), trackRelays(event), trackHashtags(event), + processMedia(event, data), streamOut(event, data), broadcast(event, data), ]); @@ -120,6 +122,14 @@ function trackRelays(event: Event) { 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. */ const isFresh = (event: Event): boolean => eventAge(event) < Time.seconds(10);