├── .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 |
2 |
3 |
11 |
12 |
13 |
30 |
31 |
32 |
33 |
34 |
35 |
40 |
45 |
46 |
47 |
48 |
49 |
63 |
64 |
65 |
Existing API Keys
66 |
{{ listError }}
67 |
Loading API keys...
68 |
69 | No API keys found. Create one above to get started.
70 |
71 |
72 |
73 |
74 |
{{ key.username }}
75 |
{{ key.key }}
76 |
Created: {{ formatDate(key.createdAt) }}
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
Request Analytics
91 |
92 |
93 |
94 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
114 |
115 |
116 |
117 |
118 |
{{ analyticsError }}
119 |
120 |
Loading analytics...
121 |
122 |
123 | No analytics data found for the selected period.
124 |
125 |
126 |
127 |
128 |
129 |
130 | | Start Time |
131 | Elapsed (ms) |
132 | Status |
133 |
134 |
135 |
136 |
137 | | {{ formatAnalyticsDate(record.startTime) }} |
138 | {{ calculateElapsed(record) }} |
139 |
140 |
141 | {{ record.endTime === null ? 'Failed' : 'Success' }}
142 |
143 | |
144 |
145 |
146 |
147 |
148 | Total Requests: {{ analyticsRecords.length }} |
149 | Successful: {{ successfulRequests }} |
150 | Failed: {{ failedRequests }}
151 |
152 |
153 |
154 |
155 |
156 |
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 |
--------------------------------------------------------------------------------