diff --git a/prisma/migrations/20230927083830_item/migration.sql b/prisma/migrations/20230927083830_item/migration.sql new file mode 100644 index 0000000..69ca66e --- /dev/null +++ b/prisma/migrations/20230927083830_item/migration.sql @@ -0,0 +1,35 @@ +-- CreateTable +CREATE TABLE "Item" ( + "id" SERIAL NOT NULL, + "name" VARCHAR(50) NOT NULL, + "mimeType" VARCHAR(255) NOT NULL, + "blobUrl" VARCHAR(1024) NOT NULL, + "ownerId" INTEGER NOT NULL, + "parentId" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "deletedAt" TIMESTAMP(3), + + CONSTRAINT "Item_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Item_blobUrl_idx" ON "Item"("blobUrl"); + +-- CreateIndex +CREATE INDEX "Item_ownerId_idx" ON "Item"("ownerId"); + +-- CreateIndex +CREATE INDEX "Item_parentId_idx" ON "Item"("parentId"); + +-- CreateIndex +CREATE INDEX "Item_ownerId_parentId_idx" ON "Item"("ownerId", "parentId"); + +-- CreateIndex +CREATE INDEX "Item_deletedAt_idx" ON "Item"("deletedAt"); + +-- AddForeignKey +ALTER TABLE "Item" ADD CONSTRAINT "Item_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Item" ADD CONSTRAINT "Item_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Item"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a8865b5..fa19c26 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -12,25 +12,55 @@ datasource db { } model User { - id Int @id @default(autoincrement()) - name String @db.VarChar(50) - email String @unique @db.VarChar(255) - password String @db.Text - sessions UserSession[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id Int @id @default(autoincrement()) + name String @db.VarChar(50) + email String @unique @db.VarChar(255) + password String @db.Text + + sessions UserSession[] + Item Item[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model UserSession { - id Int @id @default(autoincrement()) - userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - refreshToken String @db.VarChar(1024) - tokenFamily String @db.VarChar(36) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id Int @id @default(autoincrement()) + + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + refreshToken String @db.VarChar(1024) + tokenFamily String @db.VarChar(36) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@unique([tokenFamily]) @@index([userId]) @@index([userId, tokenFamily]) } + +model Item { + id Int @id @default(autoincrement()) + name String @db.VarChar(50) + mimeType String @db.VarChar(255) + blobUrl String @db.VarChar(1024) + ownerId Int + parentId Int? + + owner User @relation(fields: [ownerId], references: [id]) + + parentItem Item? @relation("ItemToItem", fields: [parentId], references: [id]) + Items Item[] @relation("ItemToItem") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + + @@index([blobUrl]) + @@index([ownerId]) + @@index([parentId]) + @@index([ownerId, parentId]) + @@index([deletedAt]) +} diff --git a/src/modules/blob/blob.controller.ts b/src/modules/blob/blob.controller.ts index 964b221..0f53af7 100644 --- a/src/modules/blob/blob.controller.ts +++ b/src/modules/blob/blob.controller.ts @@ -2,12 +2,15 @@ import { FastifyReply, FastifyRequest } from 'fastify'; import { UploadInput } from './blob.schema'; import BlobService from './blob.service'; import { HandleUploadBody } from '@vercel/blob/client'; +import ItemService from '../item/item.service'; export default class BlobController { private blobService: BlobService; + private itemService: ItemService; constructor(blobService: BlobService) { this.blobService = blobService; + this.itemService = new ItemService(); } public async uploadHandler( @@ -22,7 +25,49 @@ export default class BlobController { request.raw, ['text/plain'], async ({ blob, tokenPayload }) => { - console.log('File uploaded!', blob, tokenPayload); + request.log.info('bloby blob', blob); + + if (!tokenPayload) { + request.log.error( + "Vercel blob storage didn't pass a token payload!", + blob, + tokenPayload, + ); + throw new Error('Unauthorized'); + } + + const tokenPayloadObject = JSON.parse(tokenPayload); + if (!tokenPayloadObject.ownerId) { + request.log.error( + "Vercel blob storage didn't pass a valid token payload! ownerId is missing!", + blob, + tokenPayload, + ); + throw new Error('Unauthorized'); + } + + await this.itemService.createItem({ + name: blob.pathname, + mimeType: blob.contentType, + blobUrl: blob.url, + ownerId: tokenPayloadObject.ownerId, + parentId: tokenPayloadObject.parentId ?? null, + }); + }, + async (clientPayload, accessTokenPayload) => { + if (!clientPayload) { + throw new Error('No clientPayload was passed!'); + } + + const clientPayloadObject = JSON.parse(clientPayload); + if ('parentId' in clientPayloadObject === false) { + throw new Error('clientPayload.parentId is required!'); + } + + return JSON.stringify({ + parentId: clientPayloadObject.parentId, + ownerId: accessTokenPayload.sub, + }); }, ); diff --git a/src/modules/index.ts b/src/modules/index.ts index a43da66..977a471 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -3,6 +3,7 @@ import { FastifyInstance, FastifyPluginOptions } from 'fastify'; import fastifyPlugin from 'fastify-plugin'; import auth from './auth'; import blob from './blob'; +import item from './item'; const getOptionsWithPrefix = (options: FastifyPluginOptions, prefix: string) => { return { @@ -19,5 +20,6 @@ export default fastifyPlugin(async (fastify: FastifyInstance, options: FastifyPl await Promise.all([ fastify.register(auth, getOptionsWithPrefix(options, '/auth')), fastify.register(blob, getOptionsWithPrefix(options, '/blob')), + fastify.register(item, getOptionsWithPrefix(options, '/item')), ]); }); diff --git a/src/modules/item/index.ts b/src/modules/item/index.ts new file mode 100644 index 0000000..dfad8b9 --- /dev/null +++ b/src/modules/item/index.ts @@ -0,0 +1,13 @@ +import { FastifyInstance, FastifyPluginOptions } from 'fastify'; + +import fastifyPlugin from 'fastify-plugin'; +import itemRoute from './item.route'; +import { itemSchemas } from './item.schema'; + +export default fastifyPlugin(async (fastify: FastifyInstance, options: FastifyPluginOptions) => { + for (const schema of itemSchemas) { + fastify.addSchema(schema); + } + + await fastify.register(itemRoute, options); +}); diff --git a/src/modules/item/item.controller.ts b/src/modules/item/item.controller.ts new file mode 100644 index 0000000..ddcbb5a --- /dev/null +++ b/src/modules/item/item.controller.ts @@ -0,0 +1,89 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import { UploadInput } from './item.schema'; +import ItemService from './item.service'; +import { HandleUploadBody } from '@vercel/blob/client'; +import BlobService from '../blob/blob.service'; + +export default class ItemController { + private itemService: ItemService; + private blobService: BlobService; + + constructor(itemService: ItemService, blobService: BlobService) { + this.itemService = itemService; + this.blobService = blobService; + } + + public async uploadHandler( + request: FastifyRequest<{ + Body: UploadInput; + }>, + reply: FastifyReply, + ) { + try { + const jsonResponse = await this.blobService.handleUpload( + request.body as HandleUploadBody, + request.raw, + ['text/plain'], + async ({ blob, tokenPayload }) => { + fastify.log.info('bloby blob', blob); + + if (!tokenPayload) { + fastify.log.error( + "Vercel blob storage didn't pass a token payload!", + blob, + tokenPayload, + ); + throw new Error('Unauthorized'); + } + + const tokenPayloadObject = JSON.parse(tokenPayload); + if (!tokenPayloadObject.ownerId) { + fastify.log.error( + "Vercel blob storage didn't pass a valid token payload! ownerId is missing!", + blob, + tokenPayload, + ); + throw new Error('Unauthorized'); + } + + await this.itemService.createItem({ + name: blob.pathname, + mimeType: blob.contentType, + blobUrl: blob.url, + ownerId: tokenPayloadObject.ownerId, + parentId: tokenPayloadObject.parentId ?? null, + }); + }, + async (clientPayload, accessTokenPayload) => { + if (!clientPayload) { + throw new Error('No clientPayload was passed!'); + } + + const clientPayloadObject = JSON.parse(clientPayload); + if ('parentId' in clientPayloadObject === false) { + throw new Error('clientPayload.parentId is required!'); + } + + return JSON.stringify({ + parentId: clientPayloadObject.parentId, + ownerId: accessTokenPayload.sub, + }); + }, + ); + + return reply.code(200).send(jsonResponse); + } catch (e) { + if (e instanceof Error) { + if (e.message === 'Unauthorized') { + return reply.unauthorized(); + } + + console.error(e); + + return reply.badRequest(e.message); + } + + return reply.badRequest(); + } + } +} diff --git a/src/modules/item/item.route.ts b/src/modules/item/item.route.ts new file mode 100644 index 0000000..24cd325 --- /dev/null +++ b/src/modules/item/item.route.ts @@ -0,0 +1,23 @@ +import { FastifyInstance } from 'fastify'; +import ItemController from './item.controller'; +import { $ref } from './item.schema'; +import ItemService from './item.service'; +import BlobService from '../blob/blob.service'; + +export default async (fastify: FastifyInstance) => { + const itemController = new ItemController(new ItemService(), new BlobService()); + + fastify.post( + '/', + { + schema: { + headers: { + Authorization: true, + }, + tags: ['Item'], + body: $ref('uploadSchema'), + }, + }, + itemController.uploadHandler.bind(itemController), + ); +}; diff --git a/src/modules/item/item.schema.ts b/src/modules/item/item.schema.ts new file mode 100644 index 0000000..a329698 --- /dev/null +++ b/src/modules/item/item.schema.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; +import { buildJsonSchemas } from 'fastify-zod'; + +const uploadSchema = z.object({ + type: z.enum(['blob.generate-client-token', 'blob.upload-completed']), + payload: z.object({ + pathname: z.string().optional(), + callbackUrl: z.string().optional(), + clientPayload: z.string().optional(), + tokenPayload: z.string().optional(), + item: z + .object({ + url: z.string(), + pathname: z.string(), + contentType: z.string(), + contentDisposition: z.string(), + }) + .optional(), + }), +}); + +export type UploadInput = z.infer; + +export type CreateItem = { + name: string; + mimeType: string; + blobUrl: string; + ownerId: number; + parentId: number | null; +}; + +export const { schemas: itemSchemas, $ref } = buildJsonSchemas( + { + uploadSchema, + }, + { + $id: 'itemSchema', + }, +); diff --git a/src/modules/item/item.service.ts b/src/modules/item/item.service.ts new file mode 100644 index 0000000..d7c89db --- /dev/null +++ b/src/modules/item/item.service.ts @@ -0,0 +1,16 @@ +import { prisma } from '../../plugins/prisma'; +import { CreateItem } from './item.schema'; + +export default class ItemService { + public async createItem(input: CreateItem) { + await prisma.item.create({ + data: { + name: input.name, + mimeType: input.mimeType, + blobUrl: input.blobUrl, + ownerId: input.ownerId, + parentId: input.parentId, + }, + }); + } +}