From 82c03dcb56f9d9235a4d15d1ebe41a696bd92546 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 22:00:24 -0500 Subject: [PATCH 1/4] Rewrite all the uploaders --- src/app.ts | 5 ++ src/controllers/api/accounts.ts | 9 +++- src/controllers/api/media.ts | 7 ++- src/interfaces/DittoUploader.ts | 3 ++ src/middleware/uploaderMiddleware.ts | 27 ++++++++++ src/schemas/nostrbuild.ts | 19 ------- src/upload.ts | 12 +++-- src/uploaders/DenoUploader.ts | 44 ++++++++++++++++ src/uploaders/IPFSUploader.ts | 70 ++++++++++++++++++++++++++ src/uploaders/NostrBuildUploader.ts | 65 ++++++++++++++++++++++++ src/uploaders/{s3.ts => S3Uploader.ts} | 40 +++++++++------ src/uploaders/config.ts | 36 ------------- src/uploaders/ipfs.ts | 57 --------------------- src/uploaders/local.ts | 37 -------------- src/uploaders/nostrbuild.ts | 35 ------------- src/uploaders/types.ts | 9 ---- 16 files changed, 260 insertions(+), 215 deletions(-) create mode 100644 src/interfaces/DittoUploader.ts create mode 100644 src/middleware/uploaderMiddleware.ts delete mode 100644 src/schemas/nostrbuild.ts create mode 100644 src/uploaders/DenoUploader.ts create mode 100644 src/uploaders/IPFSUploader.ts create mode 100644 src/uploaders/NostrBuildUploader.ts rename src/uploaders/{s3.ts => S3Uploader.ts} (58%) delete mode 100644 src/uploaders/config.ts delete mode 100644 src/uploaders/ipfs.ts delete mode 100644 src/uploaders/local.ts delete mode 100644 src/uploaders/nostrbuild.ts delete mode 100644 src/uploaders/types.ts diff --git a/src/app.ts b/src/app.ts index 059e883..ddc9990 100644 --- a/src/app.ts +++ b/src/app.ts @@ -81,6 +81,7 @@ import { hostMetaController } from '@/controllers/well-known/host-meta.ts'; import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts'; import { nostrController } from '@/controllers/well-known/nostr.ts'; import { webfingerController } from '@/controllers/well-known/webfinger.ts'; +import { DittoUploader } from '@/interfaces/DittoUploader.ts'; import { auth98Middleware, requireProof, requireRole } from '@/middleware/auth98Middleware.ts'; import { cacheMiddleware } from '@/middleware/cacheMiddleware.ts'; import { cspMiddleware } from '@/middleware/cspMiddleware.ts'; @@ -89,11 +90,14 @@ import { signerMiddleware } from '@/middleware/signerMiddleware.ts'; import { storeMiddleware } from '@/middleware/storeMiddleware.ts'; import { blockController } from '@/controllers/api/accounts.ts'; import { unblockController } from '@/controllers/api/accounts.ts'; +import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts'; interface AppEnv extends HonoEnv { Variables: { /** Signer to get the logged-in user's pubkey, relays, and to sign events, or `undefined` if the user isn't logged in. */ signer?: NostrSigner; + /** Uploader for the user to upload files. */ + uploader?: DittoUploader; /** NIP-98 signed event proving the pubkey is owned by the user. */ proof?: NostrEvent; /** Store */ @@ -129,6 +133,7 @@ app.use( cspMiddleware(), cors({ origin: '*', exposeHeaders: ['link'] }), signerMiddleware, + uploaderMiddleware, auth98Middleware(), storeMiddleware, ); diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 5c26ba5..d67fd88 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -202,6 +202,7 @@ const updateCredentialsSchema = z.object({ const updateCredentialsController: AppController = async (c) => { const pubkey = await c.get('signer')?.getPublicKey()!; + const uploader = c.get('uploader'); const body = await parseBody(c.req.raw); const result = updateCredentialsSchema.safeParse(body); @@ -220,9 +221,13 @@ const updateCredentialsController: AppController = async (c) => { nip05, } = result.data; + if ((avatarFile || headerFile) && !uploader) { + return c.json({ error: 'No uploader configured.' }, 500); + } + const [avatar, header] = await Promise.all([ - avatarFile ? uploadFile(avatarFile, { pubkey }) : undefined, - headerFile ? uploadFile(headerFile, { pubkey }) : undefined, + (avatarFile && uploader) ? uploadFile(uploader, avatarFile, { pubkey }) : undefined, + (headerFile && uploader) ? uploadFile(uploader, headerFile, { pubkey }) : undefined, ]); meta.name = display_name ?? meta.name; diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index 33b7981..101b776 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -14,6 +14,11 @@ const mediaBodySchema = z.object({ }); const mediaController: AppController = async (c) => { + const uploader = c.get('uploader'); + if (!uploader) { + return c.json({ error: 'No uploader configured.' }, 500); + } + const pubkey = await c.get('signer')?.getPublicKey()!; const result = mediaBodySchema.safeParse(await parseBody(c.req.raw)); const { signal } = c.req.raw; @@ -24,7 +29,7 @@ const mediaController: AppController = async (c) => { try { const { file, description } = result.data; - const media = await uploadFile(file, { pubkey, description }, signal); + const media = await uploadFile(uploader, file, { pubkey, description }, signal); return c.json(renderAttachment(media)); } catch (e) { console.error(e); diff --git a/src/interfaces/DittoUploader.ts b/src/interfaces/DittoUploader.ts new file mode 100644 index 0000000..08cbf50 --- /dev/null +++ b/src/interfaces/DittoUploader.ts @@ -0,0 +1,3 @@ +export interface DittoUploader { + upload(file: File, opts?: { signal?: AbortSignal }): Promise<[['url', string], ...string[][]]>; +} diff --git a/src/middleware/uploaderMiddleware.ts b/src/middleware/uploaderMiddleware.ts new file mode 100644 index 0000000..8279a12 --- /dev/null +++ b/src/middleware/uploaderMiddleware.ts @@ -0,0 +1,27 @@ +import { AppMiddleware } from '@/app.ts'; +import { Conf } from '@/config.ts'; +import { DenoUploader } from '@/uploaders/DenoUploader.ts'; +import { IPFSUploader } from '@/uploaders/IPFSUploader.ts'; +import { NostrBuildUploader } from '@/uploaders/NostrBuildUploader.ts'; +import { S3Uploader } from '@/uploaders/S3Uploader.ts'; +import { fetchWorker } from '@/workers/fetch.ts'; + +/** Set an uploader for the user. */ +export const uploaderMiddleware: AppMiddleware = async (c, next) => { + switch (Conf.uploader) { + case 's3': + c.set('uploader', new S3Uploader(Conf.s3)); + break; + case 'ipfs': + c.set('uploader', new IPFSUploader({ baseUrl: Conf.mediaDomain, apiUrl: Conf.ipfs.apiUrl, fetch: fetchWorker })); + break; + case 'local': + c.set('uploader', new DenoUploader({ baseUrl: Conf.mediaDomain, dir: Conf.uploadsDir })); + break; + case 'nostrbuild': + c.set('uploader', new NostrBuildUploader({ endpoint: Conf.nostrbuildEndpoint, fetch: fetchWorker })); + break; + } + + await next(); +}; diff --git a/src/schemas/nostrbuild.ts b/src/schemas/nostrbuild.ts deleted file mode 100644 index db9f607..0000000 --- a/src/schemas/nostrbuild.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod'; - -export const nostrbuildFileSchema = z.object({ - name: z.string(), - url: z.string().url(), - thumbnail: z.string(), - blurhash: z.string(), - sha256: z.string(), - original_sha256: z.string(), - mime: z.string(), - dimensions: z.object({ - width: z.number(), - height: z.number(), - }).optional().catch(undefined), -}); - -export const nostrbuildSchema = z.object({ - data: nostrbuildFileSchema.array().min(1), -}); diff --git a/src/upload.ts b/src/upload.ts index cd9d2bb..0d1a085 100644 --- a/src/upload.ts +++ b/src/upload.ts @@ -1,14 +1,18 @@ import { Conf } from '@/config.ts'; import { insertUnattachedMedia, UnattachedMedia } from '@/db/unattached-media.ts'; -import { configUploader as uploader } from '@/uploaders/config.ts'; - +import { DittoUploader } from '@/interfaces/DittoUploader.ts'; interface FileMeta { pubkey: string; description?: string; } /** Upload a file, track it in the database, and return the resulting media object. */ -async function uploadFile(file: File, meta: FileMeta, signal?: AbortSignal): Promise { +export async function uploadFile( + uploader: DittoUploader, + file: File, + meta: FileMeta, + signal?: AbortSignal, +): Promise { const { pubkey, description } = meta; if (file.size > Conf.maxUploadSize) { @@ -30,5 +34,3 @@ async function uploadFile(file: File, meta: FileMeta, signal?: AbortSignal): Pro uploaded_at: Date.now(), }); } - -export { uploadFile }; diff --git a/src/uploaders/DenoUploader.ts b/src/uploaders/DenoUploader.ts new file mode 100644 index 0000000..6c2e6d4 --- /dev/null +++ b/src/uploaders/DenoUploader.ts @@ -0,0 +1,44 @@ +import { join } from 'node:path'; + +import { crypto } from '@std/crypto'; +import { encodeHex } from '@std/encoding/hex'; +import { extensionsByType } from '@std/media-types'; + +import { DittoUploader } from '@/interfaces/DittoUploader.ts'; + +export interface DenoUploaderOpts { + baseUrl: string; + dir: string; +} + +/** Local Deno filesystem uploader. */ +export class DenoUploader implements DittoUploader { + constructor(private opts: DenoUploaderOpts) {} + + async upload(file: File): Promise<[['url', string], ...string[][]]> { + const { dir, baseUrl } = this.opts; + + const sha256 = encodeHex(await crypto.subtle.digest('SHA-256', file.stream())); + const ext = extensionsByType(file.type)?.[0] ?? 'bin'; + const filename = `${sha256}.${ext}`; + + await Deno.mkdir(dir, { recursive: true }); + await Deno.writeFile(join(dir, filename), file.stream()); + + const url = new URL(baseUrl); + const path = url.pathname === '/' ? filename : join(url.pathname, filename); + + return [ + ['url', new URL(path, url).toString()], + ['m', file.type], + ['x', sha256], + ['size', file.size.toString()], + ]; + } + + async delete(filename: string) { + const { dir } = this.opts; + const path = join(dir, filename); + await Deno.remove(path); + } +} diff --git a/src/uploaders/IPFSUploader.ts b/src/uploaders/IPFSUploader.ts new file mode 100644 index 0000000..ceb4e82 --- /dev/null +++ b/src/uploaders/IPFSUploader.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; + +import { DittoUploader } from '@/interfaces/DittoUploader.ts'; + +export interface IPFSUploaderOpts { + baseUrl: string; + apiUrl?: string; + fetch?: typeof fetch; +} + +/** + * 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. + */ +export class IPFSUploader implements DittoUploader { + private baseUrl: string; + private apiUrl: string; + private fetch: typeof fetch; + + constructor(opts: IPFSUploaderOpts) { + this.baseUrl = opts.baseUrl; + this.apiUrl = opts.apiUrl ?? 'http://localhost:5001'; + this.fetch = opts.fetch ?? globalThis.fetch; + } + + async upload(file: File, opts?: { signal?: AbortSignal }): Promise<[['url', string], ...string[][]]> { + const url = new URL('/api/v0/add', this.apiUrl); + + const formData = new FormData(); + formData.append('file', file); + + const response = await this.fetch(url, { + method: 'POST', + body: formData, + signal: opts?.signal, + }); + + const { Hash: cid } = IPFSUploader.schema().parse(await response.json()); + + return [ + ['url', new URL(`/ipfs/${cid}`, this.baseUrl).toString()], + ['m', file.type], + ['cid', cid], + ['size', file.size.toString()], + ]; + } + + async delete(cid: string, opts?: { signal?: AbortSignal }): Promise { + const url = new URL('/api/v0/pin/rm', this.apiUrl); + + const query = new URLSearchParams(); + query.set('arg', cid); + url.search = query.toString(); + + await this.fetch(url, { + method: 'POST', + signal: opts?.signal, + }); + } + + /** Response schema for POST `/api/v0/add`. */ + static schema() { + return z.object({ + Name: z.string(), + Hash: z.string(), + Size: z.string(), + }); + } +} diff --git a/src/uploaders/NostrBuildUploader.ts b/src/uploaders/NostrBuildUploader.ts new file mode 100644 index 0000000..7e16448 --- /dev/null +++ b/src/uploaders/NostrBuildUploader.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; + +import { DittoUploader } from '@/interfaces/DittoUploader.ts'; + +export interface NostrBuildUploaderOpts { + endpoint?: string; + fetch?: typeof fetch; +} + +/** Upload files to nostr.build or another compatible server. */ +export class NostrBuildUploader implements DittoUploader { + constructor(private opts: NostrBuildUploaderOpts) {} + + async upload(file: File, opts?: { signal?: AbortSignal }): Promise<[['url', string], ...string[][]]> { + const { endpoint = 'https://nostr.build/api/v2/upload/files', fetch = globalThis.fetch } = this.opts; + + const formData = new FormData(); + formData.append('fileToUpload', file); + + const response = await fetch(endpoint, { + method: 'POST', + body: formData, + signal: opts?.signal, + }); + + const json = await response.json(); + const [data] = NostrBuildUploader.schema().parse(json).data; + + const tags: [['url', string], ...string[][]] = [ + ['url', data.url], + ['m', data.mime], + ['x', data.sha256], + ['ox', data.original_sha256], + ['size', data.size.toString()], + ]; + + if (data.dimensions) { + tags.push(['dim', `${data.dimensions.width}x${data.dimensions.height}`]); + } + + if (data.blurhash) { + tags.push(['blurhash', data.blurhash]); + } + + return tags; + } + + /** nostr.build API response schema. */ + private static schema() { + return z.object({ + data: z.object({ + url: z.string().url(), + blurhash: z.string().optional().catch(undefined), + sha256: z.string(), + original_sha256: z.string(), + mime: z.string(), + size: z.number(), + dimensions: z.object({ + width: z.number(), + height: z.number(), + }).optional().catch(undefined), + }).array().min(1), + }); + } +} diff --git a/src/uploaders/s3.ts b/src/uploaders/S3Uploader.ts similarity index 58% rename from src/uploaders/s3.ts rename to src/uploaders/S3Uploader.ts index aaff8c8..f210ce8 100644 --- a/src/uploaders/s3.ts +++ b/src/uploaders/S3Uploader.ts @@ -6,17 +6,34 @@ import { encodeHex } from '@std/encoding/hex'; import { extensionsByType } from '@std/media-types'; import { Conf } from '@/config.ts'; +import { DittoUploader } from '@/interfaces/DittoUploader.ts'; -import type { Uploader } from './types.ts'; +export interface S3UploaderOpts { + endPoint: string; + region: string; + accessKey?: string; + secretKey?: string; + bucket?: string; + pathStyle?: boolean; + port?: number; + sessionToken?: string; + useSSL?: boolean; +} /** S3-compatible uploader for AWS, Wasabi, DigitalOcean Spaces, and more. */ -const s3Uploader: Uploader = { - async upload(file) { +export class S3Uploader implements DittoUploader { + private client: S3Client; + + constructor(opts: S3UploaderOpts) { + this.client = new S3Client(opts); + } + + async upload(file: File): Promise<[['url', string], ...string[][]]> { const sha256 = encodeHex(await crypto.subtle.digest('SHA-256', file.stream())); const ext = extensionsByType(file.type)?.[0] ?? 'bin'; const filename = `${sha256}.${ext}`; - await client().putObject(filename, file.stream(), { + await this.client.putObject(filename, file.stream(), { metadata: { 'Content-Type': file.type, 'x-amz-acl': 'public-read', @@ -24,6 +41,7 @@ const s3Uploader: Uploader = { }); const { pathStyle, bucket } = Conf.s3; + const path = (pathStyle && bucket) ? join(bucket, filename) : filename; const url = new URL(path, Conf.mediaDomain).toString(); @@ -33,15 +51,9 @@ const s3Uploader: Uploader = { ['x', sha256], ['size', file.size.toString()], ]; - }, - async delete(id) { - await client().deleteObject(id); - }, -}; + } -/** Build S3 client from config. */ -function client() { - return new S3Client({ ...Conf.s3 }); + async delete(objectName: string) { + await this.client.deleteObject(objectName); + } } - -export { s3Uploader }; diff --git a/src/uploaders/config.ts b/src/uploaders/config.ts deleted file mode 100644 index 3f3aac7..0000000 --- a/src/uploaders/config.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { Conf } from '@/config.ts'; - -import { ipfsUploader } from '@/uploaders/ipfs.ts'; -import { localUploader } from '@/uploaders/local.ts'; -import { nostrbuildUploader } from '@/uploaders/nostrbuild.ts'; -import { s3Uploader } from '@/uploaders/s3.ts'; - -import type { Uploader } from './types.ts'; - -/** Meta-uploader determined from configuration. */ -const configUploader: Uploader = { - upload(file, opts) { - return uploader().upload(file, opts); - }, - async delete(id, opts) { - return await uploader().delete?.(id, opts); - }, -}; - -/** Get the uploader module based on configuration. */ -function uploader() { - switch (Conf.uploader) { - case 's3': - return s3Uploader; - case 'ipfs': - return ipfsUploader; - case 'local': - return localUploader; - case 'nostrbuild': - return nostrbuildUploader; - default: - throw new Error('No `DITTO_UPLOADER` configured. Uploads are disabled.'); - } -} - -export { configUploader }; diff --git a/src/uploaders/ipfs.ts b/src/uploaders/ipfs.ts deleted file mode 100644 index b83dc2e..0000000 --- a/src/uploaders/ipfs.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { z } from 'zod'; - -import { Conf } from '@/config.ts'; -import { fetchWorker } from '@/workers/fetch.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, opts) { - const url = new URL('/api/v0/add', Conf.ipfs.apiUrl); - - const formData = new FormData(); - formData.append('file', file); - - const response = await fetchWorker(url, { - method: 'POST', - body: formData, - signal: opts?.signal, - }); - - const { Hash: cid } = ipfsAddResponseSchema.parse(await response.json()); - - return [ - ['url', new URL(`/ipfs/${cid}`, Conf.mediaDomain).toString()], - ['m', file.type], - ['cid', cid], - ['size', file.size.toString()], - ]; - }, - async delete(cid, opts) { - const url = new URL('/api/v0/pin/rm', Conf.ipfs.apiUrl); - - const query = new URLSearchParams(); - query.set('arg', cid); - - url.search = query.toString(); - - await fetchWorker(url, { - method: 'POST', - signal: opts?.signal, - }); - }, -}; - -export { ipfsUploader }; diff --git a/src/uploaders/local.ts b/src/uploaders/local.ts deleted file mode 100644 index d5cd46a..0000000 --- a/src/uploaders/local.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { join } from 'node:path'; - -import { crypto } from '@std/crypto'; -import { encodeHex } from '@std/encoding/hex'; -import { extensionsByType } from '@std/media-types'; - -import { Conf } from '@/config.ts'; - -import type { Uploader } from './types.ts'; - -/** Local filesystem uploader. */ -const localUploader: Uploader = { - async upload(file) { - const sha256 = encodeHex(await crypto.subtle.digest('SHA-256', file.stream())); - const ext = extensionsByType(file.type)?.[0] ?? 'bin'; - const filename = `${sha256}.${ext}`; - - await Deno.mkdir(Conf.uploadsDir, { recursive: true }); - await Deno.writeFile(join(Conf.uploadsDir, filename), file.stream()); - - const { mediaDomain } = Conf; - const url = new URL(mediaDomain); - const path = url.pathname === '/' ? filename : join(url.pathname, filename); - - return [ - ['url', new URL(path, url).toString()], - ['m', file.type], - ['x', sha256], - ['size', file.size.toString()], - ]; - }, - async delete(id) { - await Deno.remove(join(Conf.uploadsDir, id)); - }, -}; - -export { localUploader }; diff --git a/src/uploaders/nostrbuild.ts b/src/uploaders/nostrbuild.ts deleted file mode 100644 index 8bca331..0000000 --- a/src/uploaders/nostrbuild.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Conf } from '@/config.ts'; -import { nostrbuildSchema } from '@/schemas/nostrbuild.ts'; - -import type { Uploader } from './types.ts'; - -/** nostr.build uploader. */ -export const nostrbuildUploader: Uploader = { - async upload(file) { - const formData = new FormData(); - formData.append('fileToUpload', file); - - const response = await fetch(Conf.nostrbuildEndpoint, { - method: 'POST', - body: formData, - }); - - const json = await response.json(); - const [data] = nostrbuildSchema.parse(json).data; - - const tags: [['url', string], ...string[][]] = [ - ['url', data.url], - ['m', data.mime], - ['x', data.sha256], - ['ox', data.original_sha256], - ['size', file.size.toString()], - ['blurhash', data.blurhash], - ]; - - if (data.dimensions) { - tags.push(['dim', `${data.dimensions.width}x${data.dimensions.height}`]); - } - - return tags; - }, -}; diff --git a/src/uploaders/types.ts b/src/uploaders/types.ts deleted file mode 100644 index 81b8a0a..0000000 --- a/src/uploaders/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** Modular uploader interface, to support uploading to different backends. */ -interface Uploader { - /** Upload the file to the backend. */ - upload(file: File, opts?: { signal?: AbortSignal }): Promise<[['url', string], ...string[][]]>; - /** Delete the file from the backend. */ - delete?(cid: string, opts?: { signal?: AbortSignal }): Promise; -} - -export type { Uploader }; From 6542d6a77789dd1a662696a7142497334fe9df5a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 22:04:43 -0500 Subject: [PATCH 2/4] Move uploader.ts to utils, make it kind of like api.ts --- src/controllers/api/accounts.ts | 11 +++-------- src/controllers/api/media.ts | 9 ++------- src/{ => utils}/upload.ts | 12 ++++++++++-- 3 files changed, 15 insertions(+), 17 deletions(-) rename src/{ => utils}/upload.ts (75%) diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index d67fd88..4777f56 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -8,7 +8,7 @@ import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { addTag, deleteTag, findReplyTag, getTagSet } from '@/tags.ts'; -import { uploadFile } from '@/upload.ts'; +import { uploadFile } from '@/utils/upload.ts'; import { nostrNow } from '@/utils.ts'; import { createEvent, paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; import { lookupAccount } from '@/utils/lookup.ts'; @@ -202,7 +202,6 @@ const updateCredentialsSchema = z.object({ const updateCredentialsController: AppController = async (c) => { const pubkey = await c.get('signer')?.getPublicKey()!; - const uploader = c.get('uploader'); const body = await parseBody(c.req.raw); const result = updateCredentialsSchema.safeParse(body); @@ -221,13 +220,9 @@ const updateCredentialsController: AppController = async (c) => { nip05, } = result.data; - if ((avatarFile || headerFile) && !uploader) { - return c.json({ error: 'No uploader configured.' }, 500); - } - const [avatar, header] = await Promise.all([ - (avatarFile && uploader) ? uploadFile(uploader, avatarFile, { pubkey }) : undefined, - (headerFile && uploader) ? uploadFile(uploader, headerFile, { pubkey }) : undefined, + avatarFile ? uploadFile(c, avatarFile, { pubkey }) : undefined, + headerFile ? uploadFile(c, headerFile, { pubkey }) : undefined, ]); meta.name = display_name ?? meta.name; diff --git a/src/controllers/api/media.ts b/src/controllers/api/media.ts index 101b776..71b3e78 100644 --- a/src/controllers/api/media.ts +++ b/src/controllers/api/media.ts @@ -4,7 +4,7 @@ import { AppController } from '@/app.ts'; import { fileSchema } from '@/schema.ts'; import { parseBody } from '@/utils/api.ts'; import { renderAttachment } from '@/views/mastodon/attachments.ts'; -import { uploadFile } from '@/upload.ts'; +import { uploadFile } from '@/utils/upload.ts'; const mediaBodySchema = z.object({ file: fileSchema, @@ -14,11 +14,6 @@ const mediaBodySchema = z.object({ }); const mediaController: AppController = async (c) => { - const uploader = c.get('uploader'); - if (!uploader) { - return c.json({ error: 'No uploader configured.' }, 500); - } - const pubkey = await c.get('signer')?.getPublicKey()!; const result = mediaBodySchema.safeParse(await parseBody(c.req.raw)); const { signal } = c.req.raw; @@ -29,7 +24,7 @@ const mediaController: AppController = async (c) => { try { const { file, description } = result.data; - const media = await uploadFile(uploader, file, { pubkey, description }, signal); + const media = await uploadFile(c, file, { pubkey, description }, signal); return c.json(renderAttachment(media)); } catch (e) { console.error(e); diff --git a/src/upload.ts b/src/utils/upload.ts similarity index 75% rename from src/upload.ts rename to src/utils/upload.ts index 0d1a085..c4f2fc5 100644 --- a/src/upload.ts +++ b/src/utils/upload.ts @@ -1,6 +1,7 @@ +import { AppContext } from '@/app.ts'; import { Conf } from '@/config.ts'; import { insertUnattachedMedia, UnattachedMedia } from '@/db/unattached-media.ts'; -import { DittoUploader } from '@/interfaces/DittoUploader.ts'; +import { HTTPException } from 'hono'; interface FileMeta { pubkey: string; description?: string; @@ -8,11 +9,18 @@ interface FileMeta { /** Upload a file, track it in the database, and return the resulting media object. */ export async function uploadFile( - uploader: DittoUploader, + c: AppContext, file: File, meta: FileMeta, signal?: AbortSignal, ): Promise { + const uploader = c.get('uploader'); + if (!uploader) { + throw new HTTPException(500, { + res: c.json({ error: 'No uploader configured.' }), + }); + } + const { pubkey, description } = meta; if (file.size > Conf.maxUploadSize) { From 24659d8edb0cf1daf9c20090382c50a59994c561 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 22:11:54 -0500 Subject: [PATCH 3/4] IPFSUploader: make schema private --- src/uploaders/IPFSUploader.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/uploaders/IPFSUploader.ts b/src/uploaders/IPFSUploader.ts index ceb4e82..9141e78 100644 --- a/src/uploaders/IPFSUploader.ts +++ b/src/uploaders/IPFSUploader.ts @@ -60,7 +60,7 @@ export class IPFSUploader implements DittoUploader { } /** Response schema for POST `/api/v0/add`. */ - static schema() { + private static schema() { return z.object({ Name: z.string(), Hash: z.string(), From acef173ac465c40b60919dbcdcb842ede7e0ff39 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 18 May 2024 22:15:33 -0500 Subject: [PATCH 4/4] Do things the boilerplatey way just for consistency --- src/uploaders/DenoUploader.ts | 19 +++++++++++-------- src/uploaders/NostrBuildUploader.ts | 12 ++++++++---- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/uploaders/DenoUploader.ts b/src/uploaders/DenoUploader.ts index 6c2e6d4..e2224ab 100644 --- a/src/uploaders/DenoUploader.ts +++ b/src/uploaders/DenoUploader.ts @@ -13,19 +13,23 @@ export interface DenoUploaderOpts { /** Local Deno filesystem uploader. */ export class DenoUploader implements DittoUploader { - constructor(private opts: DenoUploaderOpts) {} + baseUrl: string; + dir: string; + + constructor(opts: DenoUploaderOpts) { + this.baseUrl = opts.baseUrl; + this.dir = opts.dir; + } async upload(file: File): Promise<[['url', string], ...string[][]]> { - const { dir, baseUrl } = this.opts; - const sha256 = encodeHex(await crypto.subtle.digest('SHA-256', file.stream())); const ext = extensionsByType(file.type)?.[0] ?? 'bin'; const filename = `${sha256}.${ext}`; - await Deno.mkdir(dir, { recursive: true }); - await Deno.writeFile(join(dir, filename), file.stream()); + await Deno.mkdir(this.dir, { recursive: true }); + await Deno.writeFile(join(this.dir, filename), file.stream()); - const url = new URL(baseUrl); + const url = new URL(this.baseUrl); const path = url.pathname === '/' ? filename : join(url.pathname, filename); return [ @@ -37,8 +41,7 @@ export class DenoUploader implements DittoUploader { } async delete(filename: string) { - const { dir } = this.opts; - const path = join(dir, filename); + const path = join(this.dir, filename); await Deno.remove(path); } } diff --git a/src/uploaders/NostrBuildUploader.ts b/src/uploaders/NostrBuildUploader.ts index 7e16448..ff4a4f0 100644 --- a/src/uploaders/NostrBuildUploader.ts +++ b/src/uploaders/NostrBuildUploader.ts @@ -9,15 +9,19 @@ export interface NostrBuildUploaderOpts { /** Upload files to nostr.build or another compatible server. */ export class NostrBuildUploader implements DittoUploader { - constructor(private opts: NostrBuildUploaderOpts) {} + private endpoint: string; + private fetch: typeof fetch; + + constructor(opts: NostrBuildUploaderOpts) { + this.endpoint = opts.endpoint ?? 'https://nostr.build/api/v2/upload/files'; + this.fetch = opts.fetch ?? globalThis.fetch; + } async upload(file: File, opts?: { signal?: AbortSignal }): Promise<[['url', string], ...string[][]]> { - const { endpoint = 'https://nostr.build/api/v2/upload/files', fetch = globalThis.fetch } = this.opts; - const formData = new FormData(); formData.append('fileToUpload', file); - const response = await fetch(endpoint, { + const response = await this.fetch(this.endpoint, { method: 'POST', body: formData, signal: opts?.signal,