diff --git a/Backend/README.md b/Backend/README.md index d90ea8e..805e966 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -81,6 +81,16 @@ All authenticated endpoints expect a Better Auth session cookie sent by the clie | `GET /videos` | List objects in the configured bucket | | `DELETE /videos` | Delete an object by `objectKey` | +`POST /videos/upload-url` request body requires `fileName` and `deviceId` (UUID belonging to the authenticated user), with optional `prefix`. + +### API Docs +OpenAPI docs are generated from Zod/OpenAPI definitions: + +| Endpoint | Purpose | +| --- | --- | +| `GET /openapi.json` | OpenAPI 3 spec (JSON) | +| `GET /docs` | Swagger UI | + ### Admin Dashboard Access `/admin` with Basic auth to: diff --git a/Backend/bun.lock b/Backend/bun.lock index 63db2d5..64f6059 100644 --- a/Backend/bun.lock +++ b/Backend/bun.lock @@ -5,6 +5,7 @@ "": { "name": "backend", "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.1.0", "actions-on-google": "^3.0.0", "axios": "^1.13.4", "bcrypt": "^6.0.0", @@ -18,6 +19,7 @@ "openai": "^6.18.0", "pg": "^8.18.0", "socket.io": "^4.8.3", + "swagger-ui-express": "^5.0.1", "uuid": "^13.0.0", "zod": "^4.3.6", }, @@ -28,6 +30,7 @@ "@types/express": "^5.0.6", "@types/node": "^25.2.1", "@types/pg": "^8.16.0", + "@types/swagger-ui-express": "^4.1.8", "drizzle-kit": "^0.31.0", "ts-node": "^10.9.2", }, @@ -37,6 +40,8 @@ }, }, "packages": { + "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@8.4.0", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-Ckp971tmTw4pnv+o7iK85ldBHBKk6gxMaoNyLn3c2Th/fKoTG8G3jdYuOanpdGqwlDB0z01FOjry2d32lfTqrA=="], + "@better-auth/core": ["@better-auth/core@1.4.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.3.5" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.8", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-q+awYgC7nkLEBdx2sW0iJjkzgSHlIxGnOpsN1r/O1+a4m7osJNHtfK2mKJSL1I+GfNyIlxJF8WvD/NLuYMpmcg=="], "@better-auth/telemetry": ["@better-auth/telemetry@1.4.18", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.18" } }, "sha512-e5rDF8S4j3Um/0LIVATL2in9dL4lfO2fr2v1Wio4qTMRbfxqnUDTa+6SZtwdeJrbc4O+a3c+IyIpjG9Q/6GpfQ=="], @@ -115,6 +120,8 @@ "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="], + "@scarf/scarf": ["@scarf/scarf@1.4.0", "", {}, "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ=="], + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -163,6 +170,8 @@ "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], + "@types/swagger-ui-express": ["@types/swagger-ui-express@4.1.8", "", { "dependencies": { "@types/express": "*", "@types/serve-static": "*" } }, "sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g=="], + "@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -423,6 +432,8 @@ "openai": ["openai@6.18.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-odLRYyz9rlzz6g8gKn61RM2oP5UUm428sE2zOxZqS9MzVfD5/XW8UoEjpnRkzTuScXP7ZbP/m7fC+bl8jCOZZw=="], + "openapi3-ts": ["openapi3-ts@4.5.0", "", { "dependencies": { "yaml": "^2.8.0" } }, "sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], @@ -525,6 +536,10 @@ "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], + "swagger-ui-dist": ["swagger-ui-dist@5.31.0", "", { "dependencies": { "@scarf/scarf": "=1.4.0" } }, "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg=="], + + "swagger-ui-express": ["swagger-ui-express@5.0.1", "", { "dependencies": { "swagger-ui-dist": ">=5.0.0" }, "peerDependencies": { "express": ">=4.0.0 || >=5.0.0-beta" } }, "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA=="], + "through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], @@ -573,6 +588,8 @@ "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], diff --git a/Backend/docs/openapi.ts b/Backend/docs/openapi.ts new file mode 100644 index 0000000..4ff3a30 --- /dev/null +++ b/Backend/docs/openapi.ts @@ -0,0 +1,437 @@ +import { OpenAPIRegistry, OpenApiGeneratorV3, extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; +import { z } from 'zod'; + +extendZodWithOpenApi(z); + +const registry = new OpenAPIRegistry(); + +const ErrorResponseSchema = registry.register( + 'ErrorResponse', + z.object({ + message: z.string(), + errors: z.unknown().optional(), + }), +); + +const VideoRecordSchema = registry.register( + 'VideoRecord', + z.object({ + id: z.string().uuid(), + objectKey: z.string(), + bucket: z.string(), + status: z.string(), + createdAt: z.string().datetime(), + expiresAt: z.string().datetime().nullable(), + }), +); + +const VideoUploadUrlRequestSchema = registry.register( + 'VideoUploadUrlRequest', + z.object({ + fileName: z.string().min(1).max(255), + deviceId: z.string().uuid(), + prefix: z.string().optional(), + }), +); + +const AdminUploadUrlRequestSchema = registry.register( + 'AdminUploadUrlRequest', + z.object({ + fileName: z.string().min(1).max(255), + prefix: z.string().optional(), + }), +); + +const UploadUrlResponseSchema = registry.register( + 'UploadUrlResponse', + z.object({ + message: z.string(), + bucket: z.string(), + objectKey: z.string(), + uploadUrl: z.string().url(), + expiresInSeconds: z.number().int().positive(), + video: VideoRecordSchema, + }), +); + +const DownloadUrlQuerySchema = registry.register( + 'DownloadUrlQuery', + z.object({ + objectKey: z.string().min(1), + }), +); + +const DownloadUrlResponseSchema = registry.register( + 'DownloadUrlResponse', + z.object({ + message: z.string(), + bucket: z.string(), + objectKey: z.string(), + downloadUrl: z.string().url(), + expiresInSeconds: z.number().int().positive(), + }), +); + +const ListVideosQuerySchema = registry.register( + 'ListVideosQuery', + z.object({ + prefix: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), + }), +); + +const ListedObjectSchema = registry.register( + 'ListedObject', + z.object({ + objectKey: z.string().nullable(), + size: z.number(), + etag: z.string().nullable(), + lastModified: z.string().datetime().nullable(), + }), +); + +const ListVideosResponseSchema = registry.register( + 'ListVideosResponse', + z.object({ + bucket: z.string(), + count: z.number().int().nonnegative(), + objects: z.array(ListedObjectSchema), + }), +); + +const DeleteVideoResponseSchema = registry.register( + 'DeleteVideoResponse', + z.object({ + message: z.string(), + bucket: z.string(), + objectKey: z.string(), + }), +); + +registry.registerPath({ + method: 'get', + path: '/', + summary: 'Health check', + tags: ['System'], + responses: { + 200: { + description: 'Service health text', + content: { + 'text/plain': { + schema: z.string(), + }, + }, + }, + }, +}); + +registry.registerPath({ + method: 'post', + path: '/videos/upload-url', + summary: 'Create a presigned upload URL', + tags: ['Videos'], + security: [{ cookieAuth: [] }], + request: { + body: { + content: { + 'application/json': { + schema: VideoUploadUrlRequestSchema, + }, + }, + }, + }, + responses: { + 201: { + description: 'Presigned upload URL generated', + content: { + 'application/json': { + schema: UploadUrlResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid request body', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 401: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +registry.registerPath({ + method: 'get', + path: '/videos/download-url', + summary: 'Create a signed download URL', + tags: ['Videos'], + security: [{ cookieAuth: [] }], + request: { + query: DownloadUrlQuerySchema, + }, + responses: { + 200: { + description: 'Signed download URL generated', + content: { + 'application/json': { + schema: DownloadUrlResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid query params', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 401: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +registry.registerPath({ + method: 'get', + path: '/videos', + summary: 'List objects in the configured bucket', + tags: ['Videos'], + security: [{ cookieAuth: [] }], + request: { + query: ListVideosQuerySchema, + }, + responses: { + 200: { + description: 'Object listing', + content: { + 'application/json': { + schema: ListVideosResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid query params', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 401: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +registry.registerPath({ + method: 'delete', + path: '/videos', + summary: 'Delete object by object key', + tags: ['Videos'], + security: [{ cookieAuth: [] }], + request: { + query: DownloadUrlQuerySchema, + }, + responses: { + 200: { + description: 'Object deleted', + content: { + 'application/json': { + schema: DeleteVideoResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid query params', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 401: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +registry.registerPath({ + method: 'post', + path: '/admin/upload-url', + summary: 'Admin: create presigned upload URL', + tags: ['Admin'], + security: [{ basicAuth: [] }], + request: { + body: { + content: { + 'application/json': { + schema: AdminUploadUrlRequestSchema, + }, + }, + }, + }, + responses: { + 201: { + description: 'Admin upload URL generated', + content: { + 'application/json': { + schema: z.object({ + message: z.string(), + bucket: z.string(), + objectKey: z.string(), + uploadUrl: z.string().url(), + expiresAt: z.string().datetime(), + expiresInSeconds: z.number().int().positive(), + }), + }, + }, + }, + 400: { + description: 'Invalid request body', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 401: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +registry.registerPath({ + method: 'get', + path: '/admin/objects', + summary: 'Admin: list objects in bucket', + tags: ['Admin'], + security: [{ basicAuth: [] }], + request: { + query: ListVideosQuerySchema, + }, + responses: { + 200: { + description: 'Admin object listing', + content: { + 'application/json': { + schema: ListVideosResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid query params', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 401: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +registry.registerPath({ + method: 'delete', + path: '/admin/object', + summary: 'Admin: delete object by key', + tags: ['Admin'], + security: [{ basicAuth: [] }], + request: { + query: DownloadUrlQuerySchema, + }, + responses: { + 200: { + description: 'Object deleted', + content: { + 'application/json': { + schema: DeleteVideoResponseSchema, + }, + }, + }, + 400: { + description: 'Invalid query params', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + 401: { + description: 'Unauthorized', + content: { + 'application/json': { + schema: ErrorResponseSchema, + }, + }, + }, + }, +}); + +export function buildOpenApiDocument() { + const generator = new OpenApiGeneratorV3(registry.definitions); + + return generator.generateDocument({ + openapi: '3.0.3', + info: { + title: 'Backend API', + version: '1.0.0', + description: 'Auto-generated API documentation from Zod schemas.', + }, + servers: [{ url: process.env.BETTER_AUTH_URL ?? 'http://localhost:3000' }], + tags: [ + { name: 'System', description: 'Service endpoints' }, + { name: 'Videos', description: 'Authenticated video object operations' }, + { name: 'Admin', description: 'Basic-auth protected admin operations' }, + ], + components: { + securitySchemes: { + cookieAuth: { + type: 'apiKey', + in: 'cookie', + name: 'better-auth.session_token', + description: 'Better Auth session cookie', + }, + basicAuth: { + type: 'http', + scheme: 'basic', + }, + }, + }, + }); +} diff --git a/Backend/index.ts b/Backend/index.ts index 758b75f..0f1d507 100644 --- a/Backend/index.ts +++ b/Backend/index.ts @@ -1,17 +1,26 @@ import express from 'express'; import { toNodeHandler } from 'better-auth/node'; +import swaggerUi from 'swagger-ui-express'; import { auth } from './auth'; +import { buildOpenApiDocument } from './docs/openapi'; import videosRoutes from './routes/videos'; import adminRoutes from './routes/admin'; import { ensureMinioBucket } from './utils/minio'; const app = express(); +const openApiDocument = buildOpenApiDocument(); app.get('/', (_req, res) => { res.send('API is running'); }); +app.get('/openapi.json', (_req, res) => { + res.json(openApiDocument); +}); + +app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument)); + app.all('/api/auth/*splat', toNodeHandler(auth)); app.use(express.json()); diff --git a/Backend/package.json b/Backend/package.json index e50a63d..541ec08 100644 --- a/Backend/package.json +++ b/Backend/package.json @@ -10,6 +10,7 @@ "@types/express": "^5.0.6", "@types/node": "^25.2.1", "@types/pg": "^8.16.0", + "@types/swagger-ui-express": "^4.1.8", "drizzle-kit": "^0.31.0", "ts-node": "^10.9.2" }, @@ -17,6 +18,7 @@ "typescript": "^5.9.3" }, "dependencies": { + "@asteasolutions/zod-to-openapi": "^8.1.0", "actions-on-google": "^3.0.0", "axios": "^1.13.4", "bcrypt": "^6.0.0", @@ -30,6 +32,7 @@ "openai": "^6.18.0", "pg": "^8.18.0", "socket.io": "^4.8.3", + "swagger-ui-express": "^5.0.1", "uuid": "^13.0.0", "zod": "^4.3.6" }, diff --git a/Backend/routes/admin.ts b/Backend/routes/admin.ts index 5ef0d96..1a6c97c 100644 --- a/Backend/routes/admin.ts +++ b/Backend/routes/admin.ts @@ -1,4 +1,5 @@ -import { NextFunction, Request, Response, Router } from 'express'; +import type { NextFunction, Request, Response } from 'express'; +import { Router } from 'express'; import { z } from 'zod'; import { ensureMinioBucket, minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio'; diff --git a/Backend/routes/videos.ts b/Backend/routes/videos.ts index f1acdf4..b8019de 100644 --- a/Backend/routes/videos.ts +++ b/Backend/routes/videos.ts @@ -1,8 +1,9 @@ import { Router } from 'express'; +import { and, eq } from 'drizzle-orm'; import { z } from 'zod'; import { db } from '../db/client'; -import { videos } from '../db/schema'; +import { devices, videos } from '../db/schema'; import { requireAuth } from '../middleware/auth'; import { ensureMinioBucket, @@ -15,6 +16,7 @@ const router = Router(); const uploadUrlSchema = z.object({ fileName: z.string().trim().min(1).max(255), + deviceId: z.string().uuid(), prefix: z.string().trim().optional(), }); @@ -55,6 +57,16 @@ router.post('/upload-url', async (req, res) => { await ensureMinioBucket(); + const device = await db.query.devices.findFirst({ + where: and(eq(devices.id, parsed.data.deviceId), eq(devices.userId, authSession.user.id)), + columns: { id: true }, + }); + + if (!device) { + res.status(400).json({ message: 'Invalid deviceId for this user' }); + return; + } + const objectKey = buildObjectKey(authSession.user.id, parsed.data.fileName, parsed.data.prefix); const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds); const now = new Date(); @@ -64,6 +76,7 @@ router.post('/upload-url', async (req, res) => { .insert(videos) .values({ userId: authSession.user.id, + deviceId: parsed.data.deviceId, objectKey, bucket: minioBucket, uploadUrl,