Skip to content

Commit

Permalink
#2 - WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
frederikpyt committed Sep 28, 2023
1 parent 8218f36 commit fa0f34b
Show file tree
Hide file tree
Showing 16 changed files with 890 additions and 90 deletions.
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,7 @@ REDIS_HOST="redis"
REDIS_PORT="6379"
REDIS_USER=""
REDIS_PASSWORD=""
REDIS_URL="rediss://${REDIS_USER}:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}"
REDIS_URL="rediss://${REDIS_USER}:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}"

# Vercel Blob Storage
BLOB_READ_WRITE_TOKEN=""
604 changes: 531 additions & 73 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@fastify/swagger": "^8.10.0",
"@fastify/swagger-ui": "^1.9.3",
"@prisma/client": "^5.3.1",
"@vercel/blob": "^0.12.5",
"bcrypt": "^5.1.1",
"dotenv": "^16.3.1",
"fastify": "^4.23.2",
Expand Down
35 changes: 35 additions & 0 deletions prisma/migrations/20230927083830_item/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
55 changes: 42 additions & 13 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -12,25 +12,54 @@ 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())
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
refreshToken String @db.VarChar(1024)
tokenFamily String @db.VarChar(36)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
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])
}
6 changes: 5 additions & 1 deletion src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { FastifyInstance, FastifyPluginOptions } from 'fastify';

import fastifyPlugin from 'fastify-plugin';
import auth from './auth';
import item from './item';

const getOptionsWithPrefix = (options: FastifyPluginOptions, prefix: string) => {
return {
Expand All @@ -15,5 +16,8 @@ export default fastifyPlugin(async (fastify: FastifyInstance, options: FastifyPl
return { status: 'OK' };
});

await Promise.all([fastify.register(auth, getOptionsWithPrefix(options, '/auth'))]);
await Promise.all([
fastify.register(auth, getOptionsWithPrefix(options, '/auth')),
fastify.register(item, getOptionsWithPrefix(options, '/item')),
]);
});
55 changes: 55 additions & 0 deletions src/modules/item/blob.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { HeadBlobResult, PutBlobResult, del, head } from '@vercel/blob';
import { HandleUploadBody, handleUpload } from '@vercel/blob/client';
import { IncomingMessage } from 'http';
import { accessTokenPayload, jwt } from '../../plugins/jwt';

export default class BlobService {
public async deleteBlob(url: string | string[]): Promise<void> {
await del(url);
}

public async getBlobMetaData(url: string): Promise<HeadBlobResult | null> {
return await head(url);
}

public async handleUpload(
body: HandleUploadBody,
request: IncomingMessage,
allowedContentTypes: string[],
onUploadCompleted: (body: {
blob: PutBlobResult;
tokenPayload?: string | undefined;
}) => Promise<void>,
formatTokenPayload?: (
clientPayload: string | undefined,
accessTokenPayload: accessTokenPayload,
) => string | undefined | Promise<string | undefined>,
): Promise<
| { type: 'blob.generate-client-token'; clientToken: string }
| { type: 'blob.upload-completed'; response: 'ok' }
> {
return await handleUpload({
body: body,
request: request,
onBeforeGenerateToken: async (pathname, clientPayload) => {
const accessToken = request.headers.authorization?.replace('Bearer ', '') ?? '';

try {
jwt.verify(accessToken);
} catch (error) {
throw new Error('Unauthorized');
}

const accessTokenPayload = jwt.decodeAccessToken(accessToken);

return {
allowedContentTypes,
tokenPayload: formatTokenPayload
? await formatTokenPayload(clientPayload, accessTokenPayload)
: undefined,
};
},
onUploadCompleted,
});
}
}
13 changes: 13 additions & 0 deletions src/modules/item/index.ts
Original file line number Diff line number Diff line change
@@ -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);
});
90 changes: 90 additions & 0 deletions src/modules/item/item.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { FastifyReply, FastifyRequest } from 'fastify';
import { UploadInput } from './item.schema';
import BlobService from './blob.service';
import { HandleUploadBody } from '@vercel/blob/client';
import ItemService from './item.service';

export default class ItemController {
private itemService: ItemService;
private blobService: BlobService;

constructor(itemService: ItemService, blobService: BlobService) {
this.blobService = blobService;
this.itemService = itemService;
}

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 }) => {
try {
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,
});
} catch (e) {
request.log.error(e);
await this.blobService.deleteBlob(blob.url);
}
},
async (clientPayload, accessTokenPayload) => {
if (!clientPayload) {
throw new Error('No clientPayload was passed!');
}

const clientPayloadObject = JSON.parse(clientPayload);
if (clientPayloadObject.parentId === undefined) {
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();
}

return reply.badRequest(e.message);
}

return reply.badRequest();
}
}
}
23 changes: 23 additions & 0 deletions src/modules/item/item.route.ts
Original file line number Diff line number Diff line change
@@ -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.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),
);
};
39 changes: 39 additions & 0 deletions src/modules/item/item.schema.ts
Original file line number Diff line number Diff line change
@@ -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(),
blob: z
.object({
url: z.string(),
pathname: z.string(),
contentType: z.string(),
contentDisposition: z.string(),
})
.optional(),
}),
});

export type UploadInput = z.infer<typeof uploadSchema>;

export type CreateItem = {
name: string;
mimeType: string;
blobUrl: string;
ownerId: number;
parentId: number | null;
};

export const { schemas: itemSchemas, $ref } = buildJsonSchemas(
{
uploadSchema,
},
{
$id: 'itemSchema',
},
);
16 changes: 16 additions & 0 deletions src/modules/item/item.service.ts
Original file line number Diff line number Diff line change
@@ -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,
},
});
}
}
Loading

0 comments on commit fa0f34b

Please sign in to comment.