diff --git a/.vscode/settings.json b/.vscode/settings.json index 7ed5452..a68b194 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,7 @@ "uplo" ], "cSpell.ignoreWords": [ + "Hono", "Nextra", "blurhash", "tsup" diff --git a/examples/cloudflare-worker/README.md b/examples/cloudflare-worker/README.md index 7c1425a..6126d2a 100644 --- a/examples/cloudflare-worker/README.md +++ b/examples/cloudflare-worker/README.md @@ -1,6 +1,6 @@ # Uplo Cloudflare Workers Example -Cloudflare Workers + Neon Postgres + Drizzle Adapter +Cloudflare Workers + Hono + Neon Postgres + Drizzle Adapter ## Requirements @@ -16,4 +16,5 @@ AWS_BUCKET="" AWS_ACCESS_KEY_ID="" AWS_SECRET_ACCESS_KEY="" DATABASE_URL="" +UPLO_SECRET_TOKEN="" ``` diff --git a/examples/cloudflare-worker/package.json b/examples/cloudflare-worker/package.json index 304d110..70ad0c6 100644 --- a/examples/cloudflare-worker/package.json +++ b/examples/cloudflare-worker/package.json @@ -10,6 +10,7 @@ "@uplo/service-s3": "workspace:*", "@uplo/utils": "workspace:*", "drizzle-orm": "^0.35.3", + "hono": "^4.6.8", "pg": "^8.13.1", "wrangler": "^3.83.0" }, diff --git a/examples/cloudflare-worker/src/index.test.ts b/examples/cloudflare-worker/src/index.test.ts deleted file mode 100644 index 974364b..0000000 --- a/examples/cloudflare-worker/src/index.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { unstable_dev } from "wrangler"; -import type { UnstableDevWorker } from "wrangler"; -import { describe, expect, it, beforeAll, afterAll } from "vitest"; - -describe("Worker", () => { - let worker: UnstableDevWorker; - - beforeAll(async () => { - worker = await unstable_dev("src/index.ts", { - experimental: { disableExperimentalWarning: true }, - }); - }); - - afterAll(async () => { - await worker.stop(); - }); - - it("should return Hello World", async () => { - const resp = await worker.fetch(); - if (resp) { - const text = await resp.text(); - expect(text).toMatchInlineSnapshot(`"Hello World!"`); - } - }); -}); diff --git a/examples/cloudflare-worker/src/index.ts b/examples/cloudflare-worker/src/index.ts index 2dcb180..e48fa03 100644 --- a/examples/cloudflare-worker/src/index.ts +++ b/examples/cloudflare-worker/src/index.ts @@ -1,92 +1,42 @@ -import Uplo, { BlobInput, blobStringInput } from '@uplo/server'; -import S3Service from '@uplo/service-s3'; -import { DrizzleAdapter } from '@uplo/adapter-drizzle-pg'; -import { initDB, schema } from './db'; -import { checksumString } from '@uplo/utils'; // import GCSService from '@uplo/service-gcs' -export interface Env { - AWS_ENDPOINT: string; - AWS_BUCKET: string; - AWS_ACCESS_KEY_ID: string; - AWS_SECRET_ACCESS_KEY: string; - DATABASE_URL: string; - // Example binding to R2. Learn more at https://developers.cloudflare.com/workers/runtime-apis/r2/ - // MY_BUCKET: R2Bucket; - // - // Example binding to a Service. Learn more at https://developers.cloudflare.com/workers/runtime-apis/service-bindings/ - // MY_SERVICE: Fetcher; -} +import { Hono } from 'hono'; +import { dbMiddleware } from './middleware/dbMiddleware.js'; +import { uploMiddleware } from './middleware/uploMiddleware.js'; +import type { HonoEnv } from './types/hono.js'; +import { imageUrlToBlobInput } from './utils/imageUrlToBlobInput.js'; +import { createUploRouteHandler } from '@uplo/server/route-handler'; -const imageUrlToBlobInput = async (url: string): Promise => { - const data = await fetch(url); - const arrayBuffer = await data.arrayBuffer(); - const content = new Uint8Array(arrayBuffer); - // const base64 = btoa(String.fromCharCode()); - const contentType = data.headers.get('content-type'); +const app = new Hono(); - if (!contentType) { - throw new Error('Cannot get content type from image'); - } +app.use(dbMiddleware); +app.use(uploMiddleware); - return { - fileName: 'image.jpg', - size: content.length, - contentType, - checksum: await checksumString(content), - content: content, - }; -}; +app.get('/', (c) => c.text('Welcome to Uplo!')); -export default { - async fetch( - request: Request, - env: Env, - ctx: ExecutionContext - ): Promise { - const { db } = await initDB({ - databaseUrl: env.DATABASE_URL, - }); +app.post('/attach-avatar', async (c) => { + const blobInput = await imageUrlToBlobInput( + 'https://www.viewbug.com/media/mediafiles/2015/10/16/59560665_medium.jpg' + ); + await c.get('uplo').attachments.user(1).avatar.attachFile(blobInput); - const s3Service = S3Service({ - isPublic: false, - bucket: env.AWS_BUCKET, - accessKeyId: env.AWS_ACCESS_KEY_ID, - secretAccessKey: env.AWS_SECRET_ACCESS_KEY, - endpoint: env.AWS_ENDPOINT, - forcePathStyle: true, - requestHandler: { - requestInit(_httpRequest) { - return { cache: undefined }; - }, - }, - }); + return c.json({ success: true, message: 'Avatar attached' }); +}); - const uplo = Uplo({ - adapter: DrizzleAdapter({ - db, - schema, - }), - services: { - s3: s3Service, - }, - attachments: { - user: { - avatar: true, - }, - }, - }); +app.get('/avatar-url', async (c) => { + const uplo = c.get('uplo'); - // const service = GCSService({}) + const userAttachment = await uplo.attachments.user(1).avatar.findOne(); - // const blobInput = await imageUrlToBlobInput( - // 'https://www.viewbug.com/media/mediafiles/2015/10/16/59560665_medium.jpg' - // ); - // await uplo.attachments.user(1).avatar.attachFile(blobInput); + const avatarUrl = await userAttachment?.url(); + return c.json({ avatarUrl }); +}); - const userAttachment = await uplo.attachments.user(1).avatar.findOne(); +app.all('/uplo/*', async (c) => { + const uplo = c.get('uplo'); + const handler = createUploRouteHandler({ uplo }); - const avatarUrl = await userAttachment?.url(); - return new Response(`User avatar url: ${avatarUrl}`); - }, -}; + return handler(c.req.raw); +}); + +export default app; diff --git a/examples/cloudflare-worker/src/middleware/dbMiddleware.ts b/examples/cloudflare-worker/src/middleware/dbMiddleware.ts new file mode 100644 index 0000000..49d8f77 --- /dev/null +++ b/examples/cloudflare-worker/src/middleware/dbMiddleware.ts @@ -0,0 +1,13 @@ +import { createMiddleware } from 'hono/factory'; +import { initDB } from '../db/initDB.js'; +import { HonoEnv } from '../types/hono.js'; + +export const dbMiddleware = createMiddleware(async (c, next) => { + const { db } = await initDB({ + databaseUrl: c.env.DATABASE_URL, + }); + + c.set('db', db); + + await next(); +}); diff --git a/examples/cloudflare-worker/src/middleware/uploMiddleware.ts b/examples/cloudflare-worker/src/middleware/uploMiddleware.ts new file mode 100644 index 0000000..d58a7e9 --- /dev/null +++ b/examples/cloudflare-worker/src/middleware/uploMiddleware.ts @@ -0,0 +1,10 @@ +import { createMiddleware } from 'hono/factory'; +import { HonoEnv } from '../types/hono.js'; +import { createUplo } from '../services/uplo.js'; + +export const uploMiddleware = createMiddleware(async (c, next) => { + const uplo = createUplo(c); + + c.set('uplo', uplo); + await next(); +}); diff --git a/examples/cloudflare-worker/src/services/uplo.ts b/examples/cloudflare-worker/src/services/uplo.ts new file mode 100644 index 0000000..e91e6d5 --- /dev/null +++ b/examples/cloudflare-worker/src/services/uplo.ts @@ -0,0 +1,47 @@ +import Uplo from '@uplo/server'; +import S3Service from '@uplo/service-s3'; +import { DrizzleAdapter } from '@uplo/adapter-drizzle-pg'; +import type { HonoContext } from '../types/hono.js'; +import * as schema from '../db/schema.js'; + +export const createUplo = (c: HonoContext) => { + const db = c.get('db'); + + const s3Service = S3Service({ + isPublic: false, + bucket: c.env.AWS_BUCKET, + accessKeyId: c.env.AWS_ACCESS_KEY_ID, + secretAccessKey: c.env.AWS_SECRET_ACCESS_KEY, + endpoint: c.env.AWS_ENDPOINT, + requestHandler: { + requestInit(_httpRequest: Request) { + return { cache: undefined }; + }, + }, + }); + + const uplo = Uplo({ + config: { + privateKey: c.env.UPLO_SECRET_TOKEN, + }, + adapter: DrizzleAdapter({ + db, + schema, + }), + services: { + s3: s3Service, + }, + attachments: { + user: { + avatar: { + validate: { + // contentType: ['image/png', 'image/jpeg'], + contentType: /image\/\w/, + }, + }, + }, + }, + }); + + return uplo; +}; diff --git a/examples/cloudflare-worker/src/types/hono.ts b/examples/cloudflare-worker/src/types/hono.ts new file mode 100644 index 0000000..1546fee --- /dev/null +++ b/examples/cloudflare-worker/src/types/hono.ts @@ -0,0 +1,26 @@ +import type { Hono as H, Context } from 'hono'; +import type { initDB } from '../db/initDB.js'; +import type { createUplo } from '../services/uplo.js'; + +export type HonoBindings = { + DATABASE_URL: string; + UPLO_SECRET_TOKEN: string; + + AWS_BUCKET: string; + AWS_ACCESS_KEY_ID: string; + AWS_SECRET_ACCESS_KEY: string; + AWS_ENDPOINT: string; +}; + +export type HonoVariables = { + db: Awaited>['db']; + uplo: Awaited>; +}; + +export type HonoEnv = { + Bindings: HonoBindings; + Variables: HonoVariables; +}; + +export type Hono = H; +export type HonoContext = Context; diff --git a/examples/cloudflare-worker/src/utils/imageUrlToBlobInput.ts b/examples/cloudflare-worker/src/utils/imageUrlToBlobInput.ts new file mode 100644 index 0000000..4ffdeda --- /dev/null +++ b/examples/cloudflare-worker/src/utils/imageUrlToBlobInput.ts @@ -0,0 +1,22 @@ +import { BlobInput } from '@uplo/server'; +import { checksumString } from '@uplo/utils'; + +export const imageUrlToBlobInput = async (url: string): Promise => { + const data = await fetch(url); + const arrayBuffer = await data.arrayBuffer(); + const content = new Uint8Array(arrayBuffer); + // const base64 = btoa(String.fromCharCode()); + const contentType = data.headers.get('content-type'); + + if (!contentType) { + throw new Error('Cannot get content type from image'); + } + + return { + fileName: 'image.jpg', + size: content.length, + contentType, + checksum: await checksumString(content), + content: content, + }; +}; diff --git a/examples/cloudflare-worker/tsconfig.json b/examples/cloudflare-worker/tsconfig.json index 08e9e17..563de86 100644 --- a/examples/cloudflare-worker/tsconfig.json +++ b/examples/cloudflare-worker/tsconfig.json @@ -5,7 +5,7 @@ "strict": true, "noEmit": true, "isolatedModules": true, - "moduleResolution": "node", + "moduleResolution": "Bundler", "resolveJsonModule": true, "allowSyntheticDefaultImports": true, "noUnusedLocals": true, diff --git a/packages/server/package.json b/packages/server/package.json index 716d0ba..e7146d1 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,14 +1,30 @@ { "name": "@uplo/server", "version": "0.20.0", - "main": "./dist/index.js", - "module": "./dist/index.mjs", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { + "./route-handler": { + "import": { + "types": "./dist/route-handler/index.d.ts", + "default": "./dist/route-handler/index.js" + }, + "require": { + "types": "./dist/route-handler/index.d.cts", + "default": "./dist/route-handler/index.cjs" + } + }, ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.js" + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } } }, "sideEffects": false, @@ -19,7 +35,7 @@ "repository": "jpalumickas/uplo", "homepage": "https://uplo.js.org", "scripts": { - "build": "tsup src/index.ts --format cjs,esm --dts", + "build": "tsup", "test": "vitest run", "test:dev": "vitest dev" }, @@ -27,8 +43,10 @@ "@uplo/types": "workspace:^", "@uplo/utils": "workspace:^", "camelcase": "^6.3.0", + "hono": "^4.6.8", "jose": "^5.9.6", - "mime": "^3.0.0" + "mime": "^3.0.0", + "zod": "^3.23.8" }, "devDependencies": { "@types/mime": "^3.0.4", diff --git a/packages/server/src/GenericAttachment/createDirectUpload.ts b/packages/server/src/GenericAttachment/createDirectUpload.ts index 083be7a..d40a4e5 100644 --- a/packages/server/src/GenericAttachment/createDirectUpload.ts +++ b/packages/server/src/GenericAttachment/createDirectUpload.ts @@ -1,15 +1,17 @@ import type { Adapter, Service } from '@uplo/types'; import { generateKey } from '@uplo/utils'; -import { UploError } from '../errors'; +import { UploError } from '../errors.js'; import type { Signer } from '../Signer'; -import type { CreateDirectUploadParams } from '../types'; +import type { CreateDirectUploadParams } from '../types/generic-attachment.js'; +import { FormattedAttachmentOptions } from '../types/attachments.js'; +import { validateBlobInputData } from '../utils/validateBobInputData.js'; type Options = { params: CreateDirectUploadParams; signer: ReturnType; adapter: Adapter; service: Service; - serviceName: string; + attachmentOptions: FormattedAttachmentOptions; }; export const createDirectUpload = async ({ @@ -17,7 +19,7 @@ export const createDirectUpload = async ({ signer, adapter, service, - serviceName, + attachmentOptions, }: Options) => { const blobParams = { key: await generateKey(), @@ -28,10 +30,12 @@ export const createDirectUpload = async ({ metadata: params.metadata, }; + validateBlobInputData(blobParams, attachmentOptions.validate); + const blob = await adapter.createBlob({ params: { ...blobParams, - serviceName, + serviceName: attachmentOptions.serviceName, }, }); diff --git a/packages/server/src/GenericAttachment/index.ts b/packages/server/src/GenericAttachment/index.ts index 415784a..de59d28 100644 --- a/packages/server/src/GenericAttachment/index.ts +++ b/packages/server/src/GenericAttachment/index.ts @@ -1,21 +1,8 @@ -import { Service, Adapter } from '@uplo/types'; -import { createDirectUpload } from './createDirectUpload'; -import { CreateDirectUploadParams } from '../types'; -import { Signer } from '../Signer' - -interface Options { - multiple: boolean; - serviceName: string; - directUpload: boolean; - contentType?: string | string[] | RegExp; -} - -interface GenericAttachmentParams { - adapter: Adapter; - options: Options; - services: Record; - signer: ReturnType; -} +import { createDirectUpload } from './createDirectUpload.js'; +import type { + CreateDirectUploadParams, + GenericAttachmentParams, +} from '../types/generic-attachment.js'; export const GenericAttachment = ({ adapter, @@ -31,7 +18,7 @@ export const GenericAttachment = ({ createDirectUpload({ params, service, - serviceName: options.serviceName, + attachmentOptions: options, adapter, signer, }), diff --git a/packages/server/src/ModelAttachment/index.ts b/packages/server/src/ModelAttachment/index.ts index e31924d..033d669 100644 --- a/packages/server/src/ModelAttachment/index.ts +++ b/packages/server/src/ModelAttachment/index.ts @@ -2,16 +2,17 @@ import camelCase from 'camelcase'; import { Service, AttachmentData, BlobData, Adapter, ID } from '@uplo/types'; import { generateKey } from '@uplo/utils'; import { UploError, BlobNotFoundError } from '../errors'; -import { Callbacks } from '../types'; +import { AttachmentValidateType, Callbacks } from '../types'; import { Signer } from '../Signer'; import { Attachment } from '../Attachment'; import { BlobInput } from '../blobInputs/types'; +import { validateBlobInputData } from '../utils/validateBobInputData'; export interface ModelAttachmentOptions { multiple: boolean; serviceName: string; - contentType?: string | string[] | RegExp; directUpload?: boolean; + validate?: AttachmentValidateType; } interface ModelAttachmentParams { @@ -113,14 +114,7 @@ export class ModelAttachment { metadata: {}, }; - if ( - !blobParams.fileName || - !blobParams.contentType || - !blobParams.size || - !blobParams.checksum - ) { - throw new UploError('Missing data when attaching a file'); - } + validateBlobInputData(blobParams, this.options.validate); const blob = await this.adapter.createBlob({ params: { diff --git a/packages/server/src/errors.ts b/packages/server/src/errors.ts index dcf2a35..7b7cfaf 100644 --- a/packages/server/src/errors.ts +++ b/packages/server/src/errors.ts @@ -2,6 +2,10 @@ export class UploError extends Error {} export class NotImplementedError extends UploError {} export class SignerError extends UploError {} +// Validation Errors +export class ValidationError extends UploError {} +export class BlobValidationError extends ValidationError {} + // Not Found Errors export class NotFoundError extends UploError {} export class BlobNotFoundError extends NotFoundError {} diff --git a/packages/server/src/lib/formatAttachmentOptions.ts b/packages/server/src/lib/formatAttachmentOptions.ts index 52f85f7..e3ff3a5 100644 --- a/packages/server/src/lib/formatAttachmentOptions.ts +++ b/packages/server/src/lib/formatAttachmentOptions.ts @@ -1,6 +1,7 @@ import { Service } from '@uplo/types'; -import { UploOptionsAttachment } from '../types'; -import { UploError } from '../errors'; +import type { UploOptionsAttachment } from '../types'; +import { UploError } from '../errors.js'; +import type { FormattedAttachmentOptions } from '../types/attachments.js'; export const formatAttachmentOptions = ({ attachmentOptions, @@ -10,7 +11,7 @@ export const formatAttachmentOptions = ({ attachmentOptions: true | UploOptionsAttachment; services: Record; defaultServiceName?: string; -}) => { +}): FormattedAttachmentOptions => { if (Object.keys(services).length === 0) { throw new UploError('At least one service must be provided'); } @@ -28,7 +29,7 @@ export const formatAttachmentOptions = ({ return { multiple: options.multiple ?? false, directUpload: options.directUpload ?? true, - contentType: options.contentType, + validate: options.validate, serviceName, }; }; diff --git a/packages/server/src/route-handler/createUploRouteHandler.ts b/packages/server/src/route-handler/createUploRouteHandler.ts new file mode 100644 index 0000000..b0e0706 --- /dev/null +++ b/packages/server/src/route-handler/createUploRouteHandler.ts @@ -0,0 +1,102 @@ +import { Hono } from 'hono'; +import { validator } from 'hono/validator'; + +import type { UploInstance } from '../types'; +import { directUploadValidationSchema } from './validation-schemas/directUploadValidationSchema.js'; +import { formatZodErrors } from './utils/formatZodErrors.js'; +import { onErrorHandler } from './middleware/onErrorHandler.js'; + +type Options = { + uplo: T; + basePath?: string; +}; + +export const createUploRouteHandler = >({ + uplo, + basePath = '/uplo', +}: Options) => { + const app = new Hono().basePath(basePath); + + app.onError(onErrorHandler); + + app.post( + '/create-direct-upload', + validator('json', (value, c) => { + if (!value) { + return c.json( + { + success: false, + error: { message: 'Request body is required' }, + }, + { status: 422 } + ); + } + + return value; + }), + async (c) => { + const requestData = await c.req.valid('json'); + if (!requestData) { + return c.json( + { + success: false, + error: { message: 'Request body is required' }, + }, + { status: 422 } + ); + } + + const parsed = + await directUploadValidationSchema.safeParseAsync(requestData); + + if (!parsed.success) { + return c.json( + { + success: false, + error: { + message: 'Invalid attachment', + fields: formatZodErrors(parsed.error), + }, + }, + { status: 422 } + ); + } + + const { + attachmentName, + fileName, + contentType, + size, + checksum, + metadata, + } = parsed.data; + + const attachment = uplo.$findGenericAttachment(attachmentName); + if (!attachment) { + return c.json( + { + success: false, + error: { + message: `Attachment with name "${attachmentName}" was not found`, + }, + }, + { status: 422 } + ); + } + + const params = { + fileName, + contentType, + size, + checksum, + metadata, + }; + + const data = await attachment.createDirectUpload({ params }); + + return c.json(data, { status: 201 }); + } + ); + + return app.fetch; +}; diff --git a/packages/server/src/route-handler/index.ts b/packages/server/src/route-handler/index.ts new file mode 100644 index 0000000..a3353d5 --- /dev/null +++ b/packages/server/src/route-handler/index.ts @@ -0,0 +1 @@ +export * from './createUploRouteHandler'; diff --git a/packages/server/src/route-handler/middleware/onErrorHandler.ts b/packages/server/src/route-handler/middleware/onErrorHandler.ts new file mode 100644 index 0000000..6831e58 --- /dev/null +++ b/packages/server/src/route-handler/middleware/onErrorHandler.ts @@ -0,0 +1,33 @@ +import { ErrorHandler } from 'hono'; +import { HTTPException } from 'hono/http-exception'; +import { AttachmentNotFoundError, BlobValidationError } from '../../errors'; + +export const onErrorHandler: ErrorHandler = async (err, c) => { + if (err instanceof HTTPException) { + // Get the custom response + return err.getResponse(); + } + + if ( + err instanceof AttachmentNotFoundError || + err instanceof BlobValidationError + ) { + return c.json( + { + success: false, + error: { message: err.message }, + }, + { status: 422 } + ); + } + + // TODO: Add error handler to Uplo + console.error(err); + return c.json( + { + success: false, + error: { message: 'Internal Server Error' }, + }, + { status: 500 } + ); +}; diff --git a/packages/server/src/route-handler/utils/formatZodErrors.ts b/packages/server/src/route-handler/utils/formatZodErrors.ts new file mode 100644 index 0000000..b6b768e --- /dev/null +++ b/packages/server/src/route-handler/utils/formatZodErrors.ts @@ -0,0 +1,12 @@ +import { ZodError } from 'zod'; + +export const formatZodErrors = (error: ZodError) => { + return error.issues.reduce( + (acc, issue) => { + const path = issue.path.join('.'); + acc[path] = issue.message; + return acc; + }, + {} as Record + ); +}; diff --git a/packages/server/src/route-handler/validation-schemas/directUploadValidationSchema.ts b/packages/server/src/route-handler/validation-schemas/directUploadValidationSchema.ts new file mode 100644 index 0000000..72dc698 --- /dev/null +++ b/packages/server/src/route-handler/validation-schemas/directUploadValidationSchema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; + +export const directUploadValidationSchema = z.object({ + attachmentName: z + .string() + .regex(/^[a-z0-9-]+\.[a-z0-9-]+$/, { + message: 'Invalid attachment name. Format: "model.attachment-name"', + }) + .transform((val) => val as `${string}.${string}`), + fileName: z.string(), + contentType: z.string(), + checksum: z.string(), + size: z.number(), + metadata: z.object({}).optional(), +}); diff --git a/packages/server/src/types/attachments.ts b/packages/server/src/types/attachments.ts new file mode 100644 index 0000000..a9fab32 --- /dev/null +++ b/packages/server/src/types/attachments.ts @@ -0,0 +1,8 @@ +import { AttachmentValidateType } from './config'; + +export interface FormattedAttachmentOptions { + multiple: boolean; + serviceName: string; + directUpload: boolean; + validate?: AttachmentValidateType; +} diff --git a/packages/server/src/types/callbacks.ts b/packages/server/src/types/callbacks.ts index fcb3012..5d6160a 100644 --- a/packages/server/src/types/callbacks.ts +++ b/packages/server/src/types/callbacks.ts @@ -1,7 +1,15 @@ import type { Blob, ID } from '@uplo/types'; -export type BeforeAttachCallback = ({ blobId }: { blobId: ID }) => void; -export type AfterAttachCallback = ({ blob }: { blob: Blob }) => void; +export type BeforeAttachCallback = ({ + blobId, +}: { + blobId: ID; +}) => void | Promise; +export type AfterAttachCallback = ({ + blob, +}: { + blob: Blob; +}) => void | Promise; export type Callbacks = { beforeAttach?: BeforeAttachCallback; diff --git a/packages/server/src/types/config.ts b/packages/server/src/types/config.ts new file mode 100644 index 0000000..3c96da0 --- /dev/null +++ b/packages/server/src/types/config.ts @@ -0,0 +1,35 @@ +import type { Service, Adapter } from '@uplo/types'; +import type { Callbacks } from './callbacks'; + +export interface UploConfig { + privateKey?: string; + signedIdExpiresIn?: number; +} + +type AttachmentValidateObjectType = { + contentType?: string | string[] | RegExp | RegExp[]; +}; + +export type AttachmentValidateType = AttachmentValidateObjectType; + +export interface UploOptionsAttachment { + multiple?: boolean; + serviceName?: string; + directUpload?: boolean; + validate?: AttachmentValidateType; +} + +export type UploOptionsAttachments = Partial< + Record> +>; + +export interface UploOptions { + services: { + [serviceName: string]: Service; + }; + defaultServiceName?: string; + adapter: Adapter; + config?: UploConfig; + callbacks?: Callbacks; + attachments: AttachmentsList; +} diff --git a/packages/server/src/types/generic-attachment.ts b/packages/server/src/types/generic-attachment.ts new file mode 100644 index 0000000..7314488 --- /dev/null +++ b/packages/server/src/types/generic-attachment.ts @@ -0,0 +1,22 @@ +import type { Service, Adapter } from '@uplo/types'; +import { FormattedAttachmentOptions } from './attachments'; +import { Signer } from '../Signer'; + +export interface CreateDirectUploadParamsMetadata { + [key: string]: string | number | null; +} + +export interface CreateDirectUploadParams { + fileName: string; + contentType: string; + size: number; + checksum: string; + metadata?: CreateDirectUploadParamsMetadata; +} + +export interface GenericAttachmentParams { + adapter: Adapter; + options: FormattedAttachmentOptions; + services: Record; + signer: ReturnType; +} diff --git a/packages/server/src/types/index.ts b/packages/server/src/types/index.ts index ad3c696..e0a6c16 100644 --- a/packages/server/src/types/index.ts +++ b/packages/server/src/types/index.ts @@ -1,37 +1,11 @@ import type { ID, Blob, Service, Adapter } from '@uplo/types'; -import { Callbacks } from './callbacks'; -import { Signer } from '../Signer'; -import { ModelAttachment } from '../ModelAttachment'; -import { GenericAttachment } from '../GenericAttachment'; +import type { Signer } from '../Signer'; +import type { ModelAttachment } from '../ModelAttachment'; +import type { GenericAttachment } from '../GenericAttachment'; +import type { UploOptionsAttachments } from './config'; -export * from './callbacks'; - -export interface UploConfig { - privateKey?: string; - signedIdExpiresIn?: number; -} - -export interface UploOptionsAttachment { - multiple?: boolean; - serviceName?: string; - directUpload?: boolean; - contentType?: string | string[] | RegExp; -} - -export type UploOptionsAttachments = Partial< - Record> ->; - -export interface UploOptions { - services: { - [serviceName: string]: Service; - }; - defaultServiceName?: string; - adapter: Adapter; - config?: UploConfig; - callbacks?: Callbacks; - attachments: AttachmentsList; -} +export type * from './callbacks.js'; +export type * from './config.js'; export type UploAttachments = { [ModelName in keyof AttachmentsList]: (id: ID) => { @@ -50,15 +24,3 @@ export interface UploInstance { attachments: UploAttachments; } - -export interface CreateDirectUploadParamsMetadata { - [key: string]: string | number | null; -} - -export interface CreateDirectUploadParams { - fileName: string; - contentType: string; - size: number; - checksum: string; - metadata?: CreateDirectUploadParamsMetadata; -} diff --git a/packages/server/src/utils/validateBobInputData.ts b/packages/server/src/utils/validateBobInputData.ts new file mode 100644 index 0000000..75ef4f0 --- /dev/null +++ b/packages/server/src/utils/validateBobInputData.ts @@ -0,0 +1,57 @@ +import { BlobValidationError } from '../errors'; +import { AttachmentValidateType } from '../types'; + +type BlobInputData = { + fileName: string; + contentType: string; + size: number; + checksum: string; +}; + +const validateContentType = ( + contentType: string, + contentTypeValidator: AttachmentValidateType['contentType'] +) => { + if (!contentType) { + throw new BlobValidationError('Missing content type'); + } + + if (!contentTypeValidator) { + return true; + } + const list = Array.isArray(contentTypeValidator) + ? contentTypeValidator + : [contentTypeValidator]; + + if ( + !list.some((input) => + input instanceof RegExp ? input.test(contentType) : input === contentType + ) + ) { + throw new BlobValidationError('Invalid content type'); + } + + return true; +}; + +export const validateBlobInputData = ( + blobInputData: BlobInputData, + validator: AttachmentValidateType | null | undefined +) => { + if ( + !blobInputData?.fileName?.trim() || + !blobInputData?.contentType?.trim() || + !blobInputData?.size || + !blobInputData?.checksum?.trim() + ) { + throw new BlobValidationError('Missing data for attachment'); + } + + if (!validator) { + return true; + } + + validateContentType(blobInputData.contentType, validator.contentType); + + return true; +}; diff --git a/packages/server/tsup.config.ts b/packages/server/tsup.config.ts new file mode 100644 index 0000000..9111c29 --- /dev/null +++ b/packages/server/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: true, + dts: true, + entry: ['src/index.ts', 'src/route-handler/index.ts'], + format: ['cjs', 'esm'], + minify: true, + sourcemap: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f0c87f..edbc4aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: drizzle-orm: specifier: ^0.35.3 version: 0.35.3(@cloudflare/workers-types@4.20241022.0)(@libsql/client-wasm@0.14.0)(@neondatabase/serverless@0.10.1)(@types/pg@8.11.10)(pg@8.13.1) + hono: + specifier: ^4.6.8 + version: 4.6.8 pg: specifier: ^8.13.1 version: 8.13.1 @@ -288,12 +291,18 @@ importers: camelcase: specifier: ^6.3.0 version: 6.3.0 + hono: + specifier: ^4.6.8 + version: 4.6.8 jose: specifier: ^5.9.6 version: 5.9.6 mime: specifier: ^3.0.0 version: 3.0.0 + zod: + specifier: ^3.23.8 + version: 3.23.8 devDependencies: '@types/mime': specifier: ^3.0.4 @@ -9384,6 +9393,11 @@ packages: hermes-estree: 0.19.1 dev: true + /hono@4.6.8: + resolution: {integrity: sha512-f+2Ec9JAzabT61pglDiLJcF/DjiSefZkjCn9bzm1cYLGkD5ExJ3Jnv93ax9h0bn7UPLHF81KktoyjdQfWI2n1Q==} + engines: {node: '>=16.9.0'} + dev: false + /hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} dev: true