feat: Implement device-specific video uploads and generate comprehensive OpenAPI documentation.

This commit is contained in:
2025-12-21 13:10:00 +00:00
parent e18f6566e7
commit cdaab7f0c1
7 changed files with 492 additions and 2 deletions

View File

@@ -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 | | `GET /videos` | List objects in the configured bucket |
| `DELETE /videos` | Delete an object by `objectKey` | | `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 ### Admin Dashboard
Access `/admin` with Basic auth to: Access `/admin` with Basic auth to:

View File

@@ -5,6 +5,7 @@
"": { "": {
"name": "backend", "name": "backend",
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "^8.1.0",
"actions-on-google": "^3.0.0", "actions-on-google": "^3.0.0",
"axios": "^1.13.4", "axios": "^1.13.4",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@@ -18,6 +19,7 @@
"openai": "^6.18.0", "openai": "^6.18.0",
"pg": "^8.18.0", "pg": "^8.18.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.1",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"zod": "^4.3.6", "zod": "^4.3.6",
}, },
@@ -28,6 +30,7 @@
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.2.1", "@types/node": "^25.2.1",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/swagger-ui-express": "^4.1.8",
"drizzle-kit": "^0.31.0", "drizzle-kit": "^0.31.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
}, },
@@ -37,6 +40,8 @@
}, },
}, },
"packages": { "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/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=="], "@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=="], "@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=="], "@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=="], "@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/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=="], "@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=="], "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=="], "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=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "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=="], "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=="], "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=="], "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=="], "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=="], "yn": ["yn@3.1.1", "", {}, "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],

437
Backend/docs/openapi.ts Normal file
View File

@@ -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',
},
},
},
});
}

View File

@@ -1,17 +1,26 @@
import express from 'express'; import express from 'express';
import { toNodeHandler } from 'better-auth/node'; import { toNodeHandler } from 'better-auth/node';
import swaggerUi from 'swagger-ui-express';
import { auth } from './auth'; import { auth } from './auth';
import { buildOpenApiDocument } from './docs/openapi';
import videosRoutes from './routes/videos'; import videosRoutes from './routes/videos';
import adminRoutes from './routes/admin'; import adminRoutes from './routes/admin';
import { ensureMinioBucket } from './utils/minio'; import { ensureMinioBucket } from './utils/minio';
const app = express(); const app = express();
const openApiDocument = buildOpenApiDocument();
app.get('/', (_req, res) => { app.get('/', (_req, res) => {
res.send('API is running'); 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.all('/api/auth/*splat', toNodeHandler(auth));
app.use(express.json()); app.use(express.json());

View File

@@ -10,6 +10,7 @@
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.2.1", "@types/node": "^25.2.1",
"@types/pg": "^8.16.0", "@types/pg": "^8.16.0",
"@types/swagger-ui-express": "^4.1.8",
"drizzle-kit": "^0.31.0", "drizzle-kit": "^0.31.0",
"ts-node": "^10.9.2" "ts-node": "^10.9.2"
}, },
@@ -17,6 +18,7 @@
"typescript": "^5.9.3" "typescript": "^5.9.3"
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "^8.1.0",
"actions-on-google": "^3.0.0", "actions-on-google": "^3.0.0",
"axios": "^1.13.4", "axios": "^1.13.4",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@@ -30,6 +32,7 @@
"openai": "^6.18.0", "openai": "^6.18.0",
"pg": "^8.18.0", "pg": "^8.18.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"swagger-ui-express": "^5.0.1",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },

View File

@@ -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 { z } from 'zod';
import { ensureMinioBucket, minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio'; import { ensureMinioBucket, minioBucket, minioClient, minioPresignedExpirySeconds } from '../utils/minio';

View File

@@ -1,8 +1,9 @@
import { Router } from 'express'; import { Router } from 'express';
import { and, eq } from 'drizzle-orm';
import { z } from 'zod'; import { z } from 'zod';
import { db } from '../db/client'; import { db } from '../db/client';
import { videos } from '../db/schema'; import { devices, videos } from '../db/schema';
import { requireAuth } from '../middleware/auth'; import { requireAuth } from '../middleware/auth';
import { import {
ensureMinioBucket, ensureMinioBucket,
@@ -15,6 +16,7 @@ const router = Router();
const uploadUrlSchema = z.object({ const uploadUrlSchema = z.object({
fileName: z.string().trim().min(1).max(255), fileName: z.string().trim().min(1).max(255),
deviceId: z.string().uuid(),
prefix: z.string().trim().optional(), prefix: z.string().trim().optional(),
}); });
@@ -55,6 +57,16 @@ router.post('/upload-url', async (req, res) => {
await ensureMinioBucket(); 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 objectKey = buildObjectKey(authSession.user.id, parsed.data.fileName, parsed.data.prefix);
const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds); const uploadUrl = await minioClient.presignedPutObject(minioBucket, objectKey, minioPresignedExpirySeconds);
const now = new Date(); const now = new Date();
@@ -64,6 +76,7 @@ router.post('/upload-url', async (req, res) => {
.insert(videos) .insert(videos)
.values({ .values({
userId: authSession.user.id, userId: authSession.user.id,
deviceId: parsed.data.deviceId,
objectKey, objectKey,
bucket: minioBucket, bucket: minioBucket,
uploadUrl, uploadUrl,