feat: 实现后端基础架构与 VOD 核心 API(Express + SQLite + JWT)
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
|
||||
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
jwtSecret: process.env.JWT_SECRET || 'vod-manager-dev-secret-change-me',
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||
encryptionKey: process.env.APP_ENCRYPTION_KEY || '',
|
||||
defaultAdminUsername: process.env.DEFAULT_ADMIN_USERNAME || 'admin',
|
||||
defaultAdminPassword: process.env.DEFAULT_ADMIN_PASSWORD || 'admin',
|
||||
dbPath: process.env.DB_PATH || path.resolve(process.cwd(), 'data', 'vod-manager.db'),
|
||||
vodEndpointTemplate: process.env.VOD_ENDPOINT_TEMPLATE || 'vod.{region}.aliyuncs.com',
|
||||
};
|
||||
|
||||
export function validateConfig(): void {
|
||||
if (config.nodeEnv === 'production' && config.jwtSecret === 'vod-manager-dev-secret-change-me') {
|
||||
throw new Error('JWT_SECRET must be set in production');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import sqlite3 from 'sqlite3';
|
||||
import { open, Database } from 'sqlite';
|
||||
import { config } from './config';
|
||||
|
||||
let db: Database<sqlite3.Database, sqlite3.Statement> | null = null;
|
||||
|
||||
export async function getDb(): Promise<Database<sqlite3.Database, sqlite3.Statement>> {
|
||||
if (db) return db;
|
||||
|
||||
const dbDir = path.dirname(config.dbPath);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
db = await open({
|
||||
filename: config.dbPath,
|
||||
driver: sqlite3.Database,
|
||||
});
|
||||
|
||||
await initSchema();
|
||||
return db;
|
||||
}
|
||||
|
||||
async function initSchema(): Promise<void> {
|
||||
const database = await getDb();
|
||||
|
||||
await database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'admin',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
access_key_id TEXT NOT NULL,
|
||||
access_key_secret TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
endpoint TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_accounts_active ON accounts(is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS operation_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
username TEXT,
|
||||
action TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_ids TEXT,
|
||||
details TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_created ON operation_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_action ON operation_logs(action);
|
||||
`);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import pino from 'pino';
|
||||
import { config, validateConfig } from './config';
|
||||
import { getDb } from './db';
|
||||
import { ensureDefaultAdmin } from './services/users';
|
||||
import authRoutes from './routes/auth';
|
||||
import configRoutes from './routes/config';
|
||||
import videoRoutes from './routes/videos';
|
||||
import categoryRoutes from './routes/categories';
|
||||
import logRoutes from './routes/logs';
|
||||
|
||||
const logger = pino({
|
||||
transport: config.nodeEnv === 'development' ? { target: 'pino-pretty' } : undefined,
|
||||
});
|
||||
|
||||
async function main() {
|
||||
validateConfig();
|
||||
await getDb();
|
||||
await ensureDefaultAdmin();
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/accounts', configRoutes);
|
||||
app.use('/api/videos', videoRoutes);
|
||||
app.use('/api/categories', categoryRoutes);
|
||||
app.use('/api/logs', logRoutes);
|
||||
|
||||
// Serve static frontend in production
|
||||
if (config.nodeEnv === 'production') {
|
||||
const staticPath = path.resolve(__dirname, '../../web/dist');
|
||||
app.use(express.static(staticPath));
|
||||
app.get('*', (_req: Request, res: Response) => {
|
||||
res.sendFile(path.join(staticPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.use((err: Error, _req: Request, res: Response, _next: express.NextFunction) => {
|
||||
logger.error(err);
|
||||
res.status(500).json({ code: 500, message: err.message || 'Internal server error' });
|
||||
});
|
||||
|
||||
app.listen(config.port, () => {
|
||||
logger.info(`Server listening on port ${config.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
logger.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config';
|
||||
import { JwtPayload } from '../types';
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: JwtPayload;
|
||||
}
|
||||
|
||||
export function authMiddleware(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({ code: 401, message: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwtSecret) as JwtPayload;
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(401).json({ code: 401, message: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdmin(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
|
||||
if (!req.user || req.user.role !== 'admin') {
|
||||
res.status(403).json({ code: 403, message: 'Forbidden: admin required' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireWrite(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
|
||||
if (!req.user || req.user.role === 'readonly') {
|
||||
res.status(403).json({ code: 403, message: 'Forbidden: read-only user' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Router } from 'express';
|
||||
import jwt, { SignOptions } from 'jsonwebtoken';
|
||||
import { config } from '../config';
|
||||
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth';
|
||||
import { asyncHandler } from '../utils/asyncHandler';
|
||||
import { findUserByUsername, verifyPassword } from '../services/users';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/login', asyncHandler(async (req, res) => {
|
||||
const { username, password } = req.body;
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ code: 400, message: 'Username and password are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await findUserByUsername(username);
|
||||
if (!user || !(await verifyPassword(user, password))) {
|
||||
res.status(401).json({ code: 401, message: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const signOptions: SignOptions = { expiresIn: config.jwtExpiresIn as SignOptions['expiresIn'] };
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, username: user.username, role: user.role },
|
||||
config.jwtSecret,
|
||||
signOptions
|
||||
);
|
||||
|
||||
res.json({
|
||||
code: 200,
|
||||
data: {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
router.get('/me', authMiddleware, (req: AuthenticatedRequest, res) => {
|
||||
res.json({
|
||||
code: 200,
|
||||
data: req.user,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { asyncHandler } from '../utils/asyncHandler';
|
||||
import { getActiveAccountAndClient } from '../utils/account';
|
||||
import { getCategories } from '../services/vod';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
authMiddleware,
|
||||
asyncHandler(async (_req, res) => {
|
||||
const { client } = await getActiveAccountAndClient();
|
||||
const categories = await getCategories(client);
|
||||
res.json({ code: 200, data: categories });
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
listAccounts,
|
||||
createAccount,
|
||||
updateAccount,
|
||||
deleteAccount,
|
||||
switchActiveAccount,
|
||||
} from '../services/config';
|
||||
import { authMiddleware, requireAdmin } from '../middleware/auth';
|
||||
import { asyncHandler } from '../utils/asyncHandler';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const accountSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
accessKeyId: z.string().min(1),
|
||||
accessKeySecret: z.string().min(1),
|
||||
region: z.string().min(1),
|
||||
endpoint: z.string().optional(),
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
authMiddleware,
|
||||
asyncHandler(async (_req, res) => {
|
||||
const accounts = await listAccounts();
|
||||
res.json({ code: 200, data: accounts });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
authMiddleware,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req, res) => {
|
||||
const parsed = accountSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ code: 400, message: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const account = await createAccount(parsed.data);
|
||||
res.json({
|
||||
code: 200,
|
||||
data: {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
accessKeyId: account.accessKeyId,
|
||||
region: account.region,
|
||||
endpoint: account.endpoint,
|
||||
isActive: account.isActive,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id',
|
||||
authMiddleware,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req, res) => {
|
||||
const parsed = accountSchema.partial().safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ code: 400, message: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const account = await updateAccount(req.params.id, parsed.data);
|
||||
res.json({
|
||||
code: 200,
|
||||
data: {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
accessKeyId: account.accessKeyId,
|
||||
region: account.region,
|
||||
endpoint: account.endpoint,
|
||||
isActive: account.isActive,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
authMiddleware,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req, res) => {
|
||||
await deleteAccount(req.params.id);
|
||||
res.json({ code: 200, data: null });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/switch',
|
||||
authMiddleware,
|
||||
requireAdmin,
|
||||
asyncHandler(async (req, res) => {
|
||||
const account = await switchActiveAccount(req.params.id);
|
||||
res.json({
|
||||
code: 200,
|
||||
data: {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
isActive: account.isActive,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { asyncHandler } from '../utils/asyncHandler';
|
||||
import { listLogs } from '../services/logs';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
authMiddleware,
|
||||
asyncHandler(async (req, res) => {
|
||||
const schema = z.object({
|
||||
pageNo: z.coerce.number().optional().default(1),
|
||||
pageSize: z.coerce.number().max(100).optional().default(20),
|
||||
action: z.string().optional(),
|
||||
});
|
||||
const parsed = schema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ code: 400, message: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await listLogs(parsed.data);
|
||||
res.json({ code: 200, data: result });
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { authMiddleware, AuthenticatedRequest, requireWrite } from '../middleware/auth';
|
||||
import { asyncHandler } from '../utils/asyncHandler';
|
||||
import { getActiveAccountAndClient } from '../utils/account';
|
||||
import {
|
||||
deleteVideos,
|
||||
getPlayInfo,
|
||||
getVideoInfo,
|
||||
searchMedia,
|
||||
updateVideoInfo,
|
||||
} from '../services/vod';
|
||||
import { createLog } from '../services/logs';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const searchSchema = z.object({
|
||||
keyword: z.string().optional(),
|
||||
cateId: z.coerce.number().optional(),
|
||||
status: z.string().optional(),
|
||||
pageNo: z.coerce.number().optional().default(1),
|
||||
pageSize: z.coerce.number().max(100).optional().default(20),
|
||||
startTime: z.string().optional(),
|
||||
endTime: z.string().optional(),
|
||||
sortBy: z.string().optional(),
|
||||
sortOrder: z.enum(['Asc', 'Desc']).optional().default('Desc'),
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/',
|
||||
authMiddleware,
|
||||
asyncHandler(async (req, res) => {
|
||||
const parsed = searchSchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ code: 400, message: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const { client } = await getActiveAccountAndClient();
|
||||
const result = await searchMedia(client, parsed.data);
|
||||
res.json({ code: 200, data: result });
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id',
|
||||
authMiddleware,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { client } = await getActiveAccountAndClient();
|
||||
const video = await getVideoInfo(client, req.params.id);
|
||||
res.json({ code: 200, data: video });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/play-info',
|
||||
authMiddleware,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { definition } = req.body || {};
|
||||
const { client } = await getActiveAccountAndClient();
|
||||
const playInfo = await getPlayInfo(client, req.params.id, definition);
|
||||
res.json({ code: 200, data: playInfo });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/batch-delete',
|
||||
authMiddleware,
|
||||
requireWrite,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const schema = z.object({ videoIds: z.array(z.string()).min(1).max(100) });
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ code: 400, message: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const { client } = await getActiveAccountAndClient();
|
||||
await deleteVideos(client, parsed.data.videoIds);
|
||||
|
||||
await createLog({
|
||||
userId: req.user?.userId,
|
||||
username: req.user?.username,
|
||||
action: 'BATCH_DELETE',
|
||||
targetType: 'video',
|
||||
targetIds: parsed.data.videoIds,
|
||||
details: { count: parsed.data.videoIds.length },
|
||||
});
|
||||
|
||||
res.json({ code: 200, data: { deleted: parsed.data.videoIds.length } });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/batch-download',
|
||||
authMiddleware,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const schema = z.object({
|
||||
videoIds: z.array(z.string()).min(1).max(100),
|
||||
definition: z.string().optional(),
|
||||
});
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ code: 400, message: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const { client } = await getActiveAccountAndClient();
|
||||
const results: {
|
||||
videoId: string;
|
||||
title?: string;
|
||||
urls: { definition: string; url: string }[];
|
||||
}[] = [];
|
||||
|
||||
for (const videoId of parsed.data.videoIds) {
|
||||
try {
|
||||
const [video, playInfo] = await Promise.all([
|
||||
getVideoInfo(client, videoId),
|
||||
getPlayInfo(client, videoId, parsed.data.definition),
|
||||
]);
|
||||
|
||||
results.push({
|
||||
videoId,
|
||||
title: video.title,
|
||||
urls: playInfo.map((info) => ({
|
||||
definition: info.definition,
|
||||
url: info.playURL,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
videoId,
|
||||
title: undefined,
|
||||
urls: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await createLog({
|
||||
userId: req.user?.userId,
|
||||
username: req.user?.username,
|
||||
action: 'BATCH_DOWNLOAD',
|
||||
targetType: 'video',
|
||||
targetIds: parsed.data.videoIds,
|
||||
details: { count: parsed.data.videoIds.length, definition: parsed.data.definition },
|
||||
});
|
||||
|
||||
res.json({ code: 200, data: results });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/batch-update',
|
||||
authMiddleware,
|
||||
requireWrite,
|
||||
asyncHandler(async (req: AuthenticatedRequest, res) => {
|
||||
const schema = z.object({
|
||||
videoIds: z.array(z.string()).min(1).max(100),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
cateId: z.number().optional(),
|
||||
});
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ code: 400, message: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const { videoIds, ...updates } = parsed.data;
|
||||
const { client } = await getActiveAccountAndClient();
|
||||
|
||||
for (const videoId of videoIds) {
|
||||
await updateVideoInfo(client, videoId, updates);
|
||||
}
|
||||
|
||||
await createLog({
|
||||
userId: req.user?.userId,
|
||||
username: req.user?.username,
|
||||
action: 'BATCH_UPDATE',
|
||||
targetType: 'video',
|
||||
targetIds: videoIds,
|
||||
details: { updates },
|
||||
});
|
||||
|
||||
res.json({ code: 200, data: { updated: videoIds.length } });
|
||||
})
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,114 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDb } from '../db';
|
||||
import { Account, AccountInput } from '../types';
|
||||
import { encrypt } from '../utils/crypto';
|
||||
|
||||
const ACCOUNT_COLUMNS =
|
||||
'id, name, access_key_id as accessKeyId, access_key_secret as accessKeySecret, region, endpoint, is_active as isActive, created_at as createdAt, updated_at as updatedAt';
|
||||
|
||||
export async function listAccounts(): Promise<Omit<Account, 'accessKeySecret'>[]> {
|
||||
const db = await getDb();
|
||||
const rows = await db.all<Account[]>(`SELECT ${ACCOUNT_COLUMNS} FROM accounts ORDER BY created_at DESC`);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
accessKeyId: row.accessKeyId,
|
||||
accessKeySecret: '',
|
||||
region: row.region,
|
||||
endpoint: row.endpoint,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAccountById(id: string): Promise<Account | undefined> {
|
||||
const db = await getDb();
|
||||
return db.get<Account>(`SELECT ${ACCOUNT_COLUMNS} FROM accounts WHERE id = ?`, id);
|
||||
}
|
||||
|
||||
export async function getActiveAccount(): Promise<Account | undefined> {
|
||||
const db = await getDb();
|
||||
return db.get<Account>(`SELECT ${ACCOUNT_COLUMNS} FROM accounts WHERE is_active = 1 LIMIT 1`);
|
||||
}
|
||||
|
||||
export async function createAccount(input: AccountInput): Promise<Account> {
|
||||
const db = await getDb();
|
||||
const id = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await db.run(
|
||||
`INSERT INTO accounts (id, name, access_key_id, access_key_secret, region, endpoint, is_active, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
input.name,
|
||||
input.accessKeyId,
|
||||
encrypt(input.accessKeySecret),
|
||||
input.region,
|
||||
input.endpoint || null,
|
||||
0,
|
||||
now,
|
||||
now,
|
||||
]
|
||||
);
|
||||
|
||||
const account = await getAccountById(id);
|
||||
if (!account) throw new Error('Failed to create account');
|
||||
return account;
|
||||
}
|
||||
|
||||
export async function updateAccount(id: string, input: Partial<AccountInput>): Promise<Account> {
|
||||
const db = await getDb();
|
||||
const account = await getAccountById(id);
|
||||
if (!account) throw new Error('Account not found');
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (input.name !== undefined) {
|
||||
updates.push('name = ?');
|
||||
values.push(input.name);
|
||||
}
|
||||
if (input.accessKeyId !== undefined) {
|
||||
updates.push('access_key_id = ?');
|
||||
values.push(input.accessKeyId);
|
||||
}
|
||||
if (input.accessKeySecret !== undefined) {
|
||||
updates.push('access_key_secret = ?');
|
||||
values.push(encrypt(input.accessKeySecret));
|
||||
}
|
||||
if (input.region !== undefined) {
|
||||
updates.push('region = ?');
|
||||
values.push(input.region);
|
||||
}
|
||||
if (input.endpoint !== undefined) {
|
||||
updates.push('endpoint = ?');
|
||||
values.push(input.endpoint || null);
|
||||
}
|
||||
|
||||
updates.push('updated_at = ?');
|
||||
values.push(new Date().toISOString());
|
||||
values.push(id);
|
||||
|
||||
await db.run(`UPDATE accounts SET ${updates.join(', ')} WHERE id = ?`, values);
|
||||
|
||||
const updated = await getAccountById(id);
|
||||
if (!updated) throw new Error('Failed to update account');
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteAccount(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.run('DELETE FROM accounts WHERE id = ?', id);
|
||||
}
|
||||
|
||||
export async function switchActiveAccount(id: string): Promise<Account> {
|
||||
const db = await getDb();
|
||||
await db.run('UPDATE accounts SET is_active = 0');
|
||||
await db.run('UPDATE accounts SET is_active = 1 WHERE id = ?', id);
|
||||
|
||||
const account = await getAccountById(id);
|
||||
if (!account) throw new Error('Account not found');
|
||||
return account;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDb } from '../db';
|
||||
import { OperationLog } from '../types';
|
||||
|
||||
export async function createLog(params: {
|
||||
userId?: string;
|
||||
username?: string;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetIds?: string[];
|
||||
details?: Record<string, unknown>;
|
||||
}): Promise<OperationLog> {
|
||||
const db = await getDb();
|
||||
const id = uuidv4();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await db.run(
|
||||
`INSERT INTO operation_logs (id, user_id, username, action, target_type, target_ids, details, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
params.userId || null,
|
||||
params.username || null,
|
||||
params.action,
|
||||
params.targetType,
|
||||
params.targetIds ? params.targetIds.join(',') : null,
|
||||
params.details ? JSON.stringify(params.details) : null,
|
||||
now,
|
||||
]
|
||||
);
|
||||
|
||||
const log = await db.get<OperationLog>(
|
||||
'SELECT id, user_id as userId, username, action, target_type as targetType, target_ids as targetIds, details, created_at as createdAt FROM operation_logs WHERE id = ?',
|
||||
id
|
||||
);
|
||||
if (!log) throw new Error('Failed to create log');
|
||||
return log;
|
||||
}
|
||||
|
||||
export async function listLogs(options: {
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
action?: string;
|
||||
}): Promise<{ total: number; logs: OperationLog[]; pageNo: number; pageSize: number }> {
|
||||
const db = await getDb();
|
||||
const pageNo = options.pageNo || 1;
|
||||
const pageSize = Math.min(options.pageSize || 20, 100);
|
||||
|
||||
let whereClause = '';
|
||||
const params: unknown[] = [];
|
||||
if (options.action) {
|
||||
whereClause = 'WHERE action = ?';
|
||||
params.push(options.action);
|
||||
}
|
||||
|
||||
const countRow = await db.get<{ total: number }>(
|
||||
`SELECT COUNT(*) as total FROM operation_logs ${whereClause}`,
|
||||
params
|
||||
);
|
||||
|
||||
const logs = await db.all<OperationLog[]>(
|
||||
`SELECT id, user_id as userId, username, action, target_type as targetType, target_ids as targetIds, details, created_at as createdAt FROM operation_logs ${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
||||
[...params, pageSize, (pageNo - 1) * pageSize]
|
||||
);
|
||||
|
||||
return {
|
||||
total: countRow?.total || 0,
|
||||
pageNo,
|
||||
pageSize,
|
||||
logs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { getDb } from '../db';
|
||||
import { User } from '../types';
|
||||
|
||||
export async function findUserByUsername(username: string): Promise<User | undefined> {
|
||||
const db = await getDb();
|
||||
return db.get<User>(
|
||||
'SELECT id, username, password_hash as passwordHash, role, created_at as createdAt FROM users WHERE username = ?',
|
||||
username
|
||||
);
|
||||
}
|
||||
|
||||
export async function createUser(params: {
|
||||
username: string;
|
||||
password: string;
|
||||
role?: 'admin' | 'readonly';
|
||||
}): Promise<User> {
|
||||
const db = await getDb();
|
||||
const id = uuidv4();
|
||||
const hash = await bcrypt.hash(params.password, 10);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await db.run(
|
||||
'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
[id, params.username, hash, params.role || 'admin', now]
|
||||
);
|
||||
|
||||
const user = await findUserByUsername(params.username);
|
||||
if (!user) throw new Error('Failed to create user');
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function verifyPassword(user: User, password: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, user.passwordHash);
|
||||
}
|
||||
|
||||
export async function ensureDefaultAdmin(): Promise<void> {
|
||||
const db = await getDb();
|
||||
const count = await db.get<{ total: number }>('SELECT COUNT(*) as total FROM users');
|
||||
if ((count?.total || 0) === 0) {
|
||||
await createUser({
|
||||
username: process.env.DEFAULT_ADMIN_USERNAME || 'admin',
|
||||
password: process.env.DEFAULT_ADMIN_PASSWORD || 'admin',
|
||||
role: 'admin',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import RPCClient from '@alicloud/pop-core';
|
||||
import { Account, PlayInfo, VideoItem } from '../types';
|
||||
import { decrypt } from '../utils/crypto';
|
||||
|
||||
export interface SearchParams {
|
||||
keyword?: string;
|
||||
cateId?: number;
|
||||
status?: string;
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'Asc' | 'Desc';
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
total: number;
|
||||
pageNo: number;
|
||||
pageSize: number;
|
||||
videos: VideoItem[];
|
||||
}
|
||||
|
||||
export function createVodClient(account: Account): RPCClient {
|
||||
const endpoint = account.endpoint || `vod.${account.region}.aliyuncs.com`;
|
||||
return new RPCClient({
|
||||
accessKeyId: account.accessKeyId,
|
||||
accessKeySecret: decrypt(account.accessKeySecret),
|
||||
endpoint,
|
||||
apiVersion: '2017-03-21',
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchMedia(client: RPCClient, params: SearchParams): Promise<SearchResult> {
|
||||
const requestParams: Record<string, unknown> = {
|
||||
PageNo: params.pageNo || 1,
|
||||
PageSize: Math.min(params.pageSize || 20, 100),
|
||||
};
|
||||
|
||||
const searchParams: string[] = [];
|
||||
if (params.keyword) {
|
||||
searchParams.push(`Title='${escapeSearch(params.keyword)}'`);
|
||||
}
|
||||
if (params.cateId) {
|
||||
searchParams.push(`CateId=${params.cateId}`);
|
||||
}
|
||||
if (params.status) {
|
||||
searchParams.push(`Status='${params.status}'`);
|
||||
}
|
||||
if (params.startTime && params.endTime) {
|
||||
searchParams.push(`CreationTime>${params.startTime}`);
|
||||
searchParams.push(`CreationTime<${params.endTime}`);
|
||||
}
|
||||
|
||||
if (searchParams.length > 0) {
|
||||
requestParams.SearchType = 'video';
|
||||
requestParams.Fields = 'Title,CoverURL,CreationTime,Status,Duration,Size,CateId,CateName,Tags';
|
||||
requestParams.Match = searchParams.join(' and ');
|
||||
requestParams.SortBy = params.sortBy || 'CreationTime';
|
||||
requestParams.SortOrder = params.sortOrder || 'Desc';
|
||||
}
|
||||
|
||||
const action = 'SearchMedia';
|
||||
const response = (await client.request(action, requestParams, { method: 'POST' })) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const mediaListRaw = (response.MediaList as Record<string, unknown>[]) || [];
|
||||
const mediaList = mediaListRaw.map((item) => ({
|
||||
videoId: (item.VideoId || item.MediaId) as string,
|
||||
title: item.Title as string,
|
||||
description: item.Description as string,
|
||||
duration: item.Duration as number,
|
||||
size: item.Size as number,
|
||||
coverURL: item.CoverURL as string,
|
||||
status: item.Status as string,
|
||||
cateId: item.CateId as number,
|
||||
cateName: item.CateName as string,
|
||||
tags: item.Tags as string,
|
||||
creationTime: item.CreationTime as string,
|
||||
modificationTime: item.ModificationTime as string,
|
||||
}));
|
||||
|
||||
return {
|
||||
total: (response.Total as number) || 0,
|
||||
pageNo: params.pageNo || 1,
|
||||
pageSize: params.pageSize || 20,
|
||||
videos: mediaList,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getVideoInfo(client: RPCClient, videoId: string): Promise<VideoItem> {
|
||||
const response = (await client.request(
|
||||
'GetVideoInfo',
|
||||
{ VideoId: videoId },
|
||||
{ method: 'POST' }
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
const video = response.Video as Record<string, unknown>;
|
||||
return {
|
||||
videoId: video.VideoId as string,
|
||||
title: video.Title as string,
|
||||
description: video.Description as string,
|
||||
duration: video.Duration as number,
|
||||
size: video.Size as number,
|
||||
coverURL: video.CoverURL as string,
|
||||
status: video.Status as string,
|
||||
cateId: video.CateId as number,
|
||||
cateName: video.CateName as string,
|
||||
tags: video.Tags as string,
|
||||
creationTime: video.CreationTime as string,
|
||||
modificationTime: video.ModificationTime as string,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPlayInfo(
|
||||
client: RPCClient,
|
||||
videoId: string,
|
||||
definition?: string
|
||||
): Promise<PlayInfo[]> {
|
||||
const params: Record<string, unknown> = { VideoId: videoId };
|
||||
if (definition) {
|
||||
params.Definition = definition;
|
||||
}
|
||||
|
||||
const response = (await client.request('GetPlayInfo', params, {
|
||||
method: 'POST',
|
||||
})) as Record<string, unknown>;
|
||||
const playInfoList = (response.PlayInfoList as Record<string, unknown>) || {};
|
||||
const list = (playInfoList.PlayInfo as Record<string, unknown>[]) || [];
|
||||
|
||||
return list.map((item) => ({
|
||||
playURL: item.PlayURL as string,
|
||||
definition: item.Definition as string,
|
||||
duration: item.Duration as string,
|
||||
size: item.Size as number,
|
||||
format: item.Format as string,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function deleteVideos(client: RPCClient, videoIds: string[]): Promise<void> {
|
||||
await client.request(
|
||||
'DeleteVideo',
|
||||
{ VideoIds: videoIds.join(',') },
|
||||
{ method: 'POST' }
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateVideoInfo(
|
||||
client: RPCClient,
|
||||
videoId: string,
|
||||
updates: { title?: string; description?: string; tags?: string; cateId?: number }
|
||||
): Promise<void> {
|
||||
const params: Record<string, unknown> = { VideoId: videoId };
|
||||
if (updates.title !== undefined) params.Title = updates.title;
|
||||
if (updates.description !== undefined) params.Description = updates.description;
|
||||
if (updates.tags !== undefined) params.Tags = updates.tags;
|
||||
if (updates.cateId !== undefined) params.CateId = updates.cateId;
|
||||
|
||||
await client.request('UpdateVideoInfo', params, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function getCategories(client: RPCClient): Promise<Record<string, unknown>[]> {
|
||||
const response = (await client.request('GetCategories', { PageSize: 100 }, {
|
||||
method: 'POST',
|
||||
})) as Record<string, unknown>;
|
||||
const subCategory = (response.SubCategory as Record<string, unknown>) || {};
|
||||
return (subCategory.SubCategory as Record<string, unknown>[]) || [];
|
||||
}
|
||||
|
||||
function escapeSearch(value: string): string {
|
||||
return value.replace(/'/g, "\\'");
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
passwordHash: string;
|
||||
role: 'admin' | 'readonly';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Account {
|
||||
id: string;
|
||||
name: string;
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
isActive: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AccountInput {
|
||||
name: string;
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
region: string;
|
||||
endpoint?: string;
|
||||
}
|
||||
|
||||
export interface OperationLog {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
username: string | null;
|
||||
action: string;
|
||||
targetType: string;
|
||||
targetIds: string;
|
||||
details: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface VideoItem {
|
||||
videoId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
size?: number;
|
||||
coverURL?: string;
|
||||
status?: string;
|
||||
cateId?: number;
|
||||
cateName?: string;
|
||||
tags?: string;
|
||||
creationTime?: string;
|
||||
modificationTime?: string;
|
||||
}
|
||||
|
||||
export interface PlayInfo {
|
||||
playURL: string;
|
||||
definition: string;
|
||||
duration: string;
|
||||
size: number;
|
||||
format: string;
|
||||
}
|
||||
Vendored
+24
@@ -0,0 +1,24 @@
|
||||
declare module '@alicloud/pop-core' {
|
||||
interface RPCClientOptions {
|
||||
accessKeyId: string;
|
||||
accessKeySecret: string;
|
||||
endpoint: string;
|
||||
apiVersion: string;
|
||||
}
|
||||
|
||||
interface RequestOptions {
|
||||
method?: 'GET' | 'POST';
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
class RPCClient {
|
||||
constructor(options: RPCClientOptions);
|
||||
request<T = Record<string, unknown>>(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
options?: RequestOptions
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
export default RPCClient;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Account } from '../types';
|
||||
import { createVodClient } from '../services/vod';
|
||||
import { getActiveAccount } from '../services/config';
|
||||
import RPCClient from '@alicloud/pop-core';
|
||||
|
||||
export async function getActiveAccountAndClient(): Promise<{
|
||||
account: Account;
|
||||
client: RPCClient;
|
||||
}> {
|
||||
const account = await getActiveAccount();
|
||||
if (!account) {
|
||||
throw new Error('No active account configured');
|
||||
}
|
||||
|
||||
const client = createVodClient(account);
|
||||
return { account, client };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
|
||||
export function asyncHandler(
|
||||
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>
|
||||
): RequestHandler {
|
||||
return (req, res, next) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { encrypt, decrypt } from './crypto';
|
||||
|
||||
describe('crypto utils', () => {
|
||||
it('should encrypt and decrypt text when key is set', () => {
|
||||
process.env.APP_ENCRYPTION_KEY = 'my-32-char-encryption-key-12345';
|
||||
const text = 'super-secret-access-key';
|
||||
const encrypted = encrypt(text);
|
||||
expect(encrypted).not.toBe(text);
|
||||
expect(encrypted).toContain('enc:');
|
||||
|
||||
const decrypted = decrypt(encrypted);
|
||||
expect(decrypted).toBe(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import crypto from 'crypto';
|
||||
import { config } from '../config';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer | null {
|
||||
if (!config.encryptionKey) return null;
|
||||
return crypto.scryptSync(config.encryptionKey, 'vod-manager-salt', 32);
|
||||
}
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const key = getKey();
|
||||
if (!key) return `plain:${text}`;
|
||||
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return `enc:${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
export function decrypt(text: string): string {
|
||||
if (text.startsWith('plain:')) return text.slice(6);
|
||||
if (!text.startsWith('enc:')) return text;
|
||||
|
||||
const key = getKey();
|
||||
if (!key) {
|
||||
throw new Error('APP_ENCRYPTION_KEY is required to decrypt secrets');
|
||||
}
|
||||
|
||||
const parts = text.split(':');
|
||||
if (parts.length !== 4) throw new Error('Invalid encrypted text format');
|
||||
|
||||
const iv = Buffer.from(parts[1], 'hex');
|
||||
const authTag = Buffer.from(parts[2], 'hex');
|
||||
const encrypted = Buffer.from(parts[3], 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString('utf8');
|
||||
}
|
||||
Reference in New Issue
Block a user