ikanban-web 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -0
- package/bin/cli.js +1055 -0
- package/dist/apple-touch-icon-120x120.png +0 -0
- package/dist/apple-touch-icon-152x152.png +0 -0
- package/dist/apple-touch-icon-167x167.png +0 -0
- package/dist/apple-touch-icon-180x180.png +0 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/apple-touch-icon.svg +16 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/ToolOutputDialog-BgFj3J4u.js +5 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dist/assets/index-BQJPAP5f.css +1 -0
- package/dist/assets/index-MBuF9PXk.js +2 -0
- package/dist/assets/main-C1GggQif.css +1 -0
- package/dist/assets/main-Dyy8WvPJ.js +311 -0
- package/dist/assets/vendor--DEobTDja.css +1 -0
- package/dist/assets/vendor-.bun-KwPUu5oc.js +4594 -0
- package/dist/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/assets/worker-suRQxAtO.js +155 -0
- package/dist/favicon-16.png +0 -0
- package/dist/favicon-32.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +325 -0
- package/dist/logo-dark-192x192.png +0 -0
- package/dist/logo-dark-512x512.svg +16 -0
- package/dist/logo-light-192x192.png +0 -0
- package/dist/logo-light-512x512.svg +16 -0
- package/dist/pwa-192.png +0 -0
- package/dist/pwa-512.png +0 -0
- package/dist/pwa-maskable-192.png +0 -0
- package/dist/pwa-maskable-512.png +0 -0
- package/dist/site.webmanifest +22 -0
- package/dist/sw.js +1 -0
- package/package.json +102 -0
- package/public/apple-touch-icon-120x120.png +0 -0
- package/public/apple-touch-icon-152x152.png +0 -0
- package/public/apple-touch-icon-167x167.png +0 -0
- package/public/apple-touch-icon-180x180.png +0 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/apple-touch-icon.svg +16 -0
- package/public/favicon-16.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +15 -0
- package/public/logo-dark-192x192.png +0 -0
- package/public/logo-dark-512x512.svg +16 -0
- package/public/logo-light-192x192.png +0 -0
- package/public/logo-light-512x512.svg +16 -0
- package/public/pwa-192.png +0 -0
- package/public/pwa-512.png +0 -0
- package/public/pwa-maskable-192.png +0 -0
- package/public/pwa-maskable-512.png +0 -0
- package/public/site.webmanifest +22 -0
- package/server/TERMINAL_INPUT_WS_PROTOCOL.md +44 -0
- package/server/index.d.ts +28 -0
- package/server/index.js +11181 -0
- package/server/lib/cloudflare-tunnel.js +196 -0
- package/server/lib/git-credentials.js +74 -0
- package/server/lib/git-identity-storage.js +110 -0
- package/server/lib/git-service.js +2690 -0
- package/server/lib/github-auth.js +307 -0
- package/server/lib/github-device-flow.js +50 -0
- package/server/lib/github-octokit.js +10 -0
- package/server/lib/github-repo.js +55 -0
- package/server/lib/notification-message.js +49 -0
- package/server/lib/notification-message.test.js +59 -0
- package/server/lib/opencode-auth.js +81 -0
- package/server/lib/opencode-config.js +1840 -0
- package/server/lib/opencode-config.js.d.ts +12 -0
- package/server/lib/package-manager.js +255 -0
- package/server/lib/quota/DOCUMENTATION.md +52 -0
- package/server/lib/quota/index.js +21 -0
- package/server/lib/quota/providers/claude.js +107 -0
- package/server/lib/quota/providers/codex.js +111 -0
- package/server/lib/quota/providers/copilot.js +165 -0
- package/server/lib/quota/providers/google/api.js +92 -0
- package/server/lib/quota/providers/google/auth.js +108 -0
- package/server/lib/quota/providers/google/index.js +124 -0
- package/server/lib/quota/providers/google/transforms.js +109 -0
- package/server/lib/quota/providers/index.js +128 -0
- package/server/lib/quota/providers/interface.js +55 -0
- package/server/lib/quota/providers/kimi.js +108 -0
- package/server/lib/quota/providers/nanogpt.js +124 -0
- package/server/lib/quota/providers/openai.js +91 -0
- package/server/lib/quota/providers/openrouter.js +92 -0
- package/server/lib/quota/providers/zai.js +91 -0
- package/server/lib/quota/utils/auth.js +46 -0
- package/server/lib/quota/utils/formatters.js +76 -0
- package/server/lib/quota/utils/index.js +10 -0
- package/server/lib/quota/utils/transformers.js +55 -0
- package/server/lib/skills-catalog/cache.js +29 -0
- package/server/lib/skills-catalog/clawdhub/api.js +158 -0
- package/server/lib/skills-catalog/clawdhub/index.js +30 -0
- package/server/lib/skills-catalog/clawdhub/install.js +223 -0
- package/server/lib/skills-catalog/clawdhub/scan.js +113 -0
- package/server/lib/skills-catalog/curated-sources.js +21 -0
- package/server/lib/skills-catalog/git.js +76 -0
- package/server/lib/skills-catalog/install.js +280 -0
- package/server/lib/skills-catalog/scan.js +221 -0
- package/server/lib/skills-catalog/source.js +85 -0
- package/server/lib/summarization-service.js +171 -0
- package/server/lib/terminal-input-ws-protocol.js +66 -0
- package/server/lib/terminal-input-ws-protocol.test.js +138 -0
- package/server/lib/tts-service.js +162 -0
- package/server/lib/ui-auth.js +518 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side Text-to-Speech Service
|
|
3
|
+
*
|
|
4
|
+
* Uses OpenAI's TTS API to generate audio on the server and stream it to clients.
|
|
5
|
+
* This bypasses mobile Safari's audio context restrictions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import OpenAI from 'openai';
|
|
9
|
+
import { readAuthFile } from './opencode-auth.js';
|
|
10
|
+
|
|
11
|
+
// Voice options from OpenAI
|
|
12
|
+
export const TTS_VOICES = [
|
|
13
|
+
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable',
|
|
14
|
+
'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar'
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function getOpenAIApiKey() {
|
|
18
|
+
// First check environment variable
|
|
19
|
+
const envKey = process.env.OPENAI_API_KEY;
|
|
20
|
+
if (envKey) {
|
|
21
|
+
return envKey;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Then check opencode auth file (same as usage tracker)
|
|
25
|
+
try {
|
|
26
|
+
const auth = readAuthFile();
|
|
27
|
+
// Check for openai, codex, or chatgpt aliases
|
|
28
|
+
const openaiAuth = auth.openai || auth.codex || auth.chatgpt;
|
|
29
|
+
if (openaiAuth) {
|
|
30
|
+
// Handle both string format (just the token) and object format
|
|
31
|
+
if (typeof openaiAuth === 'string') {
|
|
32
|
+
return openaiAuth;
|
|
33
|
+
}
|
|
34
|
+
// Try access token first (OAuth), then regular token
|
|
35
|
+
if (openaiAuth.access) {
|
|
36
|
+
return openaiAuth.access;
|
|
37
|
+
}
|
|
38
|
+
if (openaiAuth.token) {
|
|
39
|
+
return openaiAuth.token;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
} catch (error) {
|
|
43
|
+
console.warn('[TTSService] Failed to read auth file:', error.message);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class TTSService {
|
|
50
|
+
constructor() {
|
|
51
|
+
this._client = null;
|
|
52
|
+
this._lastApiKey = null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_getClient() {
|
|
56
|
+
const apiKey = getOpenAIApiKey();
|
|
57
|
+
|
|
58
|
+
// If API key changed or client doesn't exist, create new client
|
|
59
|
+
if (apiKey && (!this._client || this._lastApiKey !== apiKey)) {
|
|
60
|
+
this._client = new OpenAI({ apiKey });
|
|
61
|
+
this._lastApiKey = apiKey;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return this._client;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
isAvailable() {
|
|
68
|
+
return this._getClient() !== null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Generate speech and return as a stream
|
|
73
|
+
*/
|
|
74
|
+
async generateSpeechStream(options) {
|
|
75
|
+
const {
|
|
76
|
+
text,
|
|
77
|
+
voice = 'coral',
|
|
78
|
+
model = 'gpt-4o-mini-tts',
|
|
79
|
+
speed = 1.0,
|
|
80
|
+
instructions,
|
|
81
|
+
apiKey
|
|
82
|
+
} = options;
|
|
83
|
+
|
|
84
|
+
// Use provided API key or fall back to configured key
|
|
85
|
+
let client;
|
|
86
|
+
if (apiKey) {
|
|
87
|
+
client = new OpenAI({ apiKey });
|
|
88
|
+
} else {
|
|
89
|
+
client = this._getClient();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!client) {
|
|
93
|
+
throw new Error('OpenAI API key not configured. Set OPENAI_API_KEY environment variable, configure OpenAI in OpenCode, or provide an API key in settings.');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!text.trim()) {
|
|
97
|
+
throw new Error('Text is required for TTS');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
console.log('[TTSService] Generating speech with voice:', voice, 'model:', model);
|
|
102
|
+
const response = await client.audio.speech.create({
|
|
103
|
+
model,
|
|
104
|
+
voice,
|
|
105
|
+
input: text,
|
|
106
|
+
speed,
|
|
107
|
+
...(instructions && { instructions }),
|
|
108
|
+
response_format: 'mp3',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Convert the response to a web stream
|
|
112
|
+
const stream = response.body;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
stream,
|
|
116
|
+
contentType: 'audio/mpeg',
|
|
117
|
+
};
|
|
118
|
+
} catch (error) {
|
|
119
|
+
console.error('[TTSService] Error generating speech:', error);
|
|
120
|
+
throw new Error(`Failed to generate speech: ${error.message || 'Unknown error'}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Generate speech and return as a buffer (for caching)
|
|
126
|
+
*/
|
|
127
|
+
async generateSpeechBuffer(options) {
|
|
128
|
+
const client = this._getClient();
|
|
129
|
+
if (!client) {
|
|
130
|
+
throw new Error('OpenAI API key not configured. Set OPENAI_API_KEY environment variable or configure OpenAI in OpenCode.');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const {
|
|
134
|
+
text,
|
|
135
|
+
voice = 'coral',
|
|
136
|
+
model = 'gpt-4o-mini-tts',
|
|
137
|
+
speed = 1.0,
|
|
138
|
+
instructions
|
|
139
|
+
} = options;
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const response = await client.audio.speech.create({
|
|
143
|
+
model,
|
|
144
|
+
voice,
|
|
145
|
+
input: text,
|
|
146
|
+
speed,
|
|
147
|
+
...(instructions && { instructions }),
|
|
148
|
+
response_format: 'mp3',
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
152
|
+
return Buffer.from(arrayBuffer);
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.error('[TTSService] Error generating speech buffer:', error);
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Export singleton instance
|
|
161
|
+
export const ttsService = new TTSService();
|
|
162
|
+
export { TTSService };
|
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
const SESSION_COOKIE_NAME = 'oc_ui_session';
|
|
4
|
+
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
5
|
+
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
|
6
|
+
|
|
7
|
+
// Login rate limit configuration
|
|
8
|
+
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000;
|
|
9
|
+
const RATE_LIMIT_MAX_ATTEMPTS = Number(process.env.IKANBAN_RATE_LIMIT_MAX_ATTEMPTS) || 10;
|
|
10
|
+
const RATE_LIMIT_LOCKOUT_MS = 15 * 60 * 1000;
|
|
11
|
+
const RATE_LIMIT_CLEANUP_MS = 60 * 60 * 1000;
|
|
12
|
+
const RATE_LIMIT_NO_IP_MAX_ATTEMPTS = Number(process.env.IKANBAN_RATE_LIMIT_NO_IP_MAX_ATTEMPTS) || 3;
|
|
13
|
+
|
|
14
|
+
// Rate limit tracker: IP -> { count, lastAttempt, lockedUntil }
|
|
15
|
+
const loginRateLimiter = new Map();
|
|
16
|
+
let rateLimitCleanupTimer = null;
|
|
17
|
+
|
|
18
|
+
// Concurrency control: key -> Promise<void>
|
|
19
|
+
const rateLimitLocks = new Map();
|
|
20
|
+
|
|
21
|
+
const getClientIp = (req) => {
|
|
22
|
+
const forwarded = req.headers['x-forwarded-for'];
|
|
23
|
+
if (typeof forwarded === 'string') {
|
|
24
|
+
const ip = forwarded.split(',')[0].trim();
|
|
25
|
+
if (ip.startsWith('::ffff:')) {
|
|
26
|
+
return ip.substring(7);
|
|
27
|
+
}
|
|
28
|
+
return ip;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ip = req.ip || req.connection?.remoteAddress;
|
|
32
|
+
if (ip) {
|
|
33
|
+
if (ip.startsWith('::ffff:')) {
|
|
34
|
+
return ip.substring(7);
|
|
35
|
+
}
|
|
36
|
+
return ip;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const getRateLimitKey = (req) => {
|
|
42
|
+
const ip = getClientIp(req);
|
|
43
|
+
if (ip) return ip;
|
|
44
|
+
return 'rate-limit:no-ip';
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const getRateLimitConfig = (key) => {
|
|
48
|
+
if (key === 'rate-limit:no-ip') {
|
|
49
|
+
return {
|
|
50
|
+
maxAttempts: RATE_LIMIT_NO_IP_MAX_ATTEMPTS,
|
|
51
|
+
windowMs: RATE_LIMIT_WINDOW_MS
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
maxAttempts: RATE_LIMIT_MAX_ATTEMPTS,
|
|
56
|
+
windowMs: RATE_LIMIT_WINDOW_MS
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const acquireRateLimitLock = async (key) => {
|
|
61
|
+
const prev = rateLimitLocks.get(key) || Promise.resolve();
|
|
62
|
+
const curr = prev.then(() => rateLimitLocks.delete(key));
|
|
63
|
+
rateLimitLocks.set(key, curr);
|
|
64
|
+
await curr;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const checkRateLimit = async (req) => {
|
|
68
|
+
const key = getRateLimitKey(req);
|
|
69
|
+
await acquireRateLimitLock(key);
|
|
70
|
+
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const { maxAttempts } = getRateLimitConfig(key);
|
|
73
|
+
|
|
74
|
+
let record;
|
|
75
|
+
try {
|
|
76
|
+
record = loginRateLimiter.get(key);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
console.error('[RateLimit] Failed to get record', { key, error: err.message });
|
|
79
|
+
return {
|
|
80
|
+
allowed: true,
|
|
81
|
+
limit: maxAttempts,
|
|
82
|
+
remaining: maxAttempts,
|
|
83
|
+
reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (record?.lockedUntil && now < record.lockedUntil) {
|
|
88
|
+
return {
|
|
89
|
+
allowed: false,
|
|
90
|
+
retryAfter: Math.ceil((record.lockedUntil - now) / 1000),
|
|
91
|
+
locked: true,
|
|
92
|
+
limit: maxAttempts,
|
|
93
|
+
remaining: 0,
|
|
94
|
+
reset: Math.ceil(record.lockedUntil / 1000)
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (record?.lockedUntil && now >= record.lockedUntil) {
|
|
99
|
+
try {
|
|
100
|
+
loginRateLimiter.delete(key);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
console.error('[RateLimit] Failed to delete expired record', { key, error: err.message });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
|
|
107
|
+
return {
|
|
108
|
+
allowed: true,
|
|
109
|
+
limit: maxAttempts,
|
|
110
|
+
remaining: maxAttempts,
|
|
111
|
+
reset: Math.ceil((now + RATE_LIMIT_WINDOW_MS) / 1000)
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (record.count >= maxAttempts) {
|
|
116
|
+
const lockedUntil = now + RATE_LIMIT_LOCKOUT_MS;
|
|
117
|
+
try {
|
|
118
|
+
loginRateLimiter.set(key, { count: record.count + 1, lastAttempt: now, lockedUntil });
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.error('[RateLimit] Failed to set lockout', { key, error: err.message });
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
allowed: false,
|
|
124
|
+
retryAfter: Math.ceil(RATE_LIMIT_LOCKOUT_MS / 1000),
|
|
125
|
+
locked: true,
|
|
126
|
+
limit: maxAttempts,
|
|
127
|
+
remaining: 0,
|
|
128
|
+
reset: Math.ceil(lockedUntil / 1000)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const remaining = maxAttempts - record.count;
|
|
133
|
+
const reset = Math.ceil((record.lastAttempt + RATE_LIMIT_WINDOW_MS) / 1000);
|
|
134
|
+
return {
|
|
135
|
+
allowed: true,
|
|
136
|
+
limit: maxAttempts,
|
|
137
|
+
remaining,
|
|
138
|
+
reset
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const recordFailedAttempt = async (req) => {
|
|
143
|
+
const key = getRateLimitKey(req);
|
|
144
|
+
await acquireRateLimitLock(key);
|
|
145
|
+
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
const { maxAttempts } = getRateLimitConfig(key);
|
|
148
|
+
const record = loginRateLimiter.get(key);
|
|
149
|
+
|
|
150
|
+
if (!record || now - record.lastAttempt > RATE_LIMIT_WINDOW_MS) {
|
|
151
|
+
try {
|
|
152
|
+
loginRateLimiter.set(key, { count: 1, lastAttempt: now });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
const newCount = record.count + 1;
|
|
158
|
+
try {
|
|
159
|
+
loginRateLimiter.set(key, { count: newCount, lastAttempt: now });
|
|
160
|
+
} catch (err) {
|
|
161
|
+
console.error('[RateLimit] Failed to record attempt', { key, error: err.message });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const clearRateLimit = async (req) => {
|
|
167
|
+
const key = getRateLimitKey(req);
|
|
168
|
+
await acquireRateLimitLock(key);
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
loginRateLimiter.delete(key);
|
|
172
|
+
} catch (err) {
|
|
173
|
+
console.error('[RateLimit] Failed to clear', { key, error: err.message });
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const cleanupRateLimitRecords = () => {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
for (const [key, record] of loginRateLimiter.entries()) {
|
|
180
|
+
const isExpired = record.lockedUntil && now >= record.lockedUntil;
|
|
181
|
+
const isStale = now - record.lastAttempt > RATE_LIMIT_CLEANUP_MS;
|
|
182
|
+
if (isExpired || isStale) {
|
|
183
|
+
try {
|
|
184
|
+
loginRateLimiter.delete(key);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.error('[RateLimit] Cleanup failed', { key, error: err.message });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const startRateLimitCleanup = () => {
|
|
193
|
+
if (!rateLimitCleanupTimer) {
|
|
194
|
+
rateLimitCleanupTimer = setInterval(cleanupRateLimitRecords, RATE_LIMIT_CLEANUP_MS);
|
|
195
|
+
if (rateLimitCleanupTimer && typeof rateLimitCleanupTimer.unref === 'function') {
|
|
196
|
+
rateLimitCleanupTimer.unref();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const stopRateLimitCleanup = () => {
|
|
202
|
+
if (rateLimitCleanupTimer) {
|
|
203
|
+
clearInterval(rateLimitCleanupTimer);
|
|
204
|
+
rateLimitCleanupTimer = null;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const isSecureRequest = (req) => {
|
|
209
|
+
if (req.secure) {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
const forwardedProto = req.headers['x-forwarded-proto'];
|
|
213
|
+
if (typeof forwardedProto === 'string') {
|
|
214
|
+
const firstProto = forwardedProto.split(',')[0]?.trim().toLowerCase();
|
|
215
|
+
return firstProto === 'https';
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const parseCookies = (cookieHeader) => {
|
|
221
|
+
if (!cookieHeader || typeof cookieHeader !== 'string') {
|
|
222
|
+
return {};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return cookieHeader.split(';').reduce((acc, segment) => {
|
|
226
|
+
const [name, ...rest] = segment.split('=');
|
|
227
|
+
if (!name) {
|
|
228
|
+
return acc;
|
|
229
|
+
}
|
|
230
|
+
const key = name.trim();
|
|
231
|
+
if (!key) {
|
|
232
|
+
return acc;
|
|
233
|
+
}
|
|
234
|
+
const value = rest.join('=').trim();
|
|
235
|
+
acc[key] = decodeURIComponent(value || '');
|
|
236
|
+
return acc;
|
|
237
|
+
}, {});
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const buildCookie = ({
|
|
241
|
+
name,
|
|
242
|
+
value,
|
|
243
|
+
maxAge,
|
|
244
|
+
secure,
|
|
245
|
+
}) => {
|
|
246
|
+
const attributes = [
|
|
247
|
+
`${name}=${value}`,
|
|
248
|
+
'Path=/',
|
|
249
|
+
'HttpOnly',
|
|
250
|
+
'SameSite=Strict',
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
if (typeof maxAge === 'number') {
|
|
254
|
+
attributes.push(`Max-Age=${Math.max(0, Math.floor(maxAge))}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const expires = maxAge === 0
|
|
258
|
+
? 'Thu, 01 Jan 1970 00:00:00 GMT'
|
|
259
|
+
: new Date(Date.now() + maxAge * 1000).toUTCString();
|
|
260
|
+
|
|
261
|
+
attributes.push(`Expires=${expires}`);
|
|
262
|
+
|
|
263
|
+
if (secure) {
|
|
264
|
+
attributes.push('Secure');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return attributes.join('; ');
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const normalizePassword = (candidate) => {
|
|
271
|
+
if (typeof candidate !== 'string') {
|
|
272
|
+
return '';
|
|
273
|
+
}
|
|
274
|
+
return candidate.normalize().trim();
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export const createUiAuth = ({
|
|
278
|
+
password,
|
|
279
|
+
cookieName = SESSION_COOKIE_NAME,
|
|
280
|
+
sessionTtlMs = SESSION_TTL_MS,
|
|
281
|
+
} = {}) => {
|
|
282
|
+
const normalizedPassword = normalizePassword(password);
|
|
283
|
+
|
|
284
|
+
if (!normalizedPassword) {
|
|
285
|
+
const setSessionCookie = (req, res, token) => {
|
|
286
|
+
const secure = isSecureRequest(req);
|
|
287
|
+
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
|
|
288
|
+
const header = buildCookie({
|
|
289
|
+
name: cookieName,
|
|
290
|
+
value: encodeURIComponent(token),
|
|
291
|
+
maxAge: maxAgeSeconds,
|
|
292
|
+
secure,
|
|
293
|
+
});
|
|
294
|
+
res.setHeader('Set-Cookie', header);
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const ensureSessionToken = (req, res) => {
|
|
298
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
299
|
+
if (cookies[cookieName]) {
|
|
300
|
+
return cookies[cookieName];
|
|
301
|
+
}
|
|
302
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
303
|
+
setSessionCookie(req, res, token);
|
|
304
|
+
return token;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
enabled: false,
|
|
309
|
+
requireAuth: (_req, _res, next) => next(),
|
|
310
|
+
handleSessionStatus: (_req, res) => {
|
|
311
|
+
res.json({ authenticated: true, disabled: true });
|
|
312
|
+
},
|
|
313
|
+
handleSessionCreate: (_req, res) => {
|
|
314
|
+
res.status(400).json({ error: 'UI password not configured' });
|
|
315
|
+
},
|
|
316
|
+
ensureSessionToken,
|
|
317
|
+
dispose: () => {
|
|
318
|
+
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const salt = crypto.randomBytes(16);
|
|
324
|
+
const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64);
|
|
325
|
+
const sessions = new Map();
|
|
326
|
+
|
|
327
|
+
let cleanupTimer = null;
|
|
328
|
+
|
|
329
|
+
const getTokenFromRequest = (req) => {
|
|
330
|
+
const cookies = parseCookies(req.headers.cookie);
|
|
331
|
+
if (cookies[cookieName]) {
|
|
332
|
+
return cookies[cookieName];
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const dropSession = (token) => {
|
|
338
|
+
if (token) {
|
|
339
|
+
sessions.delete(token);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const setSessionCookie = (req, res, token) => {
|
|
344
|
+
const secure = isSecureRequest(req);
|
|
345
|
+
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
|
|
346
|
+
const header = buildCookie({
|
|
347
|
+
name: cookieName,
|
|
348
|
+
value: encodeURIComponent(token),
|
|
349
|
+
maxAge: maxAgeSeconds,
|
|
350
|
+
secure,
|
|
351
|
+
});
|
|
352
|
+
res.setHeader('Set-Cookie', header);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const clearSessionCookie = (req, res) => {
|
|
356
|
+
const secure = isSecureRequest(req);
|
|
357
|
+
const header = buildCookie({
|
|
358
|
+
name: cookieName,
|
|
359
|
+
value: '',
|
|
360
|
+
maxAge: 0,
|
|
361
|
+
secure,
|
|
362
|
+
});
|
|
363
|
+
res.setHeader('Set-Cookie', header);
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const verifyPassword = (candidate) => {
|
|
367
|
+
if (!candidate) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
const normalizedCandidate = normalizePassword(candidate);
|
|
371
|
+
if (!normalizedCandidate) {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const candidateHash = crypto.scryptSync(normalizedCandidate, salt, 64);
|
|
376
|
+
return crypto.timingSafeEqual(candidateHash, expectedHash);
|
|
377
|
+
} catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const isSessionValid = (token) => {
|
|
383
|
+
if (!token) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
const record = sessions.get(token);
|
|
387
|
+
if (!record) {
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
if (Date.now() - record.lastSeen > sessionTtlMs) {
|
|
391
|
+
sessions.delete(token);
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
record.lastSeen = Date.now();
|
|
395
|
+
return true;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const issueSession = (req, res) => {
|
|
399
|
+
const token = crypto.randomBytes(32).toString('base64url');
|
|
400
|
+
const now = Date.now();
|
|
401
|
+
sessions.set(token, { createdAt: now, lastSeen: now });
|
|
402
|
+
setSessionCookie(req, res, token);
|
|
403
|
+
return token;
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
const cleanupStaleSessions = () => {
|
|
407
|
+
const now = Date.now();
|
|
408
|
+
for (const [token, record] of sessions.entries()) {
|
|
409
|
+
if (now - record.lastSeen > sessionTtlMs) {
|
|
410
|
+
sessions.delete(token);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const startCleanup = () => {
|
|
416
|
+
if (!cleanupTimer) {
|
|
417
|
+
cleanupTimer = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
|
418
|
+
if (cleanupTimer && typeof cleanupTimer.unref === 'function') {
|
|
419
|
+
cleanupTimer.unref();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
startCleanup();
|
|
425
|
+
startRateLimitCleanup();
|
|
426
|
+
|
|
427
|
+
const respondUnauthorized = (req, res) => {
|
|
428
|
+
res.status(401);
|
|
429
|
+
const acceptsJson = req.headers.accept?.includes('application/json');
|
|
430
|
+
if (acceptsJson || req.path.startsWith('/api')) {
|
|
431
|
+
res.json({ error: 'UI authentication required', locked: true });
|
|
432
|
+
} else {
|
|
433
|
+
res.type('text/plain').send('Authentication required');
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const requireAuth = (req, res, next) => {
|
|
438
|
+
if (req.method === 'OPTIONS') {
|
|
439
|
+
return next();
|
|
440
|
+
}
|
|
441
|
+
const token = getTokenFromRequest(req);
|
|
442
|
+
if (isSessionValid(token)) {
|
|
443
|
+
return next();
|
|
444
|
+
}
|
|
445
|
+
clearSessionCookie(req, res);
|
|
446
|
+
return respondUnauthorized(req, res);
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
const handleSessionStatus = (req, res) => {
|
|
450
|
+
const token = getTokenFromRequest(req);
|
|
451
|
+
if (isSessionValid(token)) {
|
|
452
|
+
res.json({ authenticated: true });
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
clearSessionCookie(req, res);
|
|
456
|
+
res.status(401).json({ authenticated: false, locked: true });
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const handleSessionCreate = async (req, res) => {
|
|
460
|
+
const rateLimitResult = await checkRateLimit(req);
|
|
461
|
+
|
|
462
|
+
res.setHeader('X-RateLimit-Limit', rateLimitResult.limit);
|
|
463
|
+
res.setHeader('X-RateLimit-Remaining', rateLimitResult.remaining);
|
|
464
|
+
res.setHeader('X-RateLimit-Reset', rateLimitResult.reset);
|
|
465
|
+
|
|
466
|
+
if (!rateLimitResult.allowed) {
|
|
467
|
+
res.setHeader('Retry-After', rateLimitResult.retryAfter);
|
|
468
|
+
res.status(429).json({
|
|
469
|
+
error: 'Too many login attempts, please try again later',
|
|
470
|
+
retryAfter: rateLimitResult.retryAfter
|
|
471
|
+
});
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const candidate = typeof req.body?.password === 'string' ? req.body.password : '';
|
|
476
|
+
if (!verifyPassword(candidate)) {
|
|
477
|
+
await recordFailedAttempt(req);
|
|
478
|
+
clearSessionCookie(req, res);
|
|
479
|
+
res.status(401).json({ error: 'Invalid credentials' });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
await clearRateLimit(req);
|
|
484
|
+
|
|
485
|
+
const previousToken = getTokenFromRequest(req);
|
|
486
|
+
if (previousToken) {
|
|
487
|
+
dropSession(previousToken);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
issueSession(req, res);
|
|
491
|
+
res.json({ authenticated: true });
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const dispose = () => {
|
|
495
|
+
sessions.clear();
|
|
496
|
+
loginRateLimiter.clear();
|
|
497
|
+
if (cleanupTimer) {
|
|
498
|
+
clearInterval(cleanupTimer);
|
|
499
|
+
cleanupTimer = null;
|
|
500
|
+
}
|
|
501
|
+
if (rateLimitCleanupTimer) {
|
|
502
|
+
clearInterval(rateLimitCleanupTimer);
|
|
503
|
+
rateLimitCleanupTimer = null;
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
enabled: true,
|
|
509
|
+
requireAuth,
|
|
510
|
+
handleSessionStatus,
|
|
511
|
+
handleSessionCreate,
|
|
512
|
+
ensureSessionToken: (req, _res) => {
|
|
513
|
+
const token = getTokenFromRequest(req);
|
|
514
|
+
return isSessionValid(token) ? token : null;
|
|
515
|
+
},
|
|
516
|
+
dispose,
|
|
517
|
+
};
|
|
518
|
+
};
|