add: OpenAPI description for backend
This commit is contained in:
parent
a95b9f76be
commit
5560f5d705
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@ -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."
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user