From 3fc60c78d2125a3d2379d9f5223ee2532959ec2b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 6 Sep 2023 17:55:46 -0500 Subject: [PATCH] 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 dc870b1..b1af464 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 e7e7a70..1147c6c 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 0000000..176bb7a --- /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 f4efc62..f1aad9a 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 d32251b..a29191f 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, +};