├── .gitignore ├── frontend ├── src │ ├── main.js │ └── App.vue ├── vite.config.js ├── index.html ├── package.json └── yarn.lock ├── .prettierrc ├── example.env ├── tsconfig.json ├── jest.config.js ├── docker-compose.yml ├── .dockerignore ├── src ├── utils │ ├── general.ts │ ├── validation.ts │ ├── auth.ts │ ├── nginx.ts │ ├── __tests__ │ │ └── apikeys.test.ts │ ├── requestTracker.ts │ └── apikeys.ts ├── static │ └── nginx-server-template.conf ├── index.ts └── controllers │ ├── auth.ts │ ├── analytics.ts │ ├── apikeys.ts │ ├── nginx.ts │ ├── __tests__ │ └── analytics.test.ts │ └── llm.ts ├── .github └── workflows │ ├── build.yaml │ └── docker-build-push-release.yml ├── nginx.conf ├── Dockerfile ├── package.json ├── CLAUDE.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | .env 4 | dist -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import App from './App.vue' 3 | 4 | createApp(App).mount('#app') 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "none", 6 | "semi": false 7 | } 8 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | PORT=8080 2 | TARGET_URLS=http://localhost:1234/v1,http://192.168.1.100:1234|api-key-here 3 | JWT_SECRET=your-jwt-secret-key-here 4 | AUTH_USERNAME=admin 5 | AUTH_PASSWORD=secure_password -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | export default defineConfig({ 5 | plugins: [vue()], 6 | build: { 7 | outDir: 'dist', 8 | emptyOutDir: true 9 | } 10 | }) 11 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | API Key Management - LLM Proxy 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules"] 14 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | roots: ['/src'], 5 | testMatch: ['**/__tests__/**/*.ts', '**/*.test.ts'], 6 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 7 | collectCoverageFrom: [ 8 | 'src/**/*.ts', 9 | '!src/**/*.d.ts', 10 | '!src/index.ts' 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "llm-proxy-frontend", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "vue": "^3.4.0" 13 | }, 14 | "devDependencies": { 15 | "@vitejs/plugin-vue": "^5.0.0", 16 | "vite": "^5.0.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.6' 2 | 3 | services: 4 | llmp: 5 | image: ghcr.io/j4ys0n/llm-proxy:1.5.4 6 | container_name: llmp 7 | hostname: llmp 8 | restart: unless-stopped 9 | ports: 10 | - 8080:8080 11 | - 443:443 12 | volumes: 13 | - .env:/app/.env 14 | - ./data:/app/data 15 | - ./cloudflare_credentials:/opt/cloudflare/credentials 16 | - ./nginx:/etc/nginx/conf.d # nginx configs 17 | - ./certs:/etc/letsencrypt # tsl certificates 18 | logging: 19 | driver: 'json-file' 20 | options: 21 | max-size: 100m 22 | max-file: '2' -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | frontend/node_modules 4 | 5 | # Build output 6 | dist 7 | coverage 8 | 9 | # Logs 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Environment files 15 | .env 16 | .env.local 17 | .env.*.local 18 | 19 | # IDE 20 | .vscode 21 | .idea 22 | *.swp 23 | *.swo 24 | *~ 25 | 26 | # Git 27 | .git 28 | .gitignore 29 | .gitattributes 30 | 31 | # CI/CD 32 | .github 33 | 34 | # Documentation 35 | *.md 36 | !README.md 37 | 38 | # Test files 39 | *.test.ts 40 | *.spec.ts 41 | __tests__ 42 | jest.config.js 43 | 44 | # Development 45 | .editorconfig 46 | .prettierrc 47 | .eslintrc* 48 | -------------------------------------------------------------------------------- /src/utils/general.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | 3 | export function log(level: string, msg: string, error?: any) { 4 | console.log(`[${new Date().toISOString()}] [${level.toUpperCase()}] ${msg}`) 5 | if (error != null) { 6 | console.error(error) 7 | } 8 | } 9 | 10 | export function md5(data: Object | string): string { 11 | const stringValue = typeof data === 'string' ? data : JSON.stringify(data) 12 | return crypto.createHash('md5').update(stringValue).digest('hex') 13 | } 14 | 15 | export function sleep(ms: number) { 16 | return new Promise((r) => setTimeout(r, ms)) 17 | } 18 | 19 | export function extractDomainName(url: string) { 20 | return url.replace('https://', '').replace('http://', '').split('/')[0].replace(/\./g, '_') 21 | } -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | paths-ignore: 7 | - '**/*.md' 8 | jobs: 9 | test: 10 | name: Test 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out code 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '18' 20 | cache: 'yarn' 21 | 22 | - name: Install backend dependencies 23 | run: yarn install 24 | 25 | - name: Run tests 26 | run: yarn test 27 | 28 | build: 29 | name: Build 30 | runs-on: ubuntu-latest 31 | needs: test 32 | steps: 33 | - name: Check out code 34 | uses: actions/checkout@v4 35 | 36 | - name: Build docker image 37 | run: | 38 | docker build . 39 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | user www-data; 2 | worker_processes auto; 3 | pid /run/nginx.pid; 4 | include /etc/nginx/modules-enabled/*.conf; 5 | 6 | events { 7 | worker_connections 1024; 8 | } 9 | 10 | http { 11 | sendfile on; 12 | # tcp_nopush on; 13 | types_hash_max_size 2048; 14 | include /etc/nginx/mime.types; 15 | default_type application/octet-stream; 16 | 17 | ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3; 18 | ssl_prefer_server_ciphers on; 19 | 20 | keepalive_timeout 65; 21 | 22 | access_log /var/log/nginx/access.log; 23 | error_log /var/log/nginx/error.log; 24 | 25 | gzip on; 26 | 27 | include /etc/nginx/conf.d/*.conf; 28 | # server { 29 | # listen 80; 30 | # server_name localhost; 31 | 32 | # location / { 33 | # proxy_pass http://localhost:3000; 34 | # proxy_http_version 1.1; 35 | # proxy_set_header Upgrade $http_upgrade; 36 | # proxy_set_header Connection 'upgrade'; 37 | # proxy_set_header Host $host; 38 | # proxy_cache_bypass $http_upgrade; 39 | # } 40 | # } 41 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Node.js runtime as the base image 2 | FROM node:18.20.3-bullseye 3 | 4 | # Install Nginx 5 | RUN apt-get update && apt-get install -y nginx certbot python3-certbot-dns-cloudflare 6 | 7 | # Create directory for Nginx configuration files 8 | RUN mkdir -p /etc/nginx/conf.d/ 9 | 10 | # Create directory for Let's Encrypt certificates 11 | RUN mkdir -p /etc/letsencrypt/ 12 | 13 | # Make sure Nginx has the right permissions to run 14 | RUN chown -R www-data:www-data /var/lib/nginx 15 | 16 | # Create app directory 17 | WORKDIR /app 18 | 19 | # Bundle app source 20 | COPY . . 21 | 22 | # Install backend dependencies 23 | RUN yarn install 24 | 25 | # Install frontend dependencies and build frontend 26 | WORKDIR /app/frontend 27 | RUN yarn install && yarn build 28 | 29 | # Build backend 30 | WORKDIR /app 31 | RUN yarn build 32 | 33 | # Expose port 8080 & 443 34 | EXPOSE 8080 443 35 | 36 | # Remove the default Nginx configuration file 37 | RUN rm /etc/nginx/sites-enabled/default 38 | 39 | # Copy a custom Nginx configuration file 40 | COPY nginx.conf /etc/nginx/nginx.conf 41 | 42 | 43 | 44 | 45 | # Start Node.js app 46 | CMD ["node", "dist/index.js"] -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "llm-proxy", 3 | "version": "1.6.1", 4 | "description": "Manages Nginx for reverse proxy to multiple LLMs, with TLS & Bearer Auth tokens", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "start": "node dist/index.js", 8 | "build": "rm -rf dist && tsc && cp -r src/static dist/ && cp -r frontend/dist/* dist/static/", 9 | "dev": "ts-node-dev --respawn --transpile-only src/index.ts", 10 | "test": "jest" 11 | }, 12 | "keywords": [ 13 | "node", 14 | "nginx", 15 | "typescript", 16 | "reverse proxy", 17 | "llm", 18 | "openai", 19 | "certificate", 20 | "bearer auth", 21 | "tls", 22 | "ai" 23 | ], 24 | "author": "Jayson Jacobs", 25 | "license": "Apache-2.0", 26 | "dependencies": { 27 | "axios": "^1.7.2", 28 | "cookie-parser": "^1.4.7", 29 | "cors": "^2.8.5", 30 | "dotenv": "^10.0.0", 31 | "express": "^4.19.2", 32 | "fs-extra": "^11.2.0", 33 | "joi": "17.13.3", 34 | "jsonwebtoken": "^9.0.2" 35 | }, 36 | "devDependencies": { 37 | "@types/cookie-parser": "^1.4.10", 38 | "@types/cors": "^2.8.19", 39 | "@types/express": "^4.17.21", 40 | "@types/fs-extra": "^11.0.4", 41 | "@types/jest": "^29.5.0", 42 | "@types/jsonwebtoken": "^9.0.2", 43 | "@types/node": "^20.12.7", 44 | "jest": "^29.7.0", 45 | "ts-jest": "^29.1.0", 46 | "ts-node-dev": "^2.0.0", 47 | "typescript": "^5.4.5" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/static/nginx-server-template.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl; 3 | 4 | add_header 'Access-Control-Allow-Origin' '*'; 5 | add_header 'Add-Control-Allow-Methods' 'GET, POST'; 6 | 7 | server_name {{domainName}}; 8 | server_tokens off; 9 | 10 | real_ip_header X-Forwarded-For; 11 | 12 | client_max_body_size 50M; 13 | 14 | ssl_certificate /etc/letsencrypt/live/{{domainName}}/fullchain.pem; 15 | ssl_certificate_key /etc/letsencrypt/live/{{domainName}}/privkey.pem; 16 | # include /etc/letsencrypt/options-ssl-nginx.conf; 17 | # ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; 18 | 19 | location ~ ^/v1/(.*) { 20 | proxy_pass_header Authorization; 21 | proxy_pass http://localhost:8080; 22 | proxy_redirect off; 23 | proxy_set_header X-Real-IP $remote_addr; 24 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 25 | proxy_http_version 1.1; 26 | proxy_set_header Upgrade $http_upgrade; 27 | proxy_set_header Connection 'upgrade'; 28 | proxy_set_header Host $host; 29 | proxy_cache_bypass $http_upgrade; 30 | proxy_buffering off; 31 | client_max_body_size 0; 32 | proxy_read_timeout 36000s; 33 | } 34 | 35 | location ~ /(auth|nginx)/(.*) { 36 | proxy_pass_header Authorization; 37 | proxy_pass http://localhost:8080; 38 | proxy_redirect off; 39 | proxy_set_header X-Real-IP $remote_addr; 40 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 41 | proxy_http_version 1.1; 42 | proxy_set_header Upgrade $http_upgrade; 43 | proxy_set_header Connection 'upgrade'; 44 | proxy_set_header Host $host; 45 | proxy_cache_bypass $http_upgrade; 46 | proxy_buffering off; 47 | client_max_body_size 0; 48 | proxy_read_timeout 36000s; 49 | {{allowedIPs}} deny all; 50 | } 51 | 52 | location ~ (.*) { 53 | return 404; 54 | } 55 | } 56 | 57 | server { 58 | listen 443 ssl; 59 | 60 | server_name ${public_ip}; 61 | server_tokens off; 62 | return 444; # "Connection closed without response" 63 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import express from 'express' 3 | import path from 'path' 4 | import cookieParser from 'cookie-parser' 5 | import cors from 'cors' 6 | import { NginxController } from './controllers/nginx' 7 | import { LLMController } from './controllers/llm' 8 | import { ApiKeyController } from './controllers/apikeys' 9 | import { AnalyticsController } from './controllers/analytics' 10 | import { tokenMiddleware, apiKeyMiddleware } from './utils/auth' 11 | import { AuthController } from './controllers/auth' 12 | import { log } from './utils/general' 13 | import bodyParser from 'body-parser' 14 | 15 | dotenv.config() 16 | 17 | const app = express() 18 | const port = process.env.PORT || 8080 19 | const targetUrls = (process.env.TARGET_URLS || 'http://example.com').split(',').map((url) => url.trim()) 20 | 21 | // CORS configuration 22 | const corsOptions = { 23 | origin: process.env.CORS_ORIGIN || true, // In production, set specific origins 24 | credentials: true, // Allow cookies 25 | methods: ['GET', 'POST', 'DELETE', 'OPTIONS'], 26 | allowedHeaders: ['Content-Type', 'Authorization'] 27 | } 28 | app.use(cors(corsOptions)) 29 | log('info', `CORS enabled with origin: ${corsOptions.origin}`) 30 | 31 | // app.use(express.json()) 32 | const payloadLimit = process.env.PAYLOAD_LIMIT || '1mb' 33 | app.use(bodyParser.json({ limit: payloadLimit })) 34 | app.use(bodyParser.urlencoded({ extended: false, limit: payloadLimit })) 35 | app.use(cookieParser()) 36 | log('info', `Payload limit is: ${payloadLimit}`) 37 | 38 | // Serve static files 39 | app.use('/static', express.static(path.join(__dirname, 'static'))) 40 | log('info', 'Static file serving enabled at /static') 41 | 42 | // Express routes 43 | app.get('/', (req, res) => { 44 | res.send('LLM Proxy') 45 | }) 46 | 47 | app.get('/admin', (req, res) => { 48 | res.sendFile(path.join(__dirname, 'static', 'index.html')) 49 | }) 50 | 51 | app.get('/keys', (req, res) => { 52 | res.sendFile(path.join(__dirname, 'static', 'index.html')) 53 | }) 54 | 55 | const authController = new AuthController({ app }) 56 | authController.registerRoutes() 57 | 58 | const apiKeyController = new ApiKeyController({ app, requestHandlers: [ tokenMiddleware ] }) 59 | apiKeyController.registerRoutes() 60 | 61 | const analyticsController = new AnalyticsController({ app, requestHandlers: [ tokenMiddleware ] }) 62 | analyticsController.registerRoutes() 63 | 64 | const nginxController = new NginxController({ app, requestHandlers: [ tokenMiddleware ] }) 65 | nginxController.registerRoutes() 66 | nginxController.start() 67 | 68 | const llmController = new LLMController({ app, requestHandlers: [apiKeyMiddleware], targetUrls }) 69 | llmController.registerRoutes() 70 | 71 | // Start the server 72 | app.listen(port, () => { 73 | console.log(`Local server running at http://localhost:${port}`) 74 | }) 75 | 76 | -------------------------------------------------------------------------------- /src/controllers/auth.ts: -------------------------------------------------------------------------------- 1 | import { Express, Request, Response } from 'express' 2 | import jwt from 'jsonwebtoken' 3 | import { log } from '../utils/general' 4 | import { validate, loginSchema } from '../utils/validation' 5 | 6 | const jwtSecret = process.env.JWT_SECRET || 'your-jwt-secret' 7 | const authUsername = process.env.AUTH_USERNAME || 'admin' 8 | const authPassword = process.env.AUTH_PASSWORD || 'secure_password' 9 | 10 | export class AuthController { 11 | private app: Express 12 | 13 | constructor({ app }: { app: Express }) { 14 | this.app = app 15 | } 16 | 17 | public registerRoutes(): void { 18 | this.app.post('/auth/login', this.login.bind(this)) 19 | this.app.post('/auth/logout', this.logout.bind(this)) 20 | // Legacy endpoint for backwards compatibility 21 | this.app.post('/auth/token', this.getToken.bind(this)) 22 | log('info', 'AuthController initialized') 23 | } 24 | 25 | private login(req: Request, res: Response): void { 26 | const validation = validate<{ username: string; password: string }>(loginSchema, req.body) 27 | 28 | if (validation.error || !validation.value) { 29 | res.status(400).json({ error: validation.error || 'Validation failed' }) 30 | return 31 | } 32 | 33 | const { username, password } = validation.value 34 | 35 | if (username === authUsername && password === authPassword) { 36 | const token = jwt.sign({ username }, jwtSecret, { algorithm: 'HS256' }) 37 | 38 | // Set HttpOnly cookie 39 | res.cookie('auth_token', token, { 40 | httpOnly: true, 41 | secure: process.env.NODE_ENV === 'production', // Only HTTPS in production 42 | sameSite: 'strict', 43 | maxAge: 24 * 60 * 60 * 1000 // 24 hours 44 | }) 45 | 46 | log('info', `User logged in: ${username}`) 47 | res.json({ success: true, username }) 48 | } else { 49 | res.status(401).json({ error: 'Invalid credentials' }) 50 | } 51 | } 52 | 53 | private logout(req: Request, res: Response): void { 54 | res.clearCookie('auth_token') 55 | log('info', 'User logged out') 56 | res.json({ success: true }) 57 | } 58 | 59 | // Legacy endpoint - still returns token in JSON for backwards compatibility 60 | private getToken(req: Request, res: Response): void { 61 | const validation = validate<{ username: string; password: string }>(loginSchema, req.body) 62 | 63 | if (validation.error || !validation.value) { 64 | res.status(400).json({ error: validation.error || 'Validation failed' }) 65 | return 66 | } 67 | 68 | const { username, password } = validation.value 69 | 70 | if (username === authUsername && password === authPassword) { 71 | const token = jwt.sign({ username }, jwtSecret, { algorithm: 'HS256' }) 72 | log('info', `token generated for ${username} (legacy endpoint)`) 73 | res.json({ token }) 74 | } else { 75 | res.status(401).json({ error: 'Invalid credentials' }) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/controllers/analytics.ts: -------------------------------------------------------------------------------- 1 | import { Express, Request, Response, RequestHandler } from 'express' 2 | import { RequestTracker } from '../utils/requestTracker' 3 | import { log } from '../utils/general' 4 | import { validate, apiKeySchema, timestampSchema } from '../utils/validation' 5 | import Joi from 'joi' 6 | 7 | export class AnalyticsController { 8 | private app: Express 9 | private requestTracker: RequestTracker 10 | private requestHandlers: RequestHandler[] 11 | 12 | constructor({ app, requestHandlers }: { app: Express; requestHandlers: RequestHandler[] }) { 13 | this.app = app 14 | this.requestHandlers = requestHandlers 15 | this.requestTracker = new RequestTracker() 16 | } 17 | 18 | public registerRoutes(): void { 19 | this.app.get('/api/analytics/:keyId', ...this.requestHandlers, this.getAnalytics.bind(this)) 20 | log('info', 'AnalyticsController initialized') 21 | } 22 | 23 | private async getAnalytics(req: Request, res: Response): Promise { 24 | // Validate keyId 25 | const keyValidation = validate(apiKeySchema, req.params.keyId) 26 | if (keyValidation.error || !keyValidation.value) { 27 | res.status(400).json({ success: false, message: keyValidation.error || 'Invalid key ID' }) 28 | return 29 | } 30 | const validKeyId = keyValidation.value 31 | 32 | // Validate date parameters if provided 33 | let startDate: number | undefined 34 | let endDate: number | undefined 35 | 36 | if (req.query.startDate) { 37 | const startValidation = validate(timestampSchema, parseInt(req.query.startDate as string, 10)) 38 | if (startValidation.error || !startValidation.value) { 39 | res.status(400).json({ success: false, message: `Invalid start date: ${startValidation.error}` }) 40 | return 41 | } 42 | startDate = startValidation.value 43 | } 44 | 45 | if (req.query.endDate) { 46 | const endValidation = validate(timestampSchema, parseInt(req.query.endDate as string, 10)) 47 | if (endValidation.error || !endValidation.value) { 48 | res.status(400).json({ success: false, message: `Invalid end date: ${endValidation.error}` }) 49 | return 50 | } 51 | endDate = endValidation.value 52 | } 53 | 54 | // Validate date range if both provided 55 | if (startDate && endDate && startDate >= endDate) { 56 | res.status(400).json({ success: false, message: 'Start date must be before end date' }) 57 | return 58 | } 59 | 60 | try { 61 | let records 62 | 63 | if (startDate && endDate) { 64 | records = await this.requestTracker.getRecords(validKeyId, startDate, endDate) 65 | } else { 66 | // Default to last week 67 | records = await this.requestTracker.getLastWeekRecords(validKeyId) 68 | } 69 | 70 | res.json({ success: true, records }) 71 | } catch (error) { 72 | log('error', 'Failed to get analytics', error) 73 | res.status(500).json({ success: false, message: 'Failed to retrieve analytics' }) 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/validation.ts: -------------------------------------------------------------------------------- 1 | import Joi from 'joi' 2 | 3 | // Username validation - alphanumeric, underscore, hyphen, 3-50 chars 4 | export const usernameSchema = Joi.string() 5 | .alphanum() 6 | .min(3) 7 | .max(50) 8 | .required() 9 | .messages({ 10 | 'string.alphanum': 'Username must contain only alphanumeric characters', 11 | 'string.min': 'Username must be at least 3 characters long', 12 | 'string.max': 'Username must not exceed 50 characters', 13 | 'any.required': 'Username is required' 14 | }) 15 | 16 | // Password validation - minimum 8 chars 17 | export const passwordSchema = Joi.string() 18 | .min(8) 19 | .max(128) 20 | .required() 21 | .messages({ 22 | 'string.min': 'Password must be at least 8 characters long', 23 | 'string.max': 'Password must not exceed 128 characters', 24 | 'any.required': 'Password is required' 25 | }) 26 | 27 | // API key validation - hex string format 28 | export const apiKeySchema = Joi.string() 29 | .pattern(/^[a-f0-9]{64}$/) 30 | .required() 31 | .messages({ 32 | 'string.pattern.base': 'Invalid API key format', 33 | 'any.required': 'API key is required' 34 | }) 35 | 36 | // Timestamp validation - positive integer, reasonable range 37 | const now = Date.now() 38 | const oneYearAgo = now - 365 * 24 * 60 * 60 * 1000 39 | const oneYearFromNow = now + 365 * 24 * 60 * 60 * 1000 40 | 41 | export const timestampSchema = Joi.number() 42 | .integer() 43 | .positive() 44 | .min(oneYearAgo) 45 | .max(oneYearFromNow) 46 | .messages({ 47 | 'number.base': 'Timestamp must be a number', 48 | 'number.integer': 'Timestamp must be an integer', 49 | 'number.positive': 'Timestamp must be positive', 50 | 'number.min': 'Timestamp is too old (more than 1 year ago)', 51 | 'number.max': 'Timestamp is too far in the future (more than 1 year from now)' 52 | }) 53 | 54 | // API Key creation schema 55 | export const createApiKeySchema = Joi.object({ 56 | username: usernameSchema 57 | }) 58 | 59 | // Auth login schema 60 | export const loginSchema = Joi.object({ 61 | username: Joi.string().min(1).max(128).required(), 62 | password: passwordSchema 63 | }) 64 | 65 | // Analytics query schema 66 | export const analyticsQuerySchema = Joi.object({ 67 | keyId: apiKeySchema, 68 | startDate: timestampSchema.optional(), 69 | endDate: timestampSchema.optional() 70 | }).custom((value, helpers) => { 71 | // If both dates provided, ensure startDate < endDate 72 | if (value.startDate && value.endDate && value.startDate >= value.endDate) { 73 | return helpers.error('any.invalid', { message: 'Start date must be before end date' }) 74 | } 75 | return value 76 | }) 77 | 78 | // Helper function to validate and sanitize input 79 | export function validate(schema: Joi.Schema, data: any): { value: T; error: null } | { value: null; error: string } { 80 | const result = schema.validate(data, { stripUnknown: true, abortEarly: false }) 81 | 82 | if (result.error) { 83 | const errorMessage = result.error.details.map(d => d.message).join(', ') 84 | return { value: null, error: errorMessage } 85 | } 86 | 87 | return { value: result.value as T, error: null } 88 | } 89 | -------------------------------------------------------------------------------- /src/controllers/apikeys.ts: -------------------------------------------------------------------------------- 1 | import { Express, Request, Response, RequestHandler } from 'express' 2 | import { ApiKeyManager } from '../utils/apikeys' 3 | import { log } from '../utils/general' 4 | import { validate, createApiKeySchema, apiKeySchema } from '../utils/validation' 5 | 6 | export class ApiKeyController { 7 | private app: Express 8 | private apiKeyManager: ApiKeyManager 9 | private requestHandlers: RequestHandler[] 10 | 11 | constructor({ app, requestHandlers }: { app: Express; requestHandlers: RequestHandler[] }) { 12 | this.app = app 13 | this.requestHandlers = requestHandlers 14 | this.apiKeyManager = new ApiKeyManager() 15 | } 16 | 17 | public registerRoutes(): void { 18 | this.app.get('/api/keys', ...this.requestHandlers, this.listKeys.bind(this)) 19 | this.app.post('/api/keys', ...this.requestHandlers, this.createKey.bind(this)) 20 | this.app.delete('/api/keys/:id', ...this.requestHandlers, this.deleteKey.bind(this)) 21 | this.app.get('/api/keys/validate/:key', ...this.requestHandlers, this.validateKey.bind(this)) 22 | log('info', 'ApiKeyController initialized') 23 | } 24 | 25 | private async listKeys(req: Request, res: Response): Promise { 26 | try { 27 | const keys = await this.apiKeyManager.listKeys() 28 | res.json({ success: true, keys }) 29 | } catch (error) { 30 | log('error', 'Failed to list API keys', error) 31 | res.status(500).json({ success: false, message: 'Failed to list API keys' }) 32 | } 33 | } 34 | 35 | private async createKey(req: Request, res: Response): Promise { 36 | const validation = validate<{ username: string }>(createApiKeySchema, req.body) 37 | 38 | if (validation.error || !validation.value) { 39 | res.status(400).json({ success: false, message: validation.error || 'Validation failed' }) 40 | return 41 | } 42 | 43 | try { 44 | const result = await this.apiKeyManager.createKey(validation.value.username) 45 | const status = result.success ? 200 : 400 46 | res.status(status).json(result) 47 | } catch (error) { 48 | log('error', 'Failed to create API key', error) 49 | res.status(500).json({ success: false, message: 'Failed to create API key' }) 50 | } 51 | } 52 | 53 | private async deleteKey(req: Request, res: Response): Promise { 54 | const { id } = req.params 55 | 56 | if (!id) { 57 | res.status(400).json({ success: false, message: 'Key ID is required' }) 58 | return 59 | } 60 | 61 | try { 62 | const result = await this.apiKeyManager.deleteKey(id) 63 | const status = result.success ? 200 : 404 64 | res.status(status).json(result) 65 | } catch (error) { 66 | log('error', 'Failed to delete API key', error) 67 | res.status(500).json({ success: false, message: 'Failed to delete API key' }) 68 | } 69 | } 70 | 71 | private async validateKey(req: Request, res: Response): Promise { 72 | const validation = validate(apiKeySchema, req.params.key) 73 | 74 | if (validation.error || !validation.value) { 75 | res.status(400).json({ success: false, message: validation.error || 'Validation failed' }) 76 | return 77 | } 78 | 79 | try { 80 | const apiKey = await this.apiKeyManager.validateKey(validation.value) 81 | if (apiKey) { 82 | res.json({ success: true, valid: true, username: apiKey.username }) 83 | } else { 84 | res.json({ success: true, valid: false }) 85 | } 86 | } catch (error) { 87 | log('error', 'Failed to validate API key', error) 88 | res.status(500).json({ success: false, message: 'Failed to validate API key' }) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/utils/auth.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response, RequestHandler } from 'express' 2 | import jwt from 'jsonwebtoken' 3 | import { log } from './general' 4 | import { ApiKeyManager } from './apikeys' 5 | import { RequestTracker } from './requestTracker' 6 | 7 | 8 | const jwtSecret = process.env.JWT_SECRET || 'your-jwt-secret' 9 | const apiKeyManager = new ApiKeyManager() 10 | const requestTracker = new RequestTracker() 11 | 12 | 13 | export const tokenMiddleware: RequestHandler = (req: Request, res: Response, next: NextFunction) => { 14 | // Try to get token from cookie first, then fall back to Authorization header 15 | let token: string | undefined 16 | 17 | // Check cookie (preferred for web UI) 18 | if (req.cookies && req.cookies.auth_token) { 19 | token = req.cookies.auth_token 20 | } 21 | // Fall back to Authorization header (for API clients) 22 | else if (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) { 23 | token = req.headers.authorization.split(' ')[1] 24 | } 25 | 26 | if (token) { 27 | try { 28 | const decoded = jwt.verify(token, jwtSecret, { 29 | algorithms: ['HS256'], 30 | ignoreExpiration: true 31 | }) 32 | ;(req as any).user = decoded 33 | next() 34 | } catch (error) { 35 | log('error', `Token verification failed: ${(error as any).toString()}`) 36 | res.status(401).json({ error: 'Invalid token' }) 37 | } 38 | } else { 39 | log('warn', 'Authentication required: no token found') 40 | res.status(401).json({ error: 'Authentication required' }) 41 | } 42 | } 43 | 44 | export const apiKeyMiddleware: RequestHandler = async (req: Request, res: Response, next: NextFunction) => { 45 | const authHeader = req.headers.authorization 46 | 47 | if (authHeader && authHeader.startsWith('Bearer ')) { 48 | const apiKey = authHeader.split(' ')[1] 49 | 50 | try { 51 | const validKey = await apiKeyManager.validateKey(apiKey) 52 | 53 | if (validKey) { 54 | ;(req as any).apiKey = validKey 55 | 56 | // Track request start time 57 | const startTime = Date.now() 58 | 59 | // Track response completion 60 | const originalEnd = res.end.bind(res) 61 | let responseSent = false 62 | 63 | res.end = function(...args: any[]): any { 64 | if (!responseSent) { 65 | responseSent = true 66 | const endTime = Date.now() 67 | 68 | // Track successful completion 69 | requestTracker.trackRequest(apiKey, startTime, endTime).catch(err => { 70 | log('error', 'Failed to track request', err) 71 | }) 72 | } 73 | return originalEnd(...args) 74 | } as any 75 | 76 | // Track errors 77 | res.on('error', () => { 78 | if (!responseSent) { 79 | responseSent = true 80 | // Track failed request 81 | requestTracker.trackRequest(apiKey, startTime, null).catch(err => { 82 | log('error', 'Failed to track failed request', err) 83 | }) 84 | } 85 | }) 86 | 87 | next() 88 | } else { 89 | log('warn', `Invalid API key attempted: ${apiKey.substring(0, 10)}...`) 90 | res.status(401).json({ error: 'Invalid API key' }) 91 | } 92 | } catch (error) { 93 | log('error', `API key validation error: ${(error as any).toString()}`) 94 | res.status(500).json({ error: 'Internal server error' }) 95 | } 96 | } else { 97 | log('warn', 'API key missing in Authorization header') 98 | res.status(401).json({ error: 'Missing API key' }) 99 | } 100 | } -------------------------------------------------------------------------------- /.github/workflows/docker-build-push-release.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build, Push, and Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**/*.md' 9 | 10 | env: 11 | REGISTRY: ghcr.io 12 | IMAGE_NAME: ${{ github.repository }} 13 | KEEP_RELEASES: 10 14 | KEEP_IMAGES: 10 15 | 16 | jobs: 17 | build-push-release: 18 | runs-on: ubuntu-latest 19 | permissions: 20 | contents: write 21 | packages: write 22 | pull-requests: read 23 | 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | with: 28 | fetch-depth: 0 29 | 30 | - name: Get version from package.json 31 | id: package-version 32 | run: echo "VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT 33 | 34 | - name: Set up Docker Buildx 35 | uses: docker/setup-buildx-action@v3 36 | 37 | - name: Cache Docker layers 38 | uses: actions/cache@v3 39 | with: 40 | path: /tmp/.buildx-cache 41 | key: ${{ runner.os }}-buildx-${{ github.sha }} 42 | restore-keys: | 43 | ${{ runner.os }}-buildx- 44 | 45 | - name: Cache npm dependencies 46 | uses: actions/cache@v3 47 | with: 48 | path: ~/.npm 49 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 50 | restore-keys: | 51 | ${{ runner.os }}-node- 52 | 53 | - name: Log in to the Container registry 54 | uses: docker/login-action@v3 55 | with: 56 | registry: ${{ env.REGISTRY }} 57 | username: ${{ github.actor }} 58 | password: ${{ secrets.GITHUB_TOKEN }} 59 | 60 | - name: Extract metadata (tags, labels) for Docker 61 | id: meta 62 | uses: docker/metadata-action@v5 63 | with: 64 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 65 | tags: | 66 | type=raw,value=${{ steps.package-version.outputs.VERSION }} 67 | 68 | - name: Build and push Docker image 69 | uses: docker/build-push-action@v5 70 | with: 71 | context: . 72 | push: true 73 | tags: ${{ steps.meta.outputs.tags }} 74 | labels: ${{ steps.meta.outputs.labels }} 75 | cache-from: type=local,src=/tmp/.buildx-cache 76 | cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max 77 | 78 | - name: Move cache 79 | run: | 80 | rm -rf /tmp/.buildx-cache 81 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 82 | 83 | # - name: Scan Docker image for vulnerabilities 84 | # uses: aquasecurity/trivy-action@master 85 | # env: 86 | # TRIVY_USERNAME: ${{ github.actor }} 87 | # TRIVY_PASSWORD: ${{ secrets.GITHUB_TOKEN }} 88 | # with: 89 | # image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.package-version.outputs.VERSION }} 90 | # format: 'table' 91 | # exit-code: '1' 92 | # ignore-unfixed: true 93 | # vuln-type: 'os,library' 94 | # severity: 'CRITICAL,HIGH' 95 | 96 | - name: Get Pull Request Messages 97 | id: pr-messages 98 | run: | 99 | PR_MESSAGES=$(git log --merges --format="%b" @~1..HEAD | sed 's/^/* /') 100 | echo "PR_MESSAGES<> $GITHUB_OUTPUT 101 | echo "$PR_MESSAGES" >> $GITHUB_OUTPUT 102 | echo "EOF" >> $GITHUB_OUTPUT 103 | 104 | - name: Create Release 105 | id: create_release 106 | uses: actions/create-release@v1 107 | env: 108 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 109 | with: 110 | tag_name: v${{ steps.package-version.outputs.VERSION }} 111 | release_name: Release v${{ steps.package-version.outputs.VERSION }} 112 | body: | 113 | ## Docker Image 114 | Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.package-version.outputs.VERSION }} 115 | 116 | ## Pull Request Messages 117 | ${{ steps.pr-messages.outputs.PR_MESSAGES }} 118 | 119 | ## Security Scan 120 | A security scan was performed on this Docker image. Any critical or high vulnerabilities would have prevented this release. 121 | draft: false 122 | prerelease: false 123 | -------------------------------------------------------------------------------- /src/controllers/nginx.ts: -------------------------------------------------------------------------------- 1 | import { Express, Request, Response, RequestHandler } from 'express' 2 | import { NginxManager } from '../utils/nginx' 3 | import { log } from '../utils/general' 4 | 5 | export class NginxController { 6 | private app: Express 7 | private nginxManager = new NginxManager() 8 | private requestHandlers: RequestHandler[] 9 | 10 | constructor({ app, requestHandlers }: { app: Express; requestHandlers: RequestHandler[] }) { 11 | this.app = app 12 | this.requestHandlers = requestHandlers 13 | } 14 | 15 | public registerRoutes(): void { 16 | this.app.get('/nginx/reload', ...this.requestHandlers, this.reloadNginx.bind(this)) 17 | this.app.post('/nginx/config/update', ...this.requestHandlers, this.updateConfig.bind(this)) 18 | this.app.get('/nginx/config/get', ...this.requestHandlers, this.getConfig.bind(this)) 19 | this.app.get('/nginx/config/get-default', ...this.requestHandlers, this.getDefaultConfig.bind(this)) 20 | this.app.post('/nginx/config/write-default', ...this.requestHandlers, this.writeDefaultConfig.bind(this)) 21 | this.app.post('/nginx/certificates/obtain', ...this.requestHandlers, this.obtainCertificates.bind(this)) 22 | this.app.get('/nginx/certificates/renew', ...this.requestHandlers, this.renewCertificates.bind(this)) 23 | log('info', 'NginxController initialized') 24 | } 25 | 26 | public async start(): Promise { 27 | const { success, message } = await this.nginxManager.start() 28 | } 29 | 30 | private async reloadNginx(req: Request, res: Response): Promise { 31 | const { success, message } = await this.nginxManager.reload() 32 | const status = success ? 200 : 500 33 | res.status(status).json({ success, message }) 34 | } 35 | 36 | private async updateConfig(req: Request, res: Response): Promise { 37 | if (req.body != null && req.body.config != null) { 38 | const newConfig = req.body.config 39 | const { success, message } = await this.nginxManager.updateConfig(newConfig) 40 | const status = success ? 200 : 500 41 | res.status(status).json({ success, message }) 42 | } else { 43 | res.status(400).json({ success: false, message: 'Invalid request body' }) 44 | } 45 | } 46 | 47 | private async getConfig(req: Request, res: Response): Promise { 48 | const { success, message, config } = await this.nginxManager.getConfig() 49 | const status = success ? 200 : 500 50 | res.status(status).json({ success, message, config }) 51 | } 52 | 53 | private async getDefaultConfig(req: Request, res: Response): Promise { 54 | const { success, message, config } = await this.nginxManager.getTemplate() 55 | if (success && config) { 56 | res.json({ success, config }) 57 | } else { 58 | res.status(500).json({ success, message }) 59 | } 60 | } 61 | 62 | private async writeDefaultConfig(req: Request, res: Response): Promise { 63 | if (req.body != null && req.body.domain != null && req.body.cidrGroups != null) { 64 | const { domain, cidrGroups } = req.body 65 | if (Array.isArray(cidrGroups) && typeof domain === 'string') { 66 | const { success, message } = await this.nginxManager.writeDefaultTemplate(domain, cidrGroups) 67 | if (success) { 68 | res.json({ success, message: 'Default config written successfully' }) 69 | } else { 70 | res.status(500).json({ success, message }) 71 | } 72 | } else { 73 | res.status(400).json({ success: false, message: 'Invalid request body' }) 74 | } 75 | } else { 76 | res.status(400).json({ success: false, message: 'Invalid request body' }) 77 | } 78 | } 79 | 80 | private async obtainCertificates(req: Request, res: Response): Promise { 81 | if (req.body != null && req.body.domains != null && Array.isArray(req.body.domains)) { 82 | const domains = req.body.domains 83 | const { success, message } = await this.nginxManager.obtainCertificates(domains, true) 84 | const status = success ? 200 : 500 85 | res.status(status).json({ success, message }) 86 | } else { 87 | res.status(400).json({ success: false, message: 'Invalid request body' }) 88 | } 89 | } 90 | 91 | private async renewCertificates(req: Request, res: Response): Promise { 92 | const { success, message } = await this.nginxManager.renewCertificates() 93 | const status = success ? 200 : 500 94 | res.status(status).json({ success, message }) 95 | } 96 | } -------------------------------------------------------------------------------- /src/utils/nginx.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process' 2 | import { readFile, writeFile } from 'fs-extra' 3 | import { join } from 'path' 4 | import { promisify } from 'util' 5 | import { log } from './general' 6 | 7 | const execAsync = promisify(exec) 8 | 9 | export interface NginxResponse { 10 | success: boolean 11 | message?: string 12 | } 13 | 14 | export interface NginxConfigResponse { 15 | success: boolean 16 | config: string 17 | message?: string 18 | } 19 | 20 | const CONFIG_TEMPLATE_PATH = join(__dirname, '../static/nginx-server-template.conf') 21 | 22 | export class NginxManager { 23 | private configPath: string 24 | // private domains: string[] 25 | 26 | constructor(configPath: string = '/etc/nginx/conf.d/server.conf', domains: string[] = []) { 27 | this.configPath = configPath 28 | // this.domains = domains 29 | } 30 | 31 | async start(): Promise { 32 | let message: string | undefined 33 | let success = false 34 | try { 35 | await execAsync('nginx') 36 | message = 'Nginx started successfully.' 37 | success = true 38 | log('info', message) 39 | } catch (error) { 40 | log('error', 'Failed to start Nginx:', error) 41 | message = error != null ? (error as any).message ?? 'Error' : 'Unknown error' 42 | } 43 | return { success, message } 44 | } 45 | 46 | async reload(): Promise { 47 | let message: string | undefined 48 | let success = false 49 | try { 50 | await execAsync('nginx -s reload') 51 | message = 'Nginx configuration reloaded successfully.' 52 | success = true 53 | log('info', message) 54 | } catch (error) { 55 | log('error', 'Failed to reload Nginx.', error) 56 | message = error != null ? (error as any).message ?? 'Error' : 'Unknown error' 57 | } 58 | return { success, message } 59 | } 60 | 61 | async updateConfig(newConfig: string): Promise { 62 | return this.putFile(this.configPath, newConfig) 63 | } 64 | 65 | async writeDefaultTemplate(domain: string, cidrGroups: string[]): Promise { 66 | const templateContent = await readFile(CONFIG_TEMPLATE_PATH, 'utf-8') 67 | const allowedIPs = cidrGroups.map((g) => ` allow ${g};\n`).reduce((acc, curr) => acc + curr, '') 68 | const content = templateContent 69 | .replace(/{{domainName}}/g, domain) 70 | .replace(/{{allowedIPs}}/g, allowedIPs) 71 | return this.putFile(this.configPath, content) 72 | } 73 | 74 | async getConfig(): Promise { 75 | return this.getFile(this.configPath) 76 | } 77 | 78 | async getTemplate(): Promise { 79 | return this.getFile(CONFIG_TEMPLATE_PATH) 80 | } 81 | 82 | async putFile(path: string, content: string): Promise { 83 | let message: string | undefined 84 | let success = false 85 | try { 86 | await writeFile(path, content) 87 | message = 'Nginx configuration updated.' 88 | success = true 89 | log('info', message) 90 | await this.reload() 91 | } catch (error) { 92 | log('error', `Failed to update Nginx configuration. ${path}`, error) 93 | message = error != null ? (error as any).message ?? 'Error' : 'Unknown error' 94 | } 95 | return { success, message } 96 | } 97 | 98 | private async getFile(path: string): Promise { 99 | let config: string = '' 100 | let message: string | undefined 101 | let success = false 102 | try { 103 | config = await readFile(path, 'utf-8') 104 | success = true 105 | } catch (error) { 106 | log('error', `Failed to read configuration file. ${path}`, error) 107 | message = error != null ? (error as any).message ?? 'Error' : 'Unknown error' 108 | } 109 | return { success, config, message } 110 | } 111 | 112 | async obtainCertificates(domains: string[] = [], cloudflare?: boolean): Promise { 113 | const domainArgs = domains.map((domain) => `-d ${domain}`).join(' ') 114 | let message: string | undefined 115 | let success = false 116 | const cloudflareFlags = cloudflare 117 | ? ' --dns-cloudflare --dns-cloudflare-credentials /opt/cloudflare/credentials' 118 | : '' 119 | const command = `certbot certonly -n --email email@email.com --agree-tos${cloudflareFlags} ${domainArgs} --preferred-challenges dns-01` 120 | try { 121 | await execAsync(command) 122 | message = 'Certificates obtained successfully.' 123 | success = true 124 | log('info', message) 125 | } catch (error) { 126 | log('error', 'Failed to obtain certificates.', error) 127 | message = error != null ? (error as any).message ?? 'Error' : 'Unknown error' 128 | message = `command: ${command}\n${message}` 129 | } 130 | return { success, message } 131 | } 132 | 133 | async renewCertificates(): Promise { 134 | let message: string | undefined 135 | let success = false 136 | const command = 'certbot renew' 137 | try { 138 | await execAsync(command) 139 | message = 'Certificates renewed successfully.' 140 | success = true 141 | log('info', message) 142 | } catch (error) { 143 | log('error', 'Failed to renew certificates.', error) 144 | message = error != null ? (error as any).message ?? 'Error' : 'Unknown error' 145 | message = `command: ${command}\n${message}` 146 | } 147 | return { success, message } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/utils/__tests__/apikeys.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import path from 'path' 3 | import { ApiKeyManager } from '../apikeys' 4 | 5 | describe('ApiKeyManager', () => { 6 | let manager: ApiKeyManager 7 | let testFilePath: string 8 | 9 | beforeEach(() => { 10 | // Use a unique test file for each test 11 | testFilePath = path.join(__dirname, `test-apikeys-${Date.now()}.json`) 12 | manager = new ApiKeyManager(testFilePath) 13 | }) 14 | 15 | afterEach(async () => { 16 | // Clean up test files 17 | try { 18 | await fs.remove(testFilePath) 19 | await fs.remove(`${testFilePath}.lock`) 20 | } catch (error) { 21 | // Ignore cleanup errors 22 | } 23 | }) 24 | 25 | describe('createKey', () => { 26 | it('should create a new API key', async () => { 27 | const result = await manager.createKey('testuser') 28 | 29 | expect(result.success).toBe(true) 30 | expect(result.key).toBeDefined() 31 | expect(result.key?.username).toBe('testuser') 32 | expect(result.key?.key).toMatch(/^sk-[a-f0-9]{64}$/) 33 | expect(result.key?.id).toBeDefined() 34 | expect(result.key?.createdAt).toBeDefined() 35 | }) 36 | 37 | it('should reject empty username', async () => { 38 | const result = await manager.createKey('') 39 | 40 | expect(result.success).toBe(false) 41 | expect(result.message).toBe('Username is required') 42 | }) 43 | 44 | it('should reject duplicate username', async () => { 45 | await manager.createKey('testuser') 46 | const result = await manager.createKey('testuser') 47 | 48 | expect(result.success).toBe(false) 49 | expect(result.message).toBe('API key already exists for this username') 50 | }) 51 | 52 | it('should trim whitespace from username', async () => { 53 | const result = await manager.createKey(' testuser ') 54 | 55 | expect(result.success).toBe(true) 56 | expect(result.key?.username).toBe('testuser') 57 | }) 58 | }) 59 | 60 | describe('listKeys', () => { 61 | it('should return empty array when no keys exist', async () => { 62 | const keys = await manager.listKeys() 63 | 64 | expect(keys).toEqual([]) 65 | }) 66 | 67 | it('should return all created keys', async () => { 68 | await manager.createKey('user1') 69 | await manager.createKey('user2') 70 | await manager.createKey('user3') 71 | 72 | const keys = await manager.listKeys() 73 | 74 | expect(keys).toHaveLength(3) 75 | expect(keys.map(k => k.username)).toEqual(['user1', 'user2', 'user3']) 76 | }) 77 | }) 78 | 79 | describe('getKeyByUsername', () => { 80 | it('should return null when key does not exist', async () => { 81 | const key = await manager.getKeyByUsername('nonexistent') 82 | 83 | expect(key).toBeNull() 84 | }) 85 | 86 | it('should return the key when it exists', async () => { 87 | const created = await manager.createKey('testuser') 88 | const fetched = await manager.getKeyByUsername('testuser') 89 | 90 | expect(fetched).toBeDefined() 91 | expect(fetched?.username).toBe('testuser') 92 | expect(fetched?.id).toBe(created.key?.id) 93 | }) 94 | }) 95 | 96 | describe('getKeyById', () => { 97 | it('should return null when key does not exist', async () => { 98 | const key = await manager.getKeyById('nonexistent') 99 | 100 | expect(key).toBeNull() 101 | }) 102 | 103 | it('should return the key when it exists', async () => { 104 | const created = await manager.createKey('testuser') 105 | const fetched = await manager.getKeyById(created.key!.id) 106 | 107 | expect(fetched).toBeDefined() 108 | expect(fetched?.username).toBe('testuser') 109 | expect(fetched?.id).toBe(created.key?.id) 110 | }) 111 | }) 112 | 113 | describe('deleteKey', () => { 114 | it('should return error when key does not exist', async () => { 115 | const result = await manager.deleteKey('nonexistent') 116 | 117 | expect(result.success).toBe(false) 118 | expect(result.message).toBe('API key not found') 119 | }) 120 | 121 | it('should delete existing key', async () => { 122 | const created = await manager.createKey('testuser') 123 | const deleteResult = await manager.deleteKey(created.key!.id) 124 | 125 | expect(deleteResult.success).toBe(true) 126 | 127 | const keys = await manager.listKeys() 128 | expect(keys).toHaveLength(0) 129 | }) 130 | 131 | it('should only delete the specified key', async () => { 132 | const created1 = await manager.createKey('user1') 133 | await manager.createKey('user2') 134 | await manager.createKey('user3') 135 | 136 | await manager.deleteKey(created1.key!.id) 137 | 138 | const keys = await manager.listKeys() 139 | expect(keys).toHaveLength(2) 140 | expect(keys.map(k => k.username)).toEqual(['user2', 'user3']) 141 | }) 142 | }) 143 | 144 | describe('validateKey', () => { 145 | it('should return null for invalid key', async () => { 146 | const result = await manager.validateKey('invalid-key') 147 | 148 | expect(result).toBeNull() 149 | }) 150 | 151 | it('should return key data for valid key', async () => { 152 | const created = await manager.createKey('testuser') 153 | const validated = await manager.validateKey(created.key!.key) 154 | 155 | expect(validated).toBeDefined() 156 | expect(validated?.username).toBe('testuser') 157 | expect(validated?.id).toBe(created.key?.id) 158 | }) 159 | }) 160 | }) 161 | -------------------------------------------------------------------------------- /src/utils/requestTracker.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import path from 'path' 3 | import { log } from './general' 4 | 5 | export interface RequestRecord { 6 | startTime: number // epoch ms 7 | endTime: number | null // epoch ms or null if failed 8 | } 9 | 10 | export class RequestTracker { 11 | private dataDir: string 12 | private cache: Map = new Map() 13 | private readonly ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000 14 | 15 | constructor(dataDir: string = './data/analytics') { 16 | this.dataDir = path.resolve(dataDir) 17 | } 18 | 19 | private async initialize(): Promise { 20 | try { 21 | await fs.ensureDir(this.dataDir) 22 | } catch (error) { 23 | log('error', 'Failed to initialize request tracker', error) 24 | throw error 25 | } 26 | } 27 | 28 | private getFilePath(apiKey: string): string { 29 | // Use MD5 hash of API key as filename to avoid filesystem issues 30 | const crypto = require('crypto') 31 | const hash = crypto.createHash('md5').update(apiKey).digest('hex') 32 | return path.join(this.dataDir, `${hash}.csv`) 33 | } 34 | 35 | private async loadKeyData(apiKey: string): Promise { 36 | const filePath = this.getFilePath(apiKey) 37 | 38 | try { 39 | const exists = await fs.pathExists(filePath) 40 | if (!exists) { 41 | this.cache.set(apiKey, []) 42 | return 43 | } 44 | 45 | const content = await fs.readFile(filePath, 'utf-8') 46 | const lines = content.trim().split('\n') 47 | 48 | // Skip header 49 | const records: RequestRecord[] = [] 50 | const oneWeekAgo = Date.now() - this.ONE_WEEK_MS 51 | 52 | for (let i = 1; i < lines.length; i++) { 53 | const line = lines[i].trim() 54 | if (!line) continue 55 | 56 | const [startTime, endTime] = line.split(',') 57 | const start = parseInt(startTime, 10) 58 | 59 | // Only keep last week in memory 60 | if (start >= oneWeekAgo) { 61 | records.push({ 62 | startTime: start, 63 | endTime: endTime === 'null' ? null : parseInt(endTime, 10) 64 | }) 65 | } 66 | } 67 | 68 | this.cache.set(apiKey, records) 69 | log('info', `Loaded ${records.length} records for API key (last week)`) 70 | } catch (error) { 71 | log('error', `Failed to load data for API key`, error) 72 | this.cache.set(apiKey, []) 73 | } 74 | } 75 | 76 | public async trackRequest(apiKey: string, startTime: number, endTime: number | null): Promise { 77 | await this.initialize() 78 | 79 | // Ensure cache is loaded for this key 80 | if (!this.cache.has(apiKey)) { 81 | await this.loadKeyData(apiKey) 82 | } 83 | 84 | const record: RequestRecord = { startTime, endTime } 85 | 86 | // Add to cache 87 | const records = this.cache.get(apiKey) || [] 88 | records.push(record) 89 | this.cache.set(apiKey, records) 90 | 91 | // Append to CSV file 92 | const filePath = this.getFilePath(apiKey) 93 | const csvLine = `${startTime},${endTime === null ? 'null' : endTime}\n` 94 | 95 | try { 96 | const exists = await fs.pathExists(filePath) 97 | if (!exists) { 98 | // Create file with header 99 | await fs.writeFile(filePath, 'startTime,endTime\n' + csvLine) 100 | } else { 101 | // Append to existing file 102 | await fs.appendFile(filePath, csvLine) 103 | } 104 | } catch (error) { 105 | log('error', `Failed to write request record`, error) 106 | } 107 | } 108 | 109 | public async getRecords(apiKey: string, startDate?: number, endDate?: number): Promise { 110 | await this.initialize() 111 | 112 | const filePath = this.getFilePath(apiKey) 113 | const exists = await fs.pathExists(filePath) 114 | 115 | if (!exists) { 116 | return [] 117 | } 118 | 119 | try { 120 | const content = await fs.readFile(filePath, 'utf-8') 121 | const lines = content.trim().split('\n') 122 | 123 | const records: RequestRecord[] = [] 124 | 125 | // Skip header 126 | for (let i = 1; i < lines.length; i++) { 127 | const line = lines[i].trim() 128 | if (!line) continue 129 | 130 | const [startTime, endTime] = line.split(',') 131 | const start = parseInt(startTime, 10) 132 | 133 | // Filter by date range if provided 134 | if (startDate && start < startDate) continue 135 | if (endDate && start > endDate) continue 136 | 137 | records.push({ 138 | startTime: start, 139 | endTime: endTime === 'null' ? null : parseInt(endTime, 10) 140 | }) 141 | } 142 | 143 | return records 144 | } catch (error) { 145 | log('error', `Failed to read records for API key`, error) 146 | return [] 147 | } 148 | } 149 | 150 | public async getLastWeekRecords(apiKey: string): Promise { 151 | const oneWeekAgo = Date.now() - this.ONE_WEEK_MS 152 | return this.getRecords(apiKey, oneWeekAgo) 153 | } 154 | 155 | public async cleanupOldData(apiKey: string): Promise { 156 | // This can be called periodically to clean up old data from CSV files 157 | const filePath = this.getFilePath(apiKey) 158 | const exists = await fs.pathExists(filePath) 159 | 160 | if (!exists) return 161 | 162 | try { 163 | const oneWeekAgo = Date.now() - this.ONE_WEEK_MS 164 | const records = await this.getRecords(apiKey) 165 | 166 | // Filter to keep only last week 167 | const recentRecords = records.filter(r => r.startTime >= oneWeekAgo) 168 | 169 | // Rewrite file with only recent records 170 | let content = 'startTime,endTime\n' 171 | for (const record of recentRecords) { 172 | content += `${record.startTime},${record.endTime === null ? 'null' : record.endTime}\n` 173 | } 174 | 175 | await fs.writeFile(filePath, content) 176 | 177 | // Update cache 178 | this.cache.set(apiKey, recentRecords) 179 | 180 | log('info', `Cleaned up old data for API key, kept ${recentRecords.length} records`) 181 | } catch (error) { 182 | log('error', `Failed to cleanup old data`, error) 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/controllers/__tests__/analytics.test.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { AnalyticsController } from '../analytics' 3 | import { RequestTracker } from '../../utils/requestTracker' 4 | 5 | // Mock dependencies 6 | jest.mock('../../utils/requestTracker') 7 | jest.mock('../../utils/general', () => ({ 8 | log: jest.fn() 9 | })) 10 | 11 | describe('AnalyticsController', () => { 12 | let controller: AnalyticsController 13 | let mockApp: any 14 | let mockRequestTracker: jest.Mocked 15 | let mockReq: Partial 16 | let mockRes: Partial 17 | 18 | beforeEach(() => { 19 | mockApp = { 20 | get: jest.fn() 21 | } 22 | 23 | mockReq = { 24 | params: {}, 25 | query: {} 26 | } 27 | 28 | mockRes = { 29 | status: jest.fn().mockReturnThis(), 30 | json: jest.fn().mockReturnThis() 31 | } 32 | 33 | // Create mock instance 34 | mockRequestTracker = { 35 | getRecords: jest.fn(), 36 | getLastWeekRecords: jest.fn(), 37 | trackRequest: jest.fn() 38 | } as any 39 | 40 | ;(RequestTracker as jest.Mock).mockImplementation(() => mockRequestTracker) 41 | 42 | controller = new AnalyticsController({ 43 | app: mockApp, 44 | requestHandlers: [] 45 | }) 46 | 47 | jest.clearAllMocks() 48 | }) 49 | 50 | describe('registerRoutes', () => { 51 | it('should register analytics routes', () => { 52 | controller.registerRoutes() 53 | 54 | expect(mockApp.get).toHaveBeenCalledWith( 55 | '/api/analytics/:keyId', 56 | expect.any(Function) 57 | ) 58 | }) 59 | }) 60 | 61 | describe('getAnalytics', () => { 62 | const validApiKey = 'a'.repeat(64) 63 | 64 | it('should return last week records by default', async () => { 65 | const mockRecords = [ 66 | { startTime: Date.now() - 1000, endTime: Date.now() }, 67 | { startTime: Date.now() - 2000, endTime: Date.now() - 1000 } 68 | ] 69 | 70 | mockReq.params = { keyId: validApiKey } 71 | mockRequestTracker.getLastWeekRecords.mockResolvedValue(mockRecords) 72 | 73 | // Get the bound handler 74 | controller.registerRoutes() 75 | const handler = mockApp.get.mock.calls[0][1] 76 | await handler(mockReq, mockRes) 77 | 78 | expect(mockRequestTracker.getLastWeekRecords).toHaveBeenCalledWith(validApiKey) 79 | expect(mockRes.json).toHaveBeenCalledWith({ success: true, records: mockRecords }) 80 | }) 81 | 82 | it('should return records for custom date range', async () => { 83 | const startDate = Date.now() - 10000 84 | const endDate = Date.now() 85 | const mockRecords = [ 86 | { startTime: startDate + 1000, endTime: endDate - 1000 } 87 | ] 88 | 89 | mockReq.params = { keyId: validApiKey } 90 | mockReq.query = { 91 | startDate: startDate.toString(), 92 | endDate: endDate.toString() 93 | } 94 | mockRequestTracker.getRecords.mockResolvedValue(mockRecords) 95 | 96 | controller.registerRoutes() 97 | const handler = mockApp.get.mock.calls[0][1] 98 | await handler(mockReq, mockRes) 99 | 100 | expect(mockRequestTracker.getRecords).toHaveBeenCalledWith(validApiKey, startDate, endDate) 101 | expect(mockRes.json).toHaveBeenCalledWith({ success: true, records: mockRecords }) 102 | }) 103 | 104 | it('should reject invalid API key format', async () => { 105 | mockReq.params = { keyId: 'invalid-key' } 106 | 107 | controller.registerRoutes() 108 | const handler = mockApp.get.mock.calls[0][1] 109 | await handler(mockReq, mockRes) 110 | 111 | expect(mockRes.status).toHaveBeenCalledWith(400) 112 | expect(mockRes.json).toHaveBeenCalledWith({ 113 | success: false, 114 | message: expect.stringContaining('Invalid API key format') 115 | }) 116 | }) 117 | 118 | it('should reject invalid start date', async () => { 119 | mockReq.params = { keyId: validApiKey } 120 | mockReq.query = { 121 | startDate: 'invalid', 122 | endDate: Date.now().toString() 123 | } 124 | 125 | controller.registerRoutes() 126 | const handler = mockApp.get.mock.calls[0][1] 127 | await handler(mockReq, mockRes) 128 | 129 | expect(mockRes.status).toHaveBeenCalledWith(400) 130 | expect(mockRes.json).toHaveBeenCalledWith({ 131 | success: false, 132 | message: expect.stringContaining('Invalid start date') 133 | }) 134 | }) 135 | 136 | it('should reject invalid end date', async () => { 137 | mockReq.params = { keyId: validApiKey } 138 | mockReq.query = { 139 | startDate: Date.now().toString(), 140 | endDate: 'invalid' 141 | } 142 | 143 | controller.registerRoutes() 144 | const handler = mockApp.get.mock.calls[0][1] 145 | await handler(mockReq, mockRes) 146 | 147 | expect(mockRes.status).toHaveBeenCalledWith(400) 148 | expect(mockRes.json).toHaveBeenCalledWith({ 149 | success: false, 150 | message: expect.stringContaining('Invalid end date') 151 | }) 152 | }) 153 | 154 | it('should reject start date after end date', async () => { 155 | const now = Date.now() 156 | mockReq.params = { keyId: validApiKey } 157 | mockReq.query = { 158 | startDate: now.toString(), 159 | endDate: (now - 10000).toString() 160 | } 161 | 162 | controller.registerRoutes() 163 | const handler = mockApp.get.mock.calls[0][1] 164 | await handler(mockReq, mockRes) 165 | 166 | expect(mockRes.status).toHaveBeenCalledWith(400) 167 | expect(mockRes.json).toHaveBeenCalledWith({ 168 | success: false, 169 | message: 'Start date must be before end date' 170 | }) 171 | }) 172 | 173 | it('should handle errors gracefully', async () => { 174 | mockReq.params = { keyId: validApiKey } 175 | mockRequestTracker.getLastWeekRecords.mockRejectedValue(new Error('Database error')) 176 | 177 | controller.registerRoutes() 178 | const handler = mockApp.get.mock.calls[0][1] 179 | await handler(mockReq, mockRes) 180 | 181 | expect(mockRes.status).toHaveBeenCalledWith(500) 182 | expect(mockRes.json).toHaveBeenCalledWith({ 183 | success: false, 184 | message: 'Failed to retrieve analytics' 185 | }) 186 | }) 187 | }) 188 | }) 189 | -------------------------------------------------------------------------------- /src/controllers/llm.ts: -------------------------------------------------------------------------------- 1 | import { Express, NextFunction, Request, RequestHandler, Response } from 'express' 2 | import { log, md5, sleep, extractDomainName } from '../utils/general' 3 | import axios, { AxiosRequestConfig } from 'axios' 4 | 5 | export interface Model { 6 | id: string 7 | object: string 8 | owned_by: string 9 | permission: Array 10 | } 11 | 12 | export interface ModelMap { 13 | [key: string]: { url: string; model: Model } 14 | } 15 | 16 | const defaultContentType = 'application/json' 17 | 18 | function getPath(url: string): { path: string, base: string, apiKey?: string } { 19 | try { 20 | const urlParts = url.split('|') 21 | const apiKey = urlParts.length > 1 ? urlParts[1] : undefined 22 | const { origin, pathname } = new URL(urlParts[0]) 23 | return { 24 | path: pathname === '/' ? '/v1' : pathname, 25 | base: origin, 26 | apiKey 27 | } 28 | } catch (error) { 29 | // Return the input if it's already a path starting with '/' 30 | if (url.startsWith('/')) return { path: url, base: 'http://localhost' } 31 | // Return '/v1' for invalid URLs 32 | return { path: '/v1', base: 'http://localhost' } 33 | } 34 | } 35 | 36 | async function fetchModels(targetUrls: string[]): Promise { 37 | const tmp: ModelMap = {} 38 | for (const urlAndToken of targetUrls) { 39 | const { path, base, apiKey } = getPath(urlAndToken) 40 | const headers: { [key: string]: string } = { 41 | accept: defaultContentType, 42 | 'Content-Type': defaultContentType 43 | } 44 | if (apiKey != null && apiKey !== '') { 45 | headers['Authorization'] = `Bearer ${apiKey}` 46 | } 47 | const params = { 48 | method: 'GET', 49 | url: `${base}${path}/models`, 50 | headers 51 | } 52 | try { 53 | const response = await axios(params) 54 | const models = response.data.data || [] 55 | const hostId = extractDomainName(base) 56 | models.forEach((model: Model) => { 57 | const hash = md5(model.id) 58 | tmp[hash] = { url: urlAndToken, model } 59 | }) 60 | log('info', `Models cached successfully for ${base}. [${models.map((m: Model) => m.id).join(', ')}]`) 61 | } catch (error) { 62 | log('error', `Error fetching models from ${base}${path}/models: ${(error as any).toString()}`) 63 | } 64 | } 65 | return tmp 66 | } 67 | 68 | export class LLMController { 69 | private app: Express 70 | private requestHandlers: RequestHandler[] 71 | private targetUrls: Array = [] 72 | private modelCache: ModelMap = {} 73 | 74 | constructor({ 75 | app, 76 | requestHandlers, 77 | targetUrls 78 | }: { 79 | app: Express 80 | requestHandlers: RequestHandler[] 81 | targetUrls: string[] 82 | }) { 83 | this.app = app 84 | this.requestHandlers = requestHandlers 85 | this.targetUrls = targetUrls 86 | } 87 | 88 | public registerRoutes(): void { 89 | this.app.get('/v1/models', ...this.requestHandlers, this.models.bind(this)) 90 | this.app.use('/', ...this.requestHandlers, this.forwardPostRequest.bind(this)) 91 | log('info', 'LLMController routes registered') 92 | log('info', 'fetching model lists') 93 | this.cacheModels() 94 | } 95 | 96 | private async cacheModels() { 97 | while (true) { 98 | this.modelCache = await fetchModels(this.targetUrls) 99 | await sleep(60000) 100 | } 101 | } 102 | 103 | private models(req: Request, res: Response): void { 104 | const combinedModels = Object.values(this.modelCache).map((item) => item.model) 105 | res.json({ data: combinedModels, object: 'list' }) 106 | } 107 | 108 | public async forwardPostRequest(req: Request, res: Response, next: NextFunction) { 109 | if ( 110 | req.method === 'POST' && 111 | (req.path.startsWith('v1') || req.path.startsWith('/v1')) && 112 | req.body != null && 113 | req.body.model != null && 114 | this.targetUrls.length > 0 115 | ) { 116 | const { model: modelId } = req.body 117 | const { base: firstBaseUrl, path: firstPath, apiKey: firstApiKey } = getPath(this.targetUrls[0]) 118 | let targetUrl = firstBaseUrl // Default to first URL if no matching model found 119 | let targetPath = firstPath 120 | let targetApiKey = firstApiKey 121 | 122 | const hash = md5(modelId) 123 | if (modelId && this.modelCache[hash]) { 124 | const { path, base, apiKey } = getPath(this.modelCache[hash].url) 125 | targetUrl = base 126 | targetPath = path 127 | targetApiKey = apiKey 128 | } 129 | const reqPath = req.path.startsWith('/v1/') ? req.path.replace('/v1', targetPath) : `${targetPath}${req.path}` 130 | const fullUrl = new URL(reqPath, targetUrl).toString() 131 | log('info', `Forwarding request to: ${fullUrl} -> ${modelId}`) 132 | const headers = { ...req.headers } 133 | if (targetApiKey) { 134 | headers['Authorization'] = `Bearer ${targetApiKey}` 135 | } 136 | try { 137 | const axiosConfig: AxiosRequestConfig = { 138 | method: req.method, 139 | url: fullUrl, 140 | headers, 141 | data: req.body, 142 | responseType: 'stream' 143 | } 144 | 145 | // Remove headers that might cause issues 146 | if (axiosConfig.headers != null) { 147 | delete axiosConfig.headers['host'] 148 | delete axiosConfig.headers['content-length'] 149 | } 150 | 151 | const axiosResponse = await axios(axiosConfig) 152 | 153 | // Forward the response status and headers 154 | res.status(axiosResponse.status) 155 | Object.entries(axiosResponse.headers).forEach(([key, value]) => { 156 | res.setHeader(key, value) 157 | }) 158 | 159 | // Pipe the response data 160 | axiosResponse.data.pipe(res) 161 | } catch (error) { 162 | log(`Error forwarding request: ${(error as any).toString()}`, 'error') 163 | if (axios.isAxiosError(error) && error.response) { 164 | // log(`Error response from ${fullUrl}:`, 'error', error.response.data) 165 | log(`Request body caused error:`, 'error', req.body) 166 | res.status(error.response.status).json({ error: 'Error processing request' }) 167 | } else { 168 | res.status(500).json({ error: 'Internal Server Error' }) 169 | } 170 | } 171 | } else { 172 | next() 173 | } 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/utils/apikeys.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs-extra' 2 | import crypto from 'crypto' 3 | import path from 'path' 4 | import { log } from './general' 5 | 6 | export interface ApiKey { 7 | id: string 8 | username: string 9 | key: string 10 | createdAt: string 11 | } 12 | 13 | export class ApiKeyManager { 14 | private filePath: string 15 | private lockFile: string 16 | private isInitialized: boolean = false 17 | private keysCache: Map = new Map() 18 | 19 | constructor(filePath: string = './data/apikeys.json') { 20 | this.filePath = path.resolve(filePath) 21 | this.lockFile = `${this.filePath}.lock` 22 | } 23 | 24 | private async initialize(): Promise { 25 | if (this.isInitialized) return 26 | 27 | try { 28 | await fs.ensureDir(path.dirname(this.filePath)) 29 | 30 | const exists = await fs.pathExists(this.filePath) 31 | if (!exists) { 32 | await fs.writeJson(this.filePath, { keys: [] }, { spaces: 2 }) 33 | log('info', `Created API keys file at ${this.filePath}`) 34 | } 35 | 36 | // Load keys into memory 37 | const data = await this.loadFromDisk() 38 | this.keysCache.clear() 39 | for (const key of data.keys) { 40 | this.keysCache.set(key.key, key) 41 | } 42 | 43 | this.isInitialized = true 44 | log('info', `Loaded ${this.keysCache.size} API keys into memory`) 45 | } catch (error) { 46 | log('error', 'Failed to initialize API key storage', error) 47 | throw error 48 | } 49 | } 50 | 51 | private async loadFromDisk(): Promise<{ keys: ApiKey[] }> { 52 | try { 53 | await this.acquireLock() 54 | const data = await fs.readJson(this.filePath) 55 | await this.releaseLock() 56 | 57 | if (!data.keys || !Array.isArray(data.keys)) { 58 | return { keys: [] } 59 | } 60 | 61 | return data 62 | } catch (error) { 63 | await this.releaseLock() 64 | log('error', 'Failed to read API keys file', error) 65 | return { keys: [] } 66 | } 67 | } 68 | 69 | private async acquireLock(maxRetries: number = 10, retryDelay: number = 100): Promise { 70 | for (let i = 0; i < maxRetries; i++) { 71 | try { 72 | await fs.writeFile(this.lockFile, process.pid.toString(), { flag: 'wx' }) 73 | return 74 | } catch (error) { 75 | if (i === maxRetries - 1) { 76 | throw new Error('Failed to acquire lock on API keys file') 77 | } 78 | await new Promise(resolve => setTimeout(resolve, retryDelay)) 79 | } 80 | } 81 | } 82 | 83 | private async releaseLock(): Promise { 84 | try { 85 | await fs.remove(this.lockFile) 86 | } catch (error) { 87 | log('warn', 'Failed to release lock file', error) 88 | } 89 | } 90 | 91 | private async safeWrite(data: { keys: ApiKey[] }): Promise { 92 | await this.initialize() 93 | 94 | try { 95 | await this.acquireLock() 96 | await fs.writeJson(this.filePath, data, { spaces: 2 }) 97 | await this.releaseLock() 98 | return true 99 | } catch (error) { 100 | await this.releaseLock() 101 | log('error', 'Failed to write API keys file', error) 102 | return false 103 | } 104 | } 105 | 106 | public async listKeys(): Promise { 107 | await this.initialize() 108 | return Array.from(this.keysCache.values()) 109 | } 110 | 111 | public async getKeyByUsername(username: string): Promise { 112 | await this.initialize() 113 | for (const key of this.keysCache.values()) { 114 | if (key.username === username) { 115 | return key 116 | } 117 | } 118 | return null 119 | } 120 | 121 | public async getKeyById(id: string): Promise { 122 | await this.initialize() 123 | for (const key of this.keysCache.values()) { 124 | if (key.id === id) { 125 | return key 126 | } 127 | } 128 | return null 129 | } 130 | 131 | public async createKey(username: string): Promise<{ success: boolean; message: string; key?: ApiKey }> { 132 | await this.initialize() 133 | 134 | if (!username || username.trim().length === 0) { 135 | return { success: false, message: 'Username is required' } 136 | } 137 | 138 | // Check cache for existing username 139 | for (const key of this.keysCache.values()) { 140 | if (key.username === username.trim()) { 141 | return { success: false, message: 'API key already exists for this username' } 142 | } 143 | } 144 | 145 | const newKey: ApiKey = { 146 | id: crypto.randomBytes(16).toString('hex'), 147 | username: username.trim(), 148 | key: `sk-${crypto.randomBytes(32).toString('hex')}`, 149 | createdAt: new Date().toISOString() 150 | } 151 | 152 | // Write to disk 153 | const allKeys = Array.from(this.keysCache.values()) 154 | allKeys.push(newKey) 155 | const writeSuccess = await this.safeWrite({ keys: allKeys }) 156 | 157 | if (writeSuccess) { 158 | // Update cache 159 | this.keysCache.set(newKey.key, newKey) 160 | log('info', `Created API key for user: ${username}`) 161 | return { success: true, message: 'API key created successfully', key: newKey } 162 | } else { 163 | return { success: false, message: 'Failed to save API key' } 164 | } 165 | } 166 | 167 | public async deleteKey(id: string): Promise<{ success: boolean; message: string }> { 168 | await this.initialize() 169 | 170 | // Find key in cache 171 | let keyToDelete: ApiKey | null = null 172 | for (const key of this.keysCache.values()) { 173 | if (key.id === id) { 174 | keyToDelete = key 175 | break 176 | } 177 | } 178 | 179 | if (!keyToDelete) { 180 | return { success: false, message: 'API key not found' } 181 | } 182 | 183 | // Remove from cache and write to disk 184 | const allKeys = Array.from(this.keysCache.values()).filter(k => k.id !== id) 185 | const writeSuccess = await this.safeWrite({ keys: allKeys }) 186 | 187 | if (writeSuccess) { 188 | // Update cache 189 | this.keysCache.delete(keyToDelete.key) 190 | log('info', `Deleted API key for user: ${keyToDelete.username}`) 191 | return { success: true, message: 'API key deleted successfully' } 192 | } else { 193 | return { success: false, message: 'Failed to delete API key' } 194 | } 195 | } 196 | 197 | public async validateKey(key: string): Promise { 198 | await this.initialize() 199 | return this.keysCache.get(key) || null 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview (llm-proxy) 6 | 7 | `llm-proxy` is a small Node.js + TypeScript + Express service that: 8 | 9 | - Issues JWT bearer tokens for an admin API (`POST /auth/token`). 10 | - Manages Nginx configuration and TLS certificates (Let’s Encrypt via `certbot`, Cloudflare DNS challenge). 11 | - Proxies OpenAI-style API requests under `/v1/*` to one of multiple upstream LLM endpoints. 12 | - Aggregates upstream model lists into a single `GET /v1/models` endpoint. 13 | 14 | Primary entrypoint: `src/index.ts`. 15 | 16 | ## CRITICAL: Engineering Standards & Operational Protocol 17 | 18 | **THIS PROJECT REQUIRES TECHNICAL RIGOR. READ THIS SECTION COMPLETELY BEFORE MAKING ANY CODE CHANGES.** 19 | 20 | ### PRIMARY DIRECTIVE: VERIFICATION OVER ASSUMPTION 21 | 22 | **THE MOST IMPORTANT RULE: VERIFY EVERYTHING BY READING THE ACTUAL CODE** 23 | 24 | Before using ANY function, type, method, class, or import: 25 | 26 | 1. **READ THE SOURCE FILE** where it is defined 27 | 2. **VERIFY THE EXACT SIGNATURE** (parameters, return types, visibility) 28 | 3. **CONFIRM IT EXISTS** (do not assume based on naming conventions) 29 | 4. **UNDERSTAND ITS BEHAVIOR** (read the implementation if unclear) 30 | 31 | **NEVER GUESS.** The only way to know for certain how something works is to read the code where it's defined. 32 | 33 | ### Verification Protocol for Code References 34 | 35 | ``` 36 | BEFORE using any import, function, type, or method: 37 | 38 | STEP 1: LOCATE THE DEFINITION 39 | - Read the file where it's defined 40 | - If unsure of location, search the repo 41 | - For third-party libraries, read their .d.ts or official docs 42 | 43 | STEP 2: VERIFY THE SIGNATURE 44 | - Parameter names, types, and order 45 | - Return type 46 | - Optional vs required 47 | - Exported vs internal 48 | 49 | STEP 3: UNDERSTAND THE BEHAVIOR 50 | - Read implementation 51 | - Identify edge cases + error conditions 52 | 53 | STEP 4: VERIFY COMPATIBILITY 54 | - Ensure intended usage matches the verified signature 55 | 56 | IF ANY STEP FAILS OR IS UNCERTAIN: 57 | - DO NOT PROCEED with assumptions 58 | - Read more code and/or consult official docs 59 | ``` 60 | 61 | ### TypeScript Standards (repo applicable) 62 | 63 | - `.d.ts` files are the source of truth for third-party library types. 64 | - Direct property access on union types `A | B` is forbidden unless that property exists on **all** union members. 65 | - Prefer type narrowing (`typeof`, `instanceof`, `in`, custom type guards) over type assertions. 66 | - Use `async/await` and handle promise rejections via `try/catch`. 67 | 68 | ## Repository Layout (verified) 69 | 70 | - `src/index.ts` – Express bootstrap and route wiring. 71 | - `src/controllers/auth.ts` – Token issuing endpoint. 72 | - `src/controllers/nginx.ts` – Nginx admin API routes. 73 | - `src/controllers/llm.ts` – `/v1/models` aggregation and request proxying. 74 | - `src/utils/auth.ts` – `tokenMiddleware` (JWT bearer verification). 75 | - `src/utils/nginx.ts` – `NginxManager` (nginx start/reload, config read/write, certbot obtain/renew). 76 | - `src/static/nginx-server-template.conf` – Nginx server block template used by `write-default`. 77 | 78 | ## Development Commands (verified: `package.json`) 79 | 80 | ```bash 81 | # Dev server 82 | npm run dev 83 | 84 | # Build 85 | npm run build 86 | 87 | # Start built output 88 | npm start 89 | ``` 90 | 91 | Notes: 92 | - `build` outputs to `dist/` and copies `src/static/` → `dist/static/`. 93 | - `test` is a placeholder script that exits 1. 94 | 95 | ## Runtime Behavior (verified) 96 | 97 | ### Express bootstrap 98 | 99 | Defined in `src/index.ts`: 100 | 101 | - Loads env from `.env` via `dotenv.config()`. 102 | - Uses `body-parser` JSON + urlencoded parsing with `PAYLOAD_LIMIT` (default: `1mb`). 103 | - Listens on `PORT` (default: `8080`). 104 | 105 | ### Authentication 106 | 107 | - `POST /auth/token` is **not** protected by `tokenMiddleware`. 108 | - All `/nginx/*` and `/v1/*` routes configured in `src/index.ts` are protected by `tokenMiddleware`. 109 | 110 | Token issuing behavior (`src/controllers/auth.ts`): 111 | - Validates JSON body `{ username, password }` against `AUTH_USERNAME` / `AUTH_PASSWORD`. 112 | - Signs an HS256 JWT using `JWT_SECRET`. 113 | 114 | Token verification behavior (`src/utils/auth.ts`): 115 | - Requires `Authorization: Bearer `. 116 | - Uses `jwt.verify(..., { algorithms: ['HS256'], ignoreExpiration: true })`. 117 | - Expiration is not enforced by this middleware; treat `JWT_SECRET` rotation as the primary invalidation mechanism. 118 | 119 | ### LLM proxying + model aggregation 120 | 121 | Defined in `src/controllers/llm.ts`: 122 | 123 | - `GET /v1/models` returns an aggregated list from upstreams. 124 | - Upstream model lists are refreshed in a loop (every 60 seconds) via `cacheModels()`. 125 | - Proxy forwarding is implemented in `forwardPostRequest()` and only triggers when: 126 | - `req.method === 'POST'` 127 | - path starts with `v1` or `/v1` 128 | - `req.body.model` exists 129 | - `TARGET_URLS` is non-empty 130 | 131 | Upstream selection: 132 | - `TARGET_URLS` is comma-separated. 133 | - Each entry may be `http(s)://host:port[/v1]|api-key`. 134 | - Upstream models are cached by `md5(model.id)`. 135 | - Incoming request chooses upstream by hashing `req.body.model` with `md5` and looking up that hash in `modelCache`. 136 | - If no cached match exists, falls back to the first `TARGET_URLS` entry. 137 | 138 | Request forwarding: 139 | - Uses axios with `responseType: 'stream'` and pipes upstream response to the client. 140 | - If upstream entry includes `|api-key`, outgoing request sets `Authorization: Bearer ` (overriding any incoming Authorization header). 141 | 142 | ### Nginx management and certificates 143 | 144 | Routes are defined in `src/controllers/nginx.ts` and backed by `src/utils/nginx.ts`. 145 | 146 | Protected routes: 147 | - `GET /nginx/reload` 148 | - `POST /nginx/config/update` (expects `{ config: string }`) 149 | - `GET /nginx/config/get` 150 | - `GET /nginx/config/get-default` 151 | - `POST /nginx/config/write-default` (expects `{ domain: string, cidrGroups: string[] }`) 152 | - `POST /nginx/certificates/obtain` (expects `{ domains: string[] }`) 153 | - `GET /nginx/certificates/renew` 154 | 155 | Template behavior (`src/static/nginx-server-template.conf` + `NginxManager.writeDefaultTemplate()`): 156 | - Replaces `{{domainName}}` with the requested domain. 157 | - Replaces `{{allowedIPs}}` with a list of `allow ;` entries. 158 | - Adds `deny all;` after the allow list for the `/(auth|nginx)/*` location. 159 | 160 | Cert issuance behavior (`NginxManager.obtainCertificates()`): 161 | - Uses `certbot certonly ... --preferred-challenges dns-01`. 162 | - When called with `cloudflare === true` (current controller always passes `true`): 163 | - Adds `--dns-cloudflare --dns-cloudflare-credentials /opt/cloudflare/credentials`. 164 | - The command currently hardcodes the email as `email@email.com`. 165 | 166 | ## Environment Variables (verified) 167 | 168 | From `src/index.ts`, `src/controllers/auth.ts`, `src/utils/auth.ts`, and `example.env`: 169 | 170 | - `PORT` (default `8080`) 171 | - `PAYLOAD_LIMIT` (default `1mb`) 172 | - `TARGET_URLS` (comma-separated upstreams; each may include `|api-key`) 173 | - `JWT_SECRET` 174 | - `AUTH_USERNAME` 175 | - `AUTH_PASSWORD` 176 | 177 | ## Docker / Deployment (verified) 178 | 179 | - `Dockerfile` installs: `nginx`, `certbot`, `python3-certbot-dns-cloudflare`. 180 | - Image exposes ports `8080` (Node) and `443` (Nginx). 181 | - `docker-compose.yml` mounts: 182 | - `.env` → `/app/.env` 183 | - `./cloudflare_credentials` → `/opt/cloudflare/credentials` 184 | - `./nginx` → `/etc/nginx/conf.d` 185 | - `./certs` → `/etc/letsencrypt` 186 | 187 | ## Common Gotchas (repo-specific) 188 | 189 | - The Node app only forwards **POST** requests and only when `req.body.model` is present; other requests fall through. 190 | - `tokenMiddleware` uses `ignoreExpiration: true`. 191 | - `TARGET_URLS` parsing (`getPath()`) falls back to base `http://localhost` and path `/v1` when URL parsing fails. 192 | - Nginx is started by Node via `exec('nginx')` (`NginxController.start()` → `NginxManager.start()`). 193 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # LLM Proxy 2 | 3 | Manages Nginx for reverse proxy to multiple LLMs, with TLS & Bearer Auth tokens. Deployed with docker. 4 | 5 | ## Features 6 | 7 | - **LLM API Aggregation**: Aggregates multiple OpenAI-type LLM APIs into a single endpoint 8 | - **API Key Management**: Web-based UI for creating and managing API keys with user associations 9 | - **Request Analytics**: Track and analyze API usage per key with CSV-based storage 10 | - **TLS/SSL Support**: Automatic Let's Encrypt certificates via certbot with Cloudflare DNS validation 11 | - **Nginx Reverse Proxy**: Production-grade reverse proxy with IP restriction 12 | - **Secure Authentication**: HttpOnly cookie-based JWT authentication for web UI, Bearer tokens for API 13 | - **Input Validation**: Comprehensive request validation using Joi 14 | - **CORS Configuration**: Configurable cross-origin resource sharing with credential support 15 | - **Request Tracking**: Per-API-key request analytics with success/failure tracking 16 | 17 | ***All requests to `/v1/*` are proxied to the LLM APIs except for `/v1/models`*** 18 | 19 | `/v1/models` is a special endpoint that returns the aggregated list of models available from all configured LLM APIs. 20 | 21 | ## Quick Start 22 | 23 | ### Prerequisites 24 | 25 | 1. **Cloudflare Account** (free): Create an API Token with "Zone", "DNS", "Edit" permissions 26 | 2. **DNS Setup**: Point your domain to your server's IP address 27 | 3. **Port Forwarding**: Forward port 443 on your router 28 | 29 | ### Installation 30 | 31 | 1. Create required files (see below) 32 | 2. Start with docker-compose: `docker-compose up -d` 33 | 3. Access web UI at `http://your-server-ip:8080/admin` 34 | 4. Configure certificates and nginx (see Routes section) 35 | 36 | ## Configuration 37 | 38 | ### Docker Compose 39 | 40 | Create `docker-compose.yml`: 41 | 42 | ```yaml 43 | version: '3.6' 44 | 45 | services: 46 | llmp: 47 | image: ghcr.io/j4ys0n/llm-proxy:latest 48 | container_name: llmp 49 | hostname: llmp 50 | restart: unless-stopped 51 | ports: 52 | - 8080:8080 53 | - 443:443 54 | volumes: 55 | - .env:/app/.env 56 | - ./data:/app/data 57 | - ./cloudflare_credentials:/opt/cloudflare/credentials 58 | - ./nginx:/etc/nginx/conf.d 59 | - ./certs:/etc/letsencrypt 60 | ``` 61 | 62 | ### Environment Variables 63 | 64 | Create `.env`: 65 | 66 | ```bash 67 | # Server Configuration 68 | PORT=8080 # Node.js listen port (don't change if using default nginx config) 69 | PAYLOAD_LIMIT=1mb # Maximum request payload size 70 | 71 | # LLM API Endpoints 72 | TARGET_URLS=http://localhost:1234,http://192.168.1.100:1234|api-key-here 73 | # Format: url1,url2|api-key,url3 74 | # /v1 path is optional and will be added automatically 75 | # API keys are optional, separated by | 76 | 77 | # Authentication 78 | JWT_SECRET=randomly_generated_secret_change_this # REQUIRED: Use a strong, random secret 79 | AUTH_USERNAME=admin # Web UI admin username 80 | AUTH_PASSWORD=secure_password_change_this # Web UI admin password 81 | 82 | # CORS (Optional) 83 | CORS_ORIGIN=true # Set to specific origin in production (e.g., https://yourdomain.com) 84 | 85 | # Environment 86 | NODE_ENV=production # Set to 'production' for HTTPS-only cookies 87 | ``` 88 | 89 | ### Cloudflare Credentials 90 | 91 | Create `cloudflare_credentials`: 92 | 93 | ```bash 94 | dns_cloudflare_api_token = your_token_here 95 | ``` 96 | 97 | ## Security Practices 98 | 99 | ### Secret Management 100 | 101 | **CRITICAL**: The following secrets must be changed from defaults: 102 | 103 | - `JWT_SECRET`: Use a cryptographically strong random string (minimum 32 characters) 104 | - Generate with: `openssl rand -base64 32` 105 | - Rotate periodically (requires re-authentication of all users) 106 | - `AUTH_PASSWORD`: Use a strong password (minimum 12 characters, mixed case, numbers, symbols) 107 | 108 | **Best Practices**: 109 | - Never commit `.env` files to version control 110 | - In production, use a secrets management service (HashiCorp Vault, AWS Secrets Manager, etc.) 111 | - Restrict file permissions: `chmod 600 .env cloudflare_credentials` 112 | - Use different secrets for development and production 113 | - Rotate API keys regularly 114 | - Monitor failed authentication attempts 115 | 116 | ### Cookie Security 117 | 118 | The application uses HttpOnly cookies for web UI authentication: 119 | - `httpOnly: true` - Prevents JavaScript access (XSS protection) 120 | - `secure: true` (production) - HTTPS-only transmission 121 | - `sameSite: 'strict'` - CSRF protection 122 | - 24-hour expiration 123 | 124 | ### API Key Security 125 | 126 | - API keys are 64-character hex strings (cryptographically random) 127 | - Stored with username associations for audit trails 128 | - Can be revoked instantly (in-memory validation) 129 | - All API requests tracked per key 130 | 131 | ### Network Security 132 | 133 | - Admin routes (`/auth`, `/nginx`, `/api/keys`, `/api/analytics`) are IP-restricted via nginx 134 | - CIDR group configuration allows granular access control 135 | - CORS configured with credential support 136 | - Input validation on all endpoints 137 | 138 | ## Web Interface 139 | 140 | Access the management UI at: 141 | - `http://your-server-ip:8080/admin` (before TLS setup) 142 | - `https://your.domain.com/admin` (after TLS setup) 143 | 144 | ### Features 145 | 146 | 1. **Login**: HttpOnly cookie-based authentication 147 | 2. **API Key Management**: 148 | - Create keys with username association 149 | - View all keys with creation dates 150 | - Copy keys to clipboard 151 | - Delete/revoke keys 152 | 3. **Analytics Dashboard**: 153 | - View request statistics per API key 154 | - Last week of data displayed by default 155 | - Custom date range filtering 156 | - Success/failure indicators 157 | - Elapsed time per request 158 | 159 | ## API Endpoints 160 | 161 | ### Authentication 162 | 163 | #### POST `/auth/login` 164 | Cookie-based login for web UI (recommended). 165 | 166 | **Request**: 167 | ```json 168 | { 169 | "username": "admin", 170 | "password": "secure_password" 171 | } 172 | ``` 173 | 174 | **Response**: 175 | ```json 176 | { 177 | "success": true, 178 | "username": "admin" 179 | } 180 | ``` 181 | 182 | Sets `auth_token` HttpOnly cookie. 183 | 184 | #### POST `/auth/logout` 185 | Clears authentication cookie. 186 | 187 | **Response**: 188 | ```json 189 | { 190 | "success": true 191 | } 192 | ``` 193 | 194 | #### POST `/auth/token` (Legacy) 195 | Returns JWT token in JSON (for API clients). 196 | 197 | **Request**: 198 | ```json 199 | { 200 | "username": "admin", 201 | "password": "secure_password" 202 | } 203 | ``` 204 | 205 | **Response**: 206 | ```json 207 | { 208 | "token": "jwt_token_here" 209 | } 210 | ``` 211 | 212 | ### API Key Management 213 | 214 | *All endpoints require authentication (cookie or Bearer token).* 215 | 216 | #### GET `/api/keys` 217 | List all API keys. 218 | 219 | **Response**: 220 | ```json 221 | { 222 | "success": true, 223 | "keys": [ 224 | { 225 | "id": "unique_id", 226 | "key": "64_char_hex_string", 227 | "username": "user1", 228 | "createdAt": "2025-12-17T00:00:00.000Z" 229 | } 230 | ] 231 | } 232 | ``` 233 | 234 | #### POST `/api/keys` 235 | Create a new API key. 236 | 237 | **Request**: 238 | ```json 239 | { 240 | "username": "user1" 241 | } 242 | ``` 243 | 244 | **Validation**: 245 | - Username: 3-50 characters, alphanumeric only 246 | 247 | **Response**: 248 | ```json 249 | { 250 | "success": true, 251 | "key": { 252 | "id": "unique_id", 253 | "key": "64_char_hex_string", 254 | "username": "user1", 255 | "createdAt": "2025-12-17T00:00:00.000Z" 256 | } 257 | } 258 | ``` 259 | 260 | #### DELETE `/api/keys/:id` 261 | Delete an API key (instant revocation). 262 | 263 | **Response**: 264 | ```json 265 | { 266 | "success": true, 267 | "message": "API key deleted successfully" 268 | } 269 | ``` 270 | 271 | ### Analytics 272 | 273 | #### GET `/api/analytics/:keyId?startDate=X&endDate=Y` 274 | Get request analytics for an API key. 275 | 276 | **Parameters**: 277 | - `keyId`: API key (64-char hex string) 278 | - `startDate`: (Optional) Start timestamp in epoch milliseconds 279 | - `endDate`: (Optional) End timestamp in epoch milliseconds 280 | 281 | **Response**: 282 | ```json 283 | { 284 | "success": true, 285 | "records": [ 286 | { 287 | "startTime": 1734400000000, 288 | "endTime": 1734400001234 289 | }, 290 | { 291 | "startTime": 1734400002000, 292 | "endTime": null 293 | } 294 | ] 295 | } 296 | ``` 297 | 298 | **Notes**: 299 | - Without date range, returns last 7 days 300 | - `endTime: null` indicates failed request 301 | - Timestamps in epoch milliseconds 302 | 303 | ### Nginx Management 304 | 305 | *All endpoints require authentication via Bearer token.* 306 | 307 | #### POST `/nginx/certificates/obtain` 308 | Obtain Let's Encrypt certificates. 309 | 310 | **Request**: 311 | ```json 312 | { 313 | "domains": ["your.domain.com"] 314 | } 315 | ``` 316 | 317 | **Response**: 318 | ```json 319 | { 320 | "success": true, 321 | "message": "Certificates obtained successfully." 322 | } 323 | ``` 324 | 325 | #### GET `/nginx/certificates/renew` 326 | Renew all certificates. 327 | 328 | #### POST `/nginx/config/write-default` 329 | Write default nginx config with IP restrictions. 330 | 331 | **Request**: 332 | ```json 333 | { 334 | "domain": "your.domain.com", 335 | "cidrGroups": ["192.168.1.0/24"] 336 | } 337 | ``` 338 | 339 | **CIDR Examples**: 340 | - `192.168.1.0/24` - Allows 192.168.1.1 through 192.168.1.254 341 | - `192.168.1.111/32` - Only allows 192.168.1.111 342 | - Multiple groups supported for complex networks 343 | 344 | **Response**: 345 | ```json 346 | { 347 | "success": true, 348 | "message": "Default config written successfully" 349 | } 350 | ``` 351 | 352 | #### GET `/nginx/reload` 353 | Reload nginx configuration. 354 | 355 | #### GET `/nginx/config/get` 356 | Get current nginx config as string. 357 | 358 | #### POST `/nginx/config/update` 359 | Update nginx config with custom configuration. 360 | 361 | **Request**: 362 | ```json 363 | { 364 | "config": "nginx_config_string_here" 365 | } 366 | ``` 367 | 368 | #### GET `/nginx/config/get-default` 369 | Get default nginx config template. 370 | 371 | ### LLM Proxy Endpoints 372 | 373 | #### GET `/v1/models` 374 | Aggregated model list from all configured LLM APIs. 375 | 376 | *Requires API key in Authorization header.* 377 | 378 | **Headers**: 379 | ``` 380 | Authorization: Bearer your_api_key_here 381 | ``` 382 | 383 | **Response**: 384 | ```json 385 | { 386 | "object": "list", 387 | "data": [ 388 | { 389 | "id": "model-name", 390 | "object": "model", 391 | "created": 1234567890, 392 | "owned_by": "organization" 393 | } 394 | ] 395 | } 396 | ``` 397 | 398 | #### POST `/v1/*` 399 | All other `/v1/*` requests are proxied to configured LLM APIs. 400 | 401 | *Requires API key in Authorization header.* 402 | 403 | **Request Routing**: 404 | 1. Extracts model from request body 405 | 2. Hashes model ID to select upstream API 406 | 3. Forwards request with streaming support 407 | 4. Tracks start/end time and success/failure 408 | 409 | ## Setup Flow 410 | 411 | 1. Start container with `docker-compose up -d` 412 | 2. Access web UI: `http://your-server-ip:8080/admin` 413 | 3. Login with `AUTH_USERNAME` and `AUTH_PASSWORD` 414 | 4. Use API to obtain certificates: 415 | ```bash 416 | curl -X POST http://192.168.1.100:8080/nginx/certificates/obtain \ 417 | -H "Authorization: Bearer $(curl -X POST http://192.168.1.100:8080/auth/token \ 418 | -H "Content-Type: application/json" \ 419 | -d '{"username":"admin","password":"secure_password"}' | jq -r '.token')" \ 420 | -H "Content-Type: application/json" \ 421 | -d '{"domains":["your.domain.com"]}' 422 | ``` 423 | 5. Write default config: 424 | ```bash 425 | curl -X POST http://192.168.1.100:8080/nginx/config/write-default \ 426 | -H "Authorization: Bearer YOUR_TOKEN" \ 427 | -H "Content-Type: application/json" \ 428 | -d '{"domain":"your.domain.com","cidrGroups":["192.168.1.0/24"]}' 429 | ``` 430 | 6. Reload nginx: 431 | ```bash 432 | curl http://192.168.1.100:8080/nginx/reload \ 433 | -H "Authorization: Bearer YOUR_TOKEN" 434 | ``` 435 | 7. Access at `https://your.domain.com/admin` 436 | 437 | ## Development 438 | 439 | ### Prerequisites 440 | - Node.js 18+ 441 | - Yarn 442 | 443 | ### Commands 444 | 445 | ```bash 446 | # Install dependencies 447 | yarn install 448 | 449 | # Run tests 450 | yarn test 451 | 452 | # Development mode (auto-reload) 453 | yarn dev 454 | 455 | # Build 456 | yarn build 457 | 458 | # Start production build 459 | yarn start 460 | ``` 461 | 462 | ### Testing 463 | 464 | Tests use Jest with ts-jest: 465 | - Unit tests for utilities (`src/utils/__tests__`) 466 | - Controller tests (`src/controllers/__tests__`) 467 | - Run with `yarn test` 468 | 469 | ### Project Structure 470 | 471 | ``` 472 | llm-proxy/ 473 | ├── src/ 474 | │ ├── controllers/ # Route controllers 475 | │ │ ├── analytics.ts # Analytics endpoints 476 | │ │ ├── apikeys.ts # API key management 477 | │ │ ├── auth.ts # Authentication 478 | │ │ ├── llm.ts # LLM proxy logic 479 | │ │ └── nginx.ts # Nginx management 480 | │ ├── utils/ # Utilities 481 | │ │ ├── apikeys.ts # API key manager 482 | │ │ ├── auth.ts # Auth middleware 483 | │ │ ├── general.ts # Logging 484 | │ │ ├── nginx.ts # Nginx manager 485 | │ │ ├── requestTracker.ts # Analytics tracker 486 | │ │ └── validation.ts # Input validation 487 | │ ├── static/ # Static assets 488 | │ └── index.ts # App entry point 489 | ├── frontend/ # Vue 3 UI 490 | │ └── src/ 491 | │ └── App.vue # Main component 492 | └── Dockerfile # Multi-stage build 493 | 494 | ## Troubleshooting 495 | 496 | ### Authentication Issues 497 | - Ensure `JWT_SECRET` is set and consistent 498 | - Check cookie settings (secure flag requires HTTPS) 499 | - Verify CORS origin matches your domain 500 | 501 | ### Certificate Issues 502 | - Verify Cloudflare API token has DNS edit permissions 503 | - Check domain DNS points to correct IP 504 | - Ensure port 443 is forwarded 505 | 506 | ### API Key Issues 507 | - Keys are validated in-memory (restart clears validation cache) 508 | - Deleted keys are immediately invalid 509 | - Check Authorization header format: `Bearer ` 510 | 511 | ### Analytics Not Recording 512 | - Verify API key is valid 513 | - Check file permissions on data directory 514 | - Review logs for tracking errors 515 | 516 | ## License 517 | 518 | Apache-2.0 519 | 520 | ## Version 521 | 522 | Current version: 1.6.1 523 | 524 | ### Changelog 525 | 526 | **v1.6.1**: 527 | - Security improvements: HttpOnly cookie auth, input validation 528 | - Fixed clipboard implementation 529 | - Added CORS configuration 530 | - Comprehensive test coverage 531 | - Documentation updates 532 | 533 | **v1.6.0**: 534 | - API key management with Vue 3 frontend 535 | - Request analytics and tracking 536 | - In-memory API key caching 537 | - Per-key request history with CSV storage 538 | 539 | **v1.5.x**: 540 | - Initial release with JWT auth 541 | - Nginx management 542 | - LLM API aggregation 543 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 157 | 158 | 443 | 444 | 745 | -------------------------------------------------------------------------------- /frontend/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@babel/helper-string-parser@^7.27.1": 6 | version "7.27.1" 7 | resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" 8 | integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== 9 | 10 | "@babel/helper-validator-identifier@^7.28.5": 11 | version "7.28.5" 12 | resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" 13 | integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== 14 | 15 | "@babel/parser@^7.28.5": 16 | version "7.28.5" 17 | resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.5.tgz#0b0225ee90362f030efd644e8034c99468893b08" 18 | integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ== 19 | dependencies: 20 | "@babel/types" "^7.28.5" 21 | 22 | "@babel/types@^7.28.5": 23 | version "7.28.5" 24 | resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.5.tgz#10fc405f60897c35f07e85493c932c7b5ca0592b" 25 | integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== 26 | dependencies: 27 | "@babel/helper-string-parser" "^7.27.1" 28 | "@babel/helper-validator-identifier" "^7.28.5" 29 | 30 | "@esbuild/aix-ppc64@0.21.5": 31 | version "0.21.5" 32 | resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" 33 | integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== 34 | 35 | "@esbuild/android-arm64@0.21.5": 36 | version "0.21.5" 37 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" 38 | integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== 39 | 40 | "@esbuild/android-arm@0.21.5": 41 | version "0.21.5" 42 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" 43 | integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== 44 | 45 | "@esbuild/android-x64@0.21.5": 46 | version "0.21.5" 47 | resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" 48 | integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== 49 | 50 | "@esbuild/darwin-arm64@0.21.5": 51 | version "0.21.5" 52 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" 53 | integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== 54 | 55 | "@esbuild/darwin-x64@0.21.5": 56 | version "0.21.5" 57 | resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" 58 | integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== 59 | 60 | "@esbuild/freebsd-arm64@0.21.5": 61 | version "0.21.5" 62 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" 63 | integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== 64 | 65 | "@esbuild/freebsd-x64@0.21.5": 66 | version "0.21.5" 67 | resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" 68 | integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== 69 | 70 | "@esbuild/linux-arm64@0.21.5": 71 | version "0.21.5" 72 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" 73 | integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== 74 | 75 | "@esbuild/linux-arm@0.21.5": 76 | version "0.21.5" 77 | resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" 78 | integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== 79 | 80 | "@esbuild/linux-ia32@0.21.5": 81 | version "0.21.5" 82 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" 83 | integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== 84 | 85 | "@esbuild/linux-loong64@0.21.5": 86 | version "0.21.5" 87 | resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" 88 | integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== 89 | 90 | "@esbuild/linux-mips64el@0.21.5": 91 | version "0.21.5" 92 | resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" 93 | integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== 94 | 95 | "@esbuild/linux-ppc64@0.21.5": 96 | version "0.21.5" 97 | resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" 98 | integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== 99 | 100 | "@esbuild/linux-riscv64@0.21.5": 101 | version "0.21.5" 102 | resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" 103 | integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== 104 | 105 | "@esbuild/linux-s390x@0.21.5": 106 | version "0.21.5" 107 | resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" 108 | integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== 109 | 110 | "@esbuild/linux-x64@0.21.5": 111 | version "0.21.5" 112 | resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" 113 | integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== 114 | 115 | "@esbuild/netbsd-x64@0.21.5": 116 | version "0.21.5" 117 | resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" 118 | integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== 119 | 120 | "@esbuild/openbsd-x64@0.21.5": 121 | version "0.21.5" 122 | resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" 123 | integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== 124 | 125 | "@esbuild/sunos-x64@0.21.5": 126 | version "0.21.5" 127 | resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" 128 | integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== 129 | 130 | "@esbuild/win32-arm64@0.21.5": 131 | version "0.21.5" 132 | resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" 133 | integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== 134 | 135 | "@esbuild/win32-ia32@0.21.5": 136 | version "0.21.5" 137 | resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" 138 | integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== 139 | 140 | "@esbuild/win32-x64@0.21.5": 141 | version "0.21.5" 142 | resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" 143 | integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== 144 | 145 | "@jridgewell/sourcemap-codec@^1.5.5": 146 | version "1.5.5" 147 | resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" 148 | integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== 149 | 150 | "@rollup/rollup-android-arm-eabi@4.53.5": 151 | version "4.53.5" 152 | resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.5.tgz#3d12635170ef3d32aa9222b4fb92b5d5400f08e9" 153 | integrity sha512-iDGS/h7D8t7tvZ1t6+WPK04KD0MwzLZrG0se1hzBjSi5fyxlsiggoJHwh18PCFNn7tG43OWb6pdZ6Y+rMlmyNQ== 154 | 155 | "@rollup/rollup-android-arm64@4.53.5": 156 | version "4.53.5" 157 | resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.5.tgz#cd2a448be8fb337f6e5ca12a3053a407ac80766d" 158 | integrity sha512-wrSAViWvZHBMMlWk6EJhvg8/rjxzyEhEdgfMMjREHEq11EtJ6IP6yfcCH57YAEca2Oe3FNCE9DSTgU70EIGmVw== 159 | 160 | "@rollup/rollup-darwin-arm64@4.53.5": 161 | version "4.53.5" 162 | resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.5.tgz#651263a5eb362a3730f8d5df2a55d1dab8a6a720" 163 | integrity sha512-S87zZPBmRO6u1YXQLwpveZm4JfPpAa6oHBX7/ghSiGH3rz/KDgAu1rKdGutV+WUI6tKDMbaBJomhnT30Y2t4VQ== 164 | 165 | "@rollup/rollup-darwin-x64@4.53.5": 166 | version "4.53.5" 167 | resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.5.tgz#76956ce183eb461a58735770b7bf3030a549ceea" 168 | integrity sha512-YTbnsAaHo6VrAczISxgpTva8EkfQus0VPEVJCEaboHtZRIb6h6j0BNxRBOwnDciFTZLDPW5r+ZBmhL/+YpTZgA== 169 | 170 | "@rollup/rollup-freebsd-arm64@4.53.5": 171 | version "4.53.5" 172 | resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.5.tgz#287448b57d619007b14d34ed35bf1bc4f41c023b" 173 | integrity sha512-1T8eY2J8rKJWzaznV7zedfdhD1BqVs1iqILhmHDq/bqCUZsrMt+j8VCTHhP0vdfbHK3e1IQ7VYx3jlKqwlf+vw== 174 | 175 | "@rollup/rollup-freebsd-x64@4.53.5": 176 | version "4.53.5" 177 | resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.5.tgz#e6dca813e189aa189dab821ea8807f48873b80f2" 178 | integrity sha512-sHTiuXyBJApxRn+VFMaw1U+Qsz4kcNlxQ742snICYPrY+DDL8/ZbaC4DVIB7vgZmp3jiDaKA0WpBdP0aqPJoBQ== 179 | 180 | "@rollup/rollup-linux-arm-gnueabihf@4.53.5": 181 | version "4.53.5" 182 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.5.tgz#74045a96fa6c5b1b1269440a68d1496d34b1730a" 183 | integrity sha512-dV3T9MyAf0w8zPVLVBptVlzaXxka6xg1f16VAQmjg+4KMSTWDvhimI/Y6mp8oHwNrmnmVl9XxJ/w/mO4uIQONA== 184 | 185 | "@rollup/rollup-linux-arm-musleabihf@4.53.5": 186 | version "4.53.5" 187 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.5.tgz#7d175bddc9acffc40431ee3fb9417136ccc499e1" 188 | integrity sha512-wIGYC1x/hyjP+KAu9+ewDI+fi5XSNiUi9Bvg6KGAh2TsNMA3tSEs+Sh6jJ/r4BV/bx/CyWu2ue9kDnIdRyafcQ== 189 | 190 | "@rollup/rollup-linux-arm64-gnu@4.53.5": 191 | version "4.53.5" 192 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.5.tgz#228b0aec95b24f4080175b51396cd14cb275e38f" 193 | integrity sha512-Y+qVA0D9d0y2FRNiG9oM3Hut/DgODZbU9I8pLLPwAsU0tUKZ49cyV1tzmB/qRbSzGvY8lpgGkJuMyuhH7Ma+Vg== 194 | 195 | "@rollup/rollup-linux-arm64-musl@4.53.5": 196 | version "4.53.5" 197 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.5.tgz#079050e023fad9bbb95d1d36fcfad23eeb0e1caa" 198 | integrity sha512-juaC4bEgJsyFVfqhtGLz8mbopaWD+WeSOYr5E16y+1of6KQjc0BpwZLuxkClqY1i8sco+MdyoXPNiCkQou09+g== 199 | 200 | "@rollup/rollup-linux-loong64-gnu@4.53.5": 201 | version "4.53.5" 202 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.5.tgz#3849451858c4d5c8838b5e16ec339b8e49aaf68a" 203 | integrity sha512-rIEC0hZ17A42iXtHX+EPJVL/CakHo+tT7W0pbzdAGuWOt2jxDFh7A/lRhsNHBcqL4T36+UiAgwO8pbmn3dE8wA== 204 | 205 | "@rollup/rollup-linux-ppc64-gnu@4.53.5": 206 | version "4.53.5" 207 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.5.tgz#10bdab69c660f6f7b48e23f17c42637aa1f9b29a" 208 | integrity sha512-T7l409NhUE552RcAOcmJHj3xyZ2h7vMWzcwQI0hvn5tqHh3oSoclf9WgTl+0QqffWFG8MEVZZP1/OBglKZx52Q== 209 | 210 | "@rollup/rollup-linux-riscv64-gnu@4.53.5": 211 | version "4.53.5" 212 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.5.tgz#c0e776c6193369ee16f8e9ebf6a6ec09828d603d" 213 | integrity sha512-7OK5/GhxbnrMcxIFoYfhV/TkknarkYC1hqUw1wU2xUN3TVRLNT5FmBv4KkheSG2xZ6IEbRAhTooTV2+R5Tk0lQ== 214 | 215 | "@rollup/rollup-linux-riscv64-musl@4.53.5": 216 | version "4.53.5" 217 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.5.tgz#6fcfb9084822036b9e4ff66e5c8b472d77226fae" 218 | integrity sha512-GwuDBE/PsXaTa76lO5eLJTyr2k8QkPipAyOrs4V/KJufHCZBJ495VCGJol35grx9xryk4V+2zd3Ri+3v7NPh+w== 219 | 220 | "@rollup/rollup-linux-s390x-gnu@4.53.5": 221 | version "4.53.5" 222 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.5.tgz#204bf1f758b65263adad3183d1ea7c9fc333e453" 223 | integrity sha512-IAE1Ziyr1qNfnmiQLHBURAD+eh/zH1pIeJjeShleII7Vj8kyEm2PF77o+lf3WTHDpNJcu4IXJxNO0Zluro8bOw== 224 | 225 | "@rollup/rollup-linux-x64-gnu@4.53.5": 226 | version "4.53.5" 227 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.5.tgz#704a927285c370b4481a77e5a6468ebc841f72ca" 228 | integrity sha512-Pg6E+oP7GvZ4XwgRJBuSXZjcqpIW3yCBhK4BcsANvb47qMvAbCjR6E+1a/U2WXz1JJxp9/4Dno3/iSJLcm5auw== 229 | 230 | "@rollup/rollup-linux-x64-musl@4.53.5": 231 | version "4.53.5" 232 | resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.5.tgz#3a7ccf6239f7efc6745b95075cf855b137cd0b52" 233 | integrity sha512-txGtluxDKTxaMDzUduGP0wdfng24y1rygUMnmlUJ88fzCCULCLn7oE5kb2+tRB+MWq1QDZT6ObT5RrR8HFRKqg== 234 | 235 | "@rollup/rollup-openharmony-arm64@4.53.5": 236 | version "4.53.5" 237 | resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.5.tgz#cb29644e4330b8d9aec0c594bf092222545db218" 238 | integrity sha512-3DFiLPnTxiOQV993fMc+KO8zXHTcIjgaInrqlG8zDp1TlhYl6WgrOHuJkJQ6M8zHEcntSJsUp1XFZSY8C1DYbg== 239 | 240 | "@rollup/rollup-win32-arm64-msvc@4.53.5": 241 | version "4.53.5" 242 | resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.5.tgz#bae9daf924900b600f6a53c0659b12cb2f6c33e4" 243 | integrity sha512-nggc/wPpNTgjGg75hu+Q/3i32R00Lq1B6N1DO7MCU340MRKL3WZJMjA9U4K4gzy3dkZPXm9E1Nc81FItBVGRlA== 244 | 245 | "@rollup/rollup-win32-ia32-msvc@4.53.5": 246 | version "4.53.5" 247 | resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.5.tgz#48002d2b9e4ab93049acd0d399c7aa7f7c5f363c" 248 | integrity sha512-U/54pTbdQpPLBdEzCT6NBCFAfSZMvmjr0twhnD9f4EIvlm9wy3jjQ38yQj1AGznrNO65EWQMgm/QUjuIVrYF9w== 249 | 250 | "@rollup/rollup-win32-x64-gnu@4.53.5": 251 | version "4.53.5" 252 | resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.5.tgz#aa0344b25dc31f2d822caf886786e377b45e660b" 253 | integrity sha512-2NqKgZSuLH9SXBBV2dWNRCZmocgSOx8OJSdpRaEcRlIfX8YrKxUT6z0F1NpvDVhOsl190UFTRh2F2WDWWCYp3A== 254 | 255 | "@rollup/rollup-win32-x64-msvc@4.53.5": 256 | version "4.53.5" 257 | resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.5.tgz#e0d19dffcf25f0fd86f50402a3413004003939e9" 258 | integrity sha512-JRpZUhCfhZ4keB5v0fe02gQJy05GqboPOaxvjugW04RLSYYoB/9t2lx2u/tMs/Na/1NXfY8QYjgRljRpN+MjTQ== 259 | 260 | "@types/estree@1.0.8": 261 | version "1.0.8" 262 | resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" 263 | integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== 264 | 265 | "@vitejs/plugin-vue@^5.0.0": 266 | version "5.2.4" 267 | resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz#9e8a512eb174bfc2a333ba959bbf9de428d89ad8" 268 | integrity sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA== 269 | 270 | "@vue/compiler-core@3.5.25": 271 | version "3.5.25" 272 | resolved "https://registry.yarnpkg.com/@vue/compiler-core/-/compiler-core-3.5.25.tgz#7ffb658d7919348baad8c491eb5b948ee8e44108" 273 | integrity sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw== 274 | dependencies: 275 | "@babel/parser" "^7.28.5" 276 | "@vue/shared" "3.5.25" 277 | entities "^4.5.0" 278 | estree-walker "^2.0.2" 279 | source-map-js "^1.2.1" 280 | 281 | "@vue/compiler-dom@3.5.25": 282 | version "3.5.25" 283 | resolved "https://registry.yarnpkg.com/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz#dd799ac2474cda54303039310b8994f0cfb40957" 284 | integrity sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q== 285 | dependencies: 286 | "@vue/compiler-core" "3.5.25" 287 | "@vue/shared" "3.5.25" 288 | 289 | "@vue/compiler-sfc@3.5.25": 290 | version "3.5.25" 291 | resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz#30377920c3869c3bb32111aa4aefad53921831ad" 292 | integrity sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag== 293 | dependencies: 294 | "@babel/parser" "^7.28.5" 295 | "@vue/compiler-core" "3.5.25" 296 | "@vue/compiler-dom" "3.5.25" 297 | "@vue/compiler-ssr" "3.5.25" 298 | "@vue/shared" "3.5.25" 299 | estree-walker "^2.0.2" 300 | magic-string "^0.30.21" 301 | postcss "^8.5.6" 302 | source-map-js "^1.2.1" 303 | 304 | "@vue/compiler-ssr@3.5.25": 305 | version "3.5.25" 306 | resolved "https://registry.yarnpkg.com/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz#51dd89b88a1e044d1beab158c91a29963d28eb96" 307 | integrity sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A== 308 | dependencies: 309 | "@vue/compiler-dom" "3.5.25" 310 | "@vue/shared" "3.5.25" 311 | 312 | "@vue/reactivity@3.5.25": 313 | version "3.5.25" 314 | resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.5.25.tgz#2420fa02022dab3373033c955802b9cdab5435ad" 315 | integrity sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA== 316 | dependencies: 317 | "@vue/shared" "3.5.25" 318 | 319 | "@vue/runtime-core@3.5.25": 320 | version "3.5.25" 321 | resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.5.25.tgz#5e524db201b419db6f091db440452fe4e49efdee" 322 | integrity sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA== 323 | dependencies: 324 | "@vue/reactivity" "3.5.25" 325 | "@vue/shared" "3.5.25" 326 | 327 | "@vue/runtime-dom@3.5.25": 328 | version "3.5.25" 329 | resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz#ebd9815f39ee70fe32698c615cc09bda604e4e06" 330 | integrity sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA== 331 | dependencies: 332 | "@vue/reactivity" "3.5.25" 333 | "@vue/runtime-core" "3.5.25" 334 | "@vue/shared" "3.5.25" 335 | csstype "^3.1.3" 336 | 337 | "@vue/server-renderer@3.5.25": 338 | version "3.5.25" 339 | resolved "https://registry.yarnpkg.com/@vue/server-renderer/-/server-renderer-3.5.25.tgz#ca67ac93cb84dd3c3bc2f89c046a18ab04f7cc96" 340 | integrity sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ== 341 | dependencies: 342 | "@vue/compiler-ssr" "3.5.25" 343 | "@vue/shared" "3.5.25" 344 | 345 | "@vue/shared@3.5.25": 346 | version "3.5.25" 347 | resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.25.tgz#21edcff133a5a04f72c4e4c6142260963fe5afbe" 348 | integrity sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg== 349 | 350 | csstype@^3.1.3: 351 | version "3.2.3" 352 | resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" 353 | integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== 354 | 355 | entities@^4.5.0: 356 | version "4.5.0" 357 | resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" 358 | integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== 359 | 360 | esbuild@^0.21.3: 361 | version "0.21.5" 362 | resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" 363 | integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== 364 | optionalDependencies: 365 | "@esbuild/aix-ppc64" "0.21.5" 366 | "@esbuild/android-arm" "0.21.5" 367 | "@esbuild/android-arm64" "0.21.5" 368 | "@esbuild/android-x64" "0.21.5" 369 | "@esbuild/darwin-arm64" "0.21.5" 370 | "@esbuild/darwin-x64" "0.21.5" 371 | "@esbuild/freebsd-arm64" "0.21.5" 372 | "@esbuild/freebsd-x64" "0.21.5" 373 | "@esbuild/linux-arm" "0.21.5" 374 | "@esbuild/linux-arm64" "0.21.5" 375 | "@esbuild/linux-ia32" "0.21.5" 376 | "@esbuild/linux-loong64" "0.21.5" 377 | "@esbuild/linux-mips64el" "0.21.5" 378 | "@esbuild/linux-ppc64" "0.21.5" 379 | "@esbuild/linux-riscv64" "0.21.5" 380 | "@esbuild/linux-s390x" "0.21.5" 381 | "@esbuild/linux-x64" "0.21.5" 382 | "@esbuild/netbsd-x64" "0.21.5" 383 | "@esbuild/openbsd-x64" "0.21.5" 384 | "@esbuild/sunos-x64" "0.21.5" 385 | "@esbuild/win32-arm64" "0.21.5" 386 | "@esbuild/win32-ia32" "0.21.5" 387 | "@esbuild/win32-x64" "0.21.5" 388 | 389 | estree-walker@^2.0.2: 390 | version "2.0.2" 391 | resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" 392 | integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== 393 | 394 | fsevents@~2.3.2, fsevents@~2.3.3: 395 | version "2.3.3" 396 | resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" 397 | integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== 398 | 399 | magic-string@^0.30.21: 400 | version "0.30.21" 401 | resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" 402 | integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== 403 | dependencies: 404 | "@jridgewell/sourcemap-codec" "^1.5.5" 405 | 406 | nanoid@^3.3.11: 407 | version "3.3.11" 408 | resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" 409 | integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== 410 | 411 | picocolors@^1.1.1: 412 | version "1.1.1" 413 | resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" 414 | integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== 415 | 416 | postcss@^8.4.43, postcss@^8.5.6: 417 | version "8.5.6" 418 | resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" 419 | integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== 420 | dependencies: 421 | nanoid "^3.3.11" 422 | picocolors "^1.1.1" 423 | source-map-js "^1.2.1" 424 | 425 | rollup@^4.20.0: 426 | version "4.53.5" 427 | resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.53.5.tgz#820f46d435c207fd640256f34a0deadf8e95b118" 428 | integrity sha512-iTNAbFSlRpcHeeWu73ywU/8KuU/LZmNCSxp6fjQkJBD3ivUb8tpDrXhIxEzA05HlYMEwmtaUnb3RP+YNv162OQ== 429 | dependencies: 430 | "@types/estree" "1.0.8" 431 | optionalDependencies: 432 | "@rollup/rollup-android-arm-eabi" "4.53.5" 433 | "@rollup/rollup-android-arm64" "4.53.5" 434 | "@rollup/rollup-darwin-arm64" "4.53.5" 435 | "@rollup/rollup-darwin-x64" "4.53.5" 436 | "@rollup/rollup-freebsd-arm64" "4.53.5" 437 | "@rollup/rollup-freebsd-x64" "4.53.5" 438 | "@rollup/rollup-linux-arm-gnueabihf" "4.53.5" 439 | "@rollup/rollup-linux-arm-musleabihf" "4.53.5" 440 | "@rollup/rollup-linux-arm64-gnu" "4.53.5" 441 | "@rollup/rollup-linux-arm64-musl" "4.53.5" 442 | "@rollup/rollup-linux-loong64-gnu" "4.53.5" 443 | "@rollup/rollup-linux-ppc64-gnu" "4.53.5" 444 | "@rollup/rollup-linux-riscv64-gnu" "4.53.5" 445 | "@rollup/rollup-linux-riscv64-musl" "4.53.5" 446 | "@rollup/rollup-linux-s390x-gnu" "4.53.5" 447 | "@rollup/rollup-linux-x64-gnu" "4.53.5" 448 | "@rollup/rollup-linux-x64-musl" "4.53.5" 449 | "@rollup/rollup-openharmony-arm64" "4.53.5" 450 | "@rollup/rollup-win32-arm64-msvc" "4.53.5" 451 | "@rollup/rollup-win32-ia32-msvc" "4.53.5" 452 | "@rollup/rollup-win32-x64-gnu" "4.53.5" 453 | "@rollup/rollup-win32-x64-msvc" "4.53.5" 454 | fsevents "~2.3.2" 455 | 456 | source-map-js@^1.2.1: 457 | version "1.2.1" 458 | resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" 459 | integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== 460 | 461 | vite@^5.0.0: 462 | version "5.4.21" 463 | resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.21.tgz#84a4f7c5d860b071676d39ba513c0d598fdc7027" 464 | integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw== 465 | dependencies: 466 | esbuild "^0.21.3" 467 | postcss "^8.4.43" 468 | rollup "^4.20.0" 469 | optionalDependencies: 470 | fsevents "~2.3.3" 471 | 472 | vue@^3.4.0: 473 | version "3.5.25" 474 | resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.25.tgz#b68b5092b617c57a0a36e8e640fd2c09aa2a374d" 475 | integrity sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g== 476 | dependencies: 477 | "@vue/compiler-dom" "3.5.25" 478 | "@vue/compiler-sfc" "3.5.25" 479 | "@vue/runtime-dom" "3.5.25" 480 | "@vue/server-renderer" "3.5.25" 481 | "@vue/shared" "3.5.25" 482 | --------------------------------------------------------------------------------