diff --git a/packages/backend/lib/auth.ts b/packages/backend/lib/auth.ts index 17ea336..2c03942 100644 --- a/packages/backend/lib/auth.ts +++ b/packages/backend/lib/auth.ts @@ -68,13 +68,9 @@ export async function createSession( export async function validateSession( sessionId: string ): Promise<{ user: User; session: any } | null> { - const session = await db - .select({ - ...getTableColumns(usersInCredentials), - ...getTableColumns(loginSessionsInCredentials) - }) + const sessions = await db + .select() .from(loginSessionsInCredentials) - .innerJoin(usersInCredentials, eq(loginSessionsInCredentials.uid, usersInCredentials.id)) .where( and( eq(loginSessionsInCredentials.id, sessionId), @@ -83,23 +79,23 @@ export async function validateSession( ) .limit(1); - if (session.length === 0) { + if (sessions.length === 0) { return null; } - const foundSession = session[0]; + const session = sessions[0]; - if (foundSession.expireAt && new Date(foundSession.expireAt) < new Date()) { + if (session.expireAt && new Date(session.expireAt) < new Date()) { return null; } - const user = await db + const users = await db .select() .from(usersInCredentials) - .where(eq(usersInCredentials.id, foundSession.uid)) + .where(eq(usersInCredentials.id, session.uid)) .limit(1); - if (user.length === 0) { + if (users.length === 0) { return null; } @@ -109,8 +105,8 @@ export async function validateSession( .where(eq(loginSessionsInCredentials.id, sessionId)); return { - user: user[0], - session: foundSession + user: users[0], + session: session }; } diff --git a/packages/backend/middlewares/auth.ts b/packages/backend/middlewares/auth.ts index 6be45ad..e29d358 100644 --- a/packages/backend/middlewares/auth.ts +++ b/packages/backend/middlewares/auth.ts @@ -30,6 +30,8 @@ export const requireAuth = new Elysia({ name: "require-auth" }) const authHeader = headers.authorization; if (authHeader.startsWith("Bearer ")) { sessionId = authHeader.substring(7); + } else if (authHeader.startsWith("Token ")) { + sessionId = authHeader.substring(6); } } diff --git a/packages/backend/package.json b/packages/backend/package.json index 263e637..0af518e 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "1.0.0", + "version": "1.1.0", "scripts": { "dev": "NODE_ENV=development bun run --watch src/index.ts", "start": "NODE_ENV=production bun run src/index.ts", diff --git a/packages/backend/routes/auth/login.ts b/packages/backend/routes/auth/login.ts index a7de193..64f4101 100644 --- a/packages/backend/routes/auth/login.ts +++ b/packages/backend/routes/auth/login.ts @@ -16,7 +16,7 @@ export const loginHandler = new Elysia({ prefix: "/auth" }).use(ip()).post( const userAgent = request.headers.get("user-agent") || "Unknown"; const sessionId = await createSession(user.id, ip || null, userAgent); - const expiresAt = getSessionExpirationDate(); + const expiresAt = getSessionExpirationDate(365); cookie.sessionId.value = sessionId; cookie.sessionId.httpOnly = true; cookie.sessionId.secure = process.env.NODE_ENV === "production"; @@ -53,6 +53,13 @@ export const loginHandler = new Elysia({ prefix: "/auth" }).use(ip()).post( body: t.Object({ username: t.String(), password: t.String() - }) + }), + detail: { + summary: "User login", + description: + "This endpoint authenticates users by verifying their credentials and creates a new session. \ + Upon successful authentication, it returns user information and sets a secure HTTP-only cookie \ + for session management. The session includes IP address and user agent tracking for security purposes." + } } ); diff --git a/packages/backend/routes/auth/logout.ts b/packages/backend/routes/auth/logout.ts index fa227f9..7d13b80 100644 --- a/packages/backend/routes/auth/logout.ts +++ b/packages/backend/routes/auth/logout.ts @@ -1,50 +1,37 @@ import { Elysia, t } from "elysia"; import { deactivateSession } from "@backend/lib/auth"; +import requireAuth from "@backend/middlewares/auth"; -export const logoutHandler = new Elysia({ prefix: "/auth" }) - .delete( - "/session", - async ({ set, cookie }) => { - const sessionId = cookie.sessionId?.value; +export const logoutHandler = new Elysia({ prefix: "/auth" }).use(requireAuth).delete( + "/session", + async ({ set, session, cookie }) => { + const sessionId = session.sessionId; - if (!sessionId) { - set.status = 401; - return { message: "Not authenticated." }; - } - - await deactivateSession(sessionId as string); - cookie.sessionId.remove(); - - return { message: "Successfully logged out." }; - }, - { - response: { - 200: t.Object({ - message: t.String() - }), - 401: t.Object({ - message: t.String() - }) - } + if (!sessionId) { + set.status = 401; + return { message: "Not authenticated." }; } - ) - .delete( - "/session/:id", - async ({ params }) => { - const sessionId = params.id; - await deactivateSession(sessionId as string); + await deactivateSession(sessionId as string); + cookie.sessionId.remove(); - return { message: "Successfully logged out." }; + return { message: "Successfully logged out." }; + }, + { + response: { + 200: t.Object({ + message: t.String() + }), + 401: t.Object({ + message: t.String() + }) }, - { - response: { - 200: t.Object({ - message: t.String() - }), - 401: t.Object({ - message: t.String() - }) - } + detail: { + summary: "Logout current session", + description: + "This endpoint logs out the current user by deactivating their session and removing the session cookie. \ + It requires an active session cookie to be present in the request. After successful logout, the session \ + is invalidated and cannot be used again." } - ); + } +); diff --git a/packages/backend/routes/search/index.ts b/packages/backend/routes/search/index.ts index d703f29..724560a 100644 --- a/packages/backend/routes/search/index.ts +++ b/packages/backend/routes/search/index.ts @@ -150,6 +150,14 @@ export const searchHandler = new Elysia({ prefix: "/search" }).get( }, query: z.object({ query: z.string() - }) + }), + detail: { + summary: "Search songs and videos", + description: + "This endpoint performs a comprehensive search across songs and videos in the database. \ + It searches for songs by name and videos by bilibili ID (av/BV format). The results are ranked \ + by relevance using a weighted algorithm that considers search term frequency, title length, \ + and view count. Returns search results with performance timing information." + } } ); diff --git a/packages/backend/routes/song/add.ts b/packages/backend/routes/song/add.ts index 9fcf7b8..4fa6a70 100644 --- a/packages/backend/routes/song/add.ts +++ b/packages/backend/routes/song/add.ts @@ -62,7 +62,15 @@ export const addSongHandler = new Elysia() }, body: t.Object({ id: t.String() - }) + }), + detail: { + summary: "Import song from bilibili", + description: + "This endpoint allows authenticated users to import a song from bilibili by providing a video ID. \ + The video ID can be in av or BV format. The system validates the ID format, checks if the video already \ + exists in the database, and if not, creates a background job to fetch video metadata and add it to the songs collection. \ + Returns the job ID for tracking the import progress." + } } ) .get( @@ -106,6 +114,13 @@ export const addSongHandler = new Elysia() }, params: t.Object({ id: t.String() - }) + }), + detail: { + summary: "Check import job status", + description: + "This endpoint retrieves the current status of a song import job. It returns the job state \ + (completed, failed, active, etc.), the result if completed, and any failure reason if the job failed. \ + Use this endpoint to monitor the progress of song imports initiated through the import endpoint." + } } ); diff --git a/packages/backend/routes/song/delete.ts b/packages/backend/routes/song/delete.ts index 8a1024c..e33368e 100644 --- a/packages/backend/routes/song/delete.ts +++ b/packages/backend/routes/song/delete.ts @@ -32,6 +32,13 @@ export const deleteSongHandler = new Elysia({ prefix: "/song" }).use(requireAuth }, params: t.Object({ id: t.String() - }) + }), + detail: { + summary: "Delete song", + description: + "This endpoint allows authenticated users to soft-delete a song from the database. \ + The song is marked as deleted rather than being permanently removed, preserving data integrity. \ + The deletion is logged in the history table for audit purposes. Requires authentication and appropriate permissions." + } } ); diff --git a/packages/backend/routes/song/info.ts b/packages/backend/routes/song/info.ts index 8d763a4..9e7d7f3 100644 --- a/packages/backend/routes/song/info.ts +++ b/packages/backend/routes/song/info.ts @@ -158,7 +158,15 @@ const songInfoUpdateHandler = new Elysia({ prefix: "/song" }).use(requireAuth).p body: t.Object({ name: t.Optional(t.String()), producer: t.Optional(t.String()) - }) + }), + detail: { + summary: "Update song information", + description: + "This endpoint allows authenticated users to update song metadata. It accepts partial updates \ + for song name and producer fields. The endpoint validates the song ID (accepting both internal database IDs \ + and bilibili video IDs in av/BV format), applies the requested changes, and logs the update in the history table \ + for audit purposes. Requires authentication." + } } ); diff --git a/packages/backend/routes/song/milestone.ts b/packages/backend/routes/song/milestone.ts index d694457..7fde260 100644 --- a/packages/backend/routes/song/milestone.ts +++ b/packages/backend/routes/song/milestone.ts @@ -50,6 +50,14 @@ export const closeMileStoneHandler = new Elysia({ prefix: "/songs" }).use(server 404: t.Object({ message: t.String() }) + }, + detail: { + summary: "Get songs close to milestones", + description: + "This endpoint retrieves songs that are approaching significant view count milestones. \ + It supports three milestone types: 'dendou' (0-100k views), 'densetsu' (100k-1M views), and 'shinwa' (1M-10M views). \ + For each type, it returns videos that are within the specified view range and have an estimated time to reach \ + the next milestone below the threshold. Results are ordered by estimated time to milestone." } } ); diff --git a/packages/backend/routes/video/eta.ts b/packages/backend/routes/video/eta.ts index 8cd5ce1..86d1573 100644 --- a/packages/backend/routes/video/eta.ts +++ b/packages/backend/routes/video/eta.ts @@ -49,6 +49,14 @@ export const songEtaHandler = new Elysia({ prefix: "/video" }).get( }, headers: t.Object({ Authorization: t.Optional(t.String()) - }) + }), + detail: { + summary: "Get video milestone ETA", + description: + "This endpoint retrieves the estimated time to reach the next milestone for a given video. \ + It accepts video IDs in av or BV format and returns the current view count, estimated time to \ + reach the next milestone (in hours), view growth speed, and last update timestamp. Useful for \ + tracking video growth and milestone predictions." + } } ); diff --git a/packages/backend/routes/video/metadata.ts b/packages/backend/routes/video/metadata.ts index 814c16a..6419c57 100644 --- a/packages/backend/routes/video/metadata.ts +++ b/packages/backend/routes/video/metadata.ts @@ -88,6 +88,14 @@ export const getVideoMetadataHandler = new Elysia({ prefix: "/video" }).get( 200: t.Any(), 400: ErrorResponseSchema, 500: ErrorResponseSchema + }, + detail: { + summary: "Get video metadata", + description: + "This endpoint retrieves comprehensive metadata for a bilibili video. It accepts video IDs in av or BV format \ + and returns detailed information including title, description, uploader, statistics (views, likes, coins, etc.), \ + and publication date. The data is cached for 60 seconds to reduce API calls. If the video is not in cache, \ + it fetches fresh data from bilibili API and stores a snapshot in the database." } } ); diff --git a/packages/backend/routes/video/snapshots.ts b/packages/backend/routes/video/snapshots.ts index 5cdc957..c788427 100644 --- a/packages/backend/routes/video/snapshots.ts +++ b/packages/backend/routes/video/snapshots.ts @@ -57,6 +57,14 @@ export const getVideoSnapshotsHandler = new Elysia({ prefix: "/video" }).get( ), 400: ErrorResponseSchema, 500: ErrorResponseSchema + }, + detail: { + summary: "Get video snapshots", + description: + "This endpoint retrieves historical view count snapshots for a bilibili video. It accepts video IDs in av or BV format \ + and returns a chronological list of snapshots showing how the video's statistics (views, likes, coins, favorites, etc.) \ + have changed over time. If no snapshots exist for the video, it automatically queues a snapshot job to collect initial data. \ + Results are ordered by creation date in descending order." } } ); diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 807d529..3e6dc99 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -16,6 +16,8 @@ import { deleteSongHandler } from "@backend/routes/song/delete"; import { songEtaHandler } from "@backend/routes/video/eta"; import "./mq"; import pkg from "../package.json"; +import * as z from "zod"; +import { fromTypes } from "@elysiajs/openapi"; const [host, port] = getBindingInfo(); logStartup(host, port); @@ -35,7 +37,25 @@ const app = new Elysia({ }) .use(onAfterHandler) .use(cors()) - .use(openapi()) + .use( + openapi({ + documentation: { + info: { + title: "CVSA API Docs", + version: pkg.version + } + }, + mapJsonSchema: { + zod: z.toJSONSchema + }, + references: fromTypes(), + scalar: { + theme: "kepler", + hideClientButton: true, + hideDarkModeToggle: true + } + }) + ) .use(rootHandler) .use(pingHandler) .use(authHandler) @@ -47,13 +67,29 @@ const app = new Elysia({ .use(addSongHandler) .use(deleteSongHandler) .use(songEtaHandler) - .get("/song/:id", ({ redirect, params }) => { - console.log(`/song/${params.id}/info`); - return redirect(`/song/${params.id}/info`, 302); - }) - .get("/video/:id", ({ redirect, params }) => { - return redirect(`/video/${params.id}/info`, 302); - }) + .get( + "/song/:id", + ({ redirect, params }) => { + console.log(`/song/${params.id}/info`); + return redirect(`/song/${params.id}/info`, 302); + }, + { + detail: { + hide: true + } + } + ) + .get( + "/video/:id", + ({ redirect, params }) => { + return redirect(`/video/${params.id}/info`, 302); + }, + { + detail: { + hide: true + } + } + ) .listen(15412); export const VERSION = pkg.version;