ai-cli-online 2.9.9 → 3.0.2
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 +161 -76
- package/README.zh-CN.md +157 -72
- package/package.json +1 -1
- package/server/dist/index.js +27 -552
- package/server/dist/middleware/auth.d.ts +9 -0
- package/server/dist/middleware/auth.js +39 -0
- package/server/dist/routes/editor.d.ts +2 -0
- package/server/dist/routes/editor.js +94 -0
- package/server/dist/routes/files.d.ts +2 -0
- package/server/dist/routes/files.js +312 -0
- package/server/dist/routes/sessions.d.ts +2 -0
- package/server/dist/routes/sessions.js +64 -0
- package/server/dist/routes/settings.d.ts +2 -0
- package/server/dist/routes/settings.js +72 -0
- package/server/package.json +1 -1
- package/shared/package.json +1 -1
- package/web/dist/assets/index-9ElcUlG3.js +32 -0
- package/web/dist/index.html +1 -1
- package/web/package.json +1 -1
- package/web/dist/assets/index-DjhdaelF.js +0 -32
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Request, Response } from 'express';
|
|
2
|
+
/** Extract Bearer token from Authorization header */
|
|
3
|
+
export declare function extractToken(req: Request): string | undefined;
|
|
4
|
+
/** Check auth — reads AUTH_TOKEN lazily from env (dotenv loads before route handlers run) */
|
|
5
|
+
export declare function checkAuth(req: Request, res: Response): boolean;
|
|
6
|
+
/** Resolve session: auth check + sessionId validation + build tmux session name */
|
|
7
|
+
export declare function resolveSession(req: Request, res: Response): string | null;
|
|
8
|
+
/** Hash token for settings storage (same prefix as tmux session names) */
|
|
9
|
+
export declare function tokenHash(token: string): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { safeTokenCompare } from '../auth.js';
|
|
3
|
+
import { buildSessionName, isValidSessionId } from '../tmux.js';
|
|
4
|
+
/** Extract Bearer token from Authorization header */
|
|
5
|
+
export function extractToken(req) {
|
|
6
|
+
const authHeader = req.headers.authorization;
|
|
7
|
+
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
8
|
+
return authHeader.slice(7);
|
|
9
|
+
}
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
/** Check auth — reads AUTH_TOKEN lazily from env (dotenv loads before route handlers run) */
|
|
13
|
+
export function checkAuth(req, res) {
|
|
14
|
+
const authToken = process.env.AUTH_TOKEN || '';
|
|
15
|
+
if (!authToken)
|
|
16
|
+
return true;
|
|
17
|
+
const token = extractToken(req);
|
|
18
|
+
if (!token || !safeTokenCompare(token, authToken)) {
|
|
19
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
/** Resolve session: auth check + sessionId validation + build tmux session name */
|
|
25
|
+
export function resolveSession(req, res) {
|
|
26
|
+
if (!checkAuth(req, res))
|
|
27
|
+
return null;
|
|
28
|
+
const sessionId = req.params.sessionId;
|
|
29
|
+
if (!isValidSessionId(sessionId)) {
|
|
30
|
+
res.status(400).json({ error: 'Invalid sessionId' });
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const token = extractToken(req) || 'default';
|
|
34
|
+
return buildSessionName(token, sessionId);
|
|
35
|
+
}
|
|
36
|
+
/** Hash token for settings storage (same prefix as tmux session names) */
|
|
37
|
+
export function tokenHash(token) {
|
|
38
|
+
return createHash('sha256').update(token).digest('hex').slice(0, 8);
|
|
39
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { writeFile } from 'fs/promises';
|
|
3
|
+
import { join, basename } from 'path';
|
|
4
|
+
import { resolveSession } from '../middleware/auth.js';
|
|
5
|
+
import { getCwd } from '../tmux.js';
|
|
6
|
+
import { getDraft, saveDraft as saveDraftDb, getAnnotation, saveAnnotation } from '../db.js';
|
|
7
|
+
import { validateNewPath } from '../files.js';
|
|
8
|
+
const router = Router();
|
|
9
|
+
// Get draft for a session
|
|
10
|
+
router.get('/api/sessions/:sessionId/draft', (req, res) => {
|
|
11
|
+
const sessionName = resolveSession(req, res);
|
|
12
|
+
if (!sessionName)
|
|
13
|
+
return;
|
|
14
|
+
const content = getDraft(sessionName);
|
|
15
|
+
res.json({ content });
|
|
16
|
+
});
|
|
17
|
+
// Save (upsert) draft for a session
|
|
18
|
+
router.put('/api/sessions/:sessionId/draft', (req, res) => {
|
|
19
|
+
const sessionName = resolveSession(req, res);
|
|
20
|
+
if (!sessionName)
|
|
21
|
+
return;
|
|
22
|
+
const { content } = req.body;
|
|
23
|
+
if (typeof content !== 'string') {
|
|
24
|
+
res.status(400).json({ error: 'content must be a string' });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
saveDraftDb(sessionName, content);
|
|
28
|
+
res.json({ ok: true });
|
|
29
|
+
});
|
|
30
|
+
// Get annotation for a file
|
|
31
|
+
router.get('/api/sessions/:sessionId/annotations', (req, res) => {
|
|
32
|
+
const sessionName = resolveSession(req, res);
|
|
33
|
+
if (!sessionName)
|
|
34
|
+
return;
|
|
35
|
+
const filePath = req.query.path;
|
|
36
|
+
if (!filePath) {
|
|
37
|
+
res.status(400).json({ error: 'path query parameter required' });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const result = getAnnotation(sessionName, filePath);
|
|
41
|
+
res.json(result || { content: null, updatedAt: 0 });
|
|
42
|
+
});
|
|
43
|
+
// Save (upsert) annotation for a file
|
|
44
|
+
router.put('/api/sessions/:sessionId/annotations', (req, res) => {
|
|
45
|
+
const sessionName = resolveSession(req, res);
|
|
46
|
+
if (!sessionName)
|
|
47
|
+
return;
|
|
48
|
+
const { path: filePath, content, updatedAt } = req.body;
|
|
49
|
+
if (!filePath || typeof filePath !== 'string') {
|
|
50
|
+
res.status(400).json({ error: 'path must be a string' });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (typeof content !== 'string') {
|
|
54
|
+
res.status(400).json({ error: 'content must be a string' });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
saveAnnotation(sessionName, filePath, content, updatedAt || Date.now());
|
|
58
|
+
res.json({ ok: true });
|
|
59
|
+
});
|
|
60
|
+
// Write .tmp-annotations.json for ai-cli-task plan
|
|
61
|
+
router.post('/api/sessions/:sessionId/task-annotations', async (req, res) => {
|
|
62
|
+
const sessionName = resolveSession(req, res);
|
|
63
|
+
if (!sessionName)
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
const { modulePath, content } = req.body;
|
|
67
|
+
if (!modulePath || typeof modulePath !== 'string') {
|
|
68
|
+
res.status(400).json({ error: 'modulePath must be a string' });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (!content || typeof content !== 'object') {
|
|
72
|
+
res.status(400).json({ error: 'content must be an object' });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const cwd = await getCwd(sessionName);
|
|
76
|
+
const targetFile = join(modulePath, '.tmp-annotations.json');
|
|
77
|
+
const resolved = await validateNewPath(targetFile, cwd);
|
|
78
|
+
if (!resolved) {
|
|
79
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (basename(resolved) !== '.tmp-annotations.json') {
|
|
83
|
+
res.status(400).json({ error: 'Only .tmp-annotations.json is allowed' });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
await writeFile(resolved, JSON.stringify(content, null, 2), 'utf-8');
|
|
87
|
+
res.json({ ok: true, path: resolved });
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
console.error(`[api:task-annotations] ${sessionName}:`, err);
|
|
91
|
+
res.status(500).json({ error: 'Failed to write annotation file' });
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
export default router;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import multer from 'multer';
|
|
3
|
+
import { createReadStream, mkdirSync } from 'fs';
|
|
4
|
+
import { copyFile, unlink, stat, mkdir, readFile, writeFile, rm } from 'fs/promises';
|
|
5
|
+
import { join, dirname, basename, extname } from 'path';
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import { resolveSession } from '../middleware/auth.js';
|
|
8
|
+
import { getCwd } from '../tmux.js';
|
|
9
|
+
import { listFiles, validatePath, validatePathNoSymlink, validateNewPath, MAX_DOWNLOAD_SIZE, MAX_UPLOAD_SIZE } from '../files.js';
|
|
10
|
+
const router = Router();
|
|
11
|
+
// Multer setup for file uploads
|
|
12
|
+
const UPLOAD_TMP_DIR = '/tmp/ai-cli-online-uploads';
|
|
13
|
+
mkdirSync(UPLOAD_TMP_DIR, { recursive: true, mode: 0o700 });
|
|
14
|
+
const upload = multer({
|
|
15
|
+
dest: UPLOAD_TMP_DIR,
|
|
16
|
+
limits: { fileSize: MAX_UPLOAD_SIZE, files: 10 },
|
|
17
|
+
});
|
|
18
|
+
// List files in directory
|
|
19
|
+
router.get('/api/sessions/:sessionId/files', async (req, res) => {
|
|
20
|
+
const sessionName = resolveSession(req, res);
|
|
21
|
+
if (!sessionName)
|
|
22
|
+
return;
|
|
23
|
+
try {
|
|
24
|
+
const cwd = await getCwd(sessionName);
|
|
25
|
+
const subPath = req.query.path || '';
|
|
26
|
+
let targetDir = null;
|
|
27
|
+
if (subPath) {
|
|
28
|
+
targetDir = await validatePath(subPath, cwd);
|
|
29
|
+
if (!targetDir) {
|
|
30
|
+
const home = process.env.HOME || '/root';
|
|
31
|
+
targetDir = await validatePath(subPath, home);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
targetDir = cwd;
|
|
36
|
+
}
|
|
37
|
+
if (!targetDir) {
|
|
38
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const { files, truncated } = await listFiles(targetDir);
|
|
42
|
+
res.json({ cwd: targetDir, home: process.env.HOME || '/root', files, truncated });
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
console.error(`[api:files] ${sessionName}:`, err);
|
|
46
|
+
res.status(404).json({ error: 'Session not found or directory not accessible' });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
// Upload files to CWD
|
|
50
|
+
router.post('/api/sessions/:sessionId/upload', upload.array('files', 10), async (req, res) => {
|
|
51
|
+
const sessionName = resolveSession(req, res);
|
|
52
|
+
if (!sessionName)
|
|
53
|
+
return;
|
|
54
|
+
try {
|
|
55
|
+
const cwd = await getCwd(sessionName);
|
|
56
|
+
const uploadedFiles = req.files;
|
|
57
|
+
if (!uploadedFiles || uploadedFiles.length === 0) {
|
|
58
|
+
res.status(400).json({ error: 'No files provided' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const results = [];
|
|
62
|
+
for (const file of uploadedFiles) {
|
|
63
|
+
const safeName = basename(file.originalname);
|
|
64
|
+
if (!safeName || safeName === '.' || safeName === '..') {
|
|
65
|
+
await unlink(file.path).catch(() => { });
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const destPath = join(cwd, safeName);
|
|
69
|
+
await copyFile(file.path, destPath);
|
|
70
|
+
await unlink(file.path).catch(() => { });
|
|
71
|
+
results.push({ name: safeName, size: file.size });
|
|
72
|
+
}
|
|
73
|
+
res.json({ uploaded: results });
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
console.error('[upload] Failed:', err);
|
|
77
|
+
const files = req.files;
|
|
78
|
+
if (files) {
|
|
79
|
+
for (const f of files) {
|
|
80
|
+
await unlink(f.path).catch(() => { });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
res.status(500).json({ error: 'Upload failed' });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
// Download a file
|
|
87
|
+
router.get('/api/sessions/:sessionId/download', async (req, res) => {
|
|
88
|
+
const sessionName = resolveSession(req, res);
|
|
89
|
+
if (!sessionName)
|
|
90
|
+
return;
|
|
91
|
+
try {
|
|
92
|
+
const cwd = await getCwd(sessionName);
|
|
93
|
+
const filePath = req.query.path;
|
|
94
|
+
if (!filePath) {
|
|
95
|
+
res.status(400).json({ error: 'path query parameter required' });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const resolved = await validatePathNoSymlink(filePath, cwd);
|
|
99
|
+
if (!resolved) {
|
|
100
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const fileStat = await stat(resolved);
|
|
104
|
+
if (!fileStat.isFile()) {
|
|
105
|
+
res.status(400).json({ error: 'Not a file' });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (fileStat.size > MAX_DOWNLOAD_SIZE) {
|
|
109
|
+
res.status(413).json({ error: 'File too large (max 100MB)' });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(basename(resolved))}"`);
|
|
113
|
+
res.setHeader('Content-Length', fileStat.size);
|
|
114
|
+
const stream = createReadStream(resolved);
|
|
115
|
+
let bytesWritten = 0;
|
|
116
|
+
stream.on('data', (chunk) => {
|
|
117
|
+
const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
118
|
+
bytesWritten += buf.length;
|
|
119
|
+
if (bytesWritten > MAX_DOWNLOAD_SIZE) {
|
|
120
|
+
stream.destroy();
|
|
121
|
+
if (!res.writableEnded)
|
|
122
|
+
res.end();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!res.writableEnded)
|
|
126
|
+
res.write(chunk);
|
|
127
|
+
});
|
|
128
|
+
stream.on('end', () => { if (!res.writableEnded)
|
|
129
|
+
res.end(); });
|
|
130
|
+
stream.on('error', () => { if (!res.writableEnded)
|
|
131
|
+
res.end(); });
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
console.error(`[api:download] ${sessionName}:`, err);
|
|
135
|
+
res.status(404).json({ error: 'File not found' });
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
// Download CWD as tar.gz
|
|
139
|
+
router.get('/api/sessions/:sessionId/download-cwd', async (req, res) => {
|
|
140
|
+
const sessionName = resolveSession(req, res);
|
|
141
|
+
if (!sessionName)
|
|
142
|
+
return;
|
|
143
|
+
try {
|
|
144
|
+
const cwd = await getCwd(sessionName);
|
|
145
|
+
if (!cwd.startsWith('/') || cwd.includes('\0')) {
|
|
146
|
+
res.status(400).json({ error: 'Invalid working directory' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const dirName = basename(cwd);
|
|
150
|
+
res.setHeader('Content-Type', 'application/gzip');
|
|
151
|
+
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(dirName)}.tar.gz"`);
|
|
152
|
+
const tar = spawn('tar', ['czf', '-', '-C', cwd, '.'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
153
|
+
tar.stdout.pipe(res);
|
|
154
|
+
tar.stderr.on('data', (data) => console.error(`[tar stderr] ${data}`));
|
|
155
|
+
tar.on('error', (err) => {
|
|
156
|
+
console.error('[api:download-cwd] tar error:', err);
|
|
157
|
+
if (!res.headersSent)
|
|
158
|
+
res.status(500).json({ error: 'Failed to create archive' });
|
|
159
|
+
});
|
|
160
|
+
tar.on('close', (code) => {
|
|
161
|
+
if (code !== 0 && !res.headersSent) {
|
|
162
|
+
res.status(500).json({ error: 'Archive creation failed' });
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
console.error(`[api:download-cwd] ${sessionName}:`, err);
|
|
168
|
+
if (!res.headersSent)
|
|
169
|
+
res.status(500).json({ error: 'Failed to download' });
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
// Create empty file
|
|
173
|
+
router.post('/api/sessions/:sessionId/touch', async (req, res) => {
|
|
174
|
+
const sessionName = resolveSession(req, res);
|
|
175
|
+
if (!sessionName)
|
|
176
|
+
return;
|
|
177
|
+
try {
|
|
178
|
+
const { name } = req.body;
|
|
179
|
+
if (!name || typeof name !== 'string' || name.includes('..')) {
|
|
180
|
+
res.status(400).json({ error: 'Invalid filename' });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const cwd = await getCwd(sessionName);
|
|
184
|
+
const resolved = await validateNewPath(join(cwd, name), cwd);
|
|
185
|
+
if (!resolved) {
|
|
186
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
await mkdir(dirname(resolved), { recursive: true });
|
|
190
|
+
await writeFile(resolved, '', { flag: 'wx' });
|
|
191
|
+
res.json({ ok: true, path: resolved });
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
if (err && typeof err === 'object' && 'code' in err && err.code === 'EEXIST') {
|
|
195
|
+
const cwd = await getCwd(sessionName).catch(() => '');
|
|
196
|
+
res.json({ ok: true, existed: true, path: cwd ? join(cwd, String(req.body.name)) : '' });
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
console.error(`[api:touch] ${sessionName}:`, err);
|
|
200
|
+
res.status(500).json({ error: 'Failed to create file' });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
// Create directory
|
|
205
|
+
router.post('/api/sessions/:sessionId/mkdir', async (req, res) => {
|
|
206
|
+
const sessionName = resolveSession(req, res);
|
|
207
|
+
if (!sessionName)
|
|
208
|
+
return;
|
|
209
|
+
try {
|
|
210
|
+
const { path: dirPath } = req.body;
|
|
211
|
+
if (!dirPath || typeof dirPath !== 'string' || dirPath.includes('..')) {
|
|
212
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const cwd = await getCwd(sessionName);
|
|
216
|
+
const resolved = await validateNewPath(join(cwd, dirPath), cwd);
|
|
217
|
+
if (!resolved) {
|
|
218
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
await mkdir(resolved, { recursive: true });
|
|
222
|
+
res.json({ ok: true, path: resolved });
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
console.error(`[api:mkdir] ${sessionName}:`, err);
|
|
226
|
+
res.status(500).json({ error: 'Failed to create directory' });
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
// Delete file or directory
|
|
230
|
+
router.delete('/api/sessions/:sessionId/rm', async (req, res) => {
|
|
231
|
+
const sessionName = resolveSession(req, res);
|
|
232
|
+
if (!sessionName)
|
|
233
|
+
return;
|
|
234
|
+
try {
|
|
235
|
+
const { path: rmPath } = req.body;
|
|
236
|
+
if (!rmPath || typeof rmPath !== 'string' || rmPath.includes('..')) {
|
|
237
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const cwd = await getCwd(sessionName);
|
|
241
|
+
const resolved = await validatePath(rmPath, cwd);
|
|
242
|
+
if (!resolved) {
|
|
243
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const fileStat = await stat(resolved);
|
|
247
|
+
if (fileStat.isDirectory()) {
|
|
248
|
+
await rm(resolved, { recursive: true });
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
await unlink(resolved);
|
|
252
|
+
}
|
|
253
|
+
res.json({ ok: true });
|
|
254
|
+
}
|
|
255
|
+
catch (err) {
|
|
256
|
+
console.error(`[api:rm] ${sessionName}:`, err);
|
|
257
|
+
res.status(500).json({ error: 'Failed to delete' });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
// Read file content (for document viewer)
|
|
261
|
+
const MAX_DOC_SIZE = 10 * 1024 * 1024; // 10MB
|
|
262
|
+
const PDF_EXTENSIONS = new Set(['.pdf']);
|
|
263
|
+
router.get('/api/sessions/:sessionId/file-content', async (req, res) => {
|
|
264
|
+
const sessionName = resolveSession(req, res);
|
|
265
|
+
if (!sessionName)
|
|
266
|
+
return;
|
|
267
|
+
const filePath = req.query.path;
|
|
268
|
+
if (!filePath) {
|
|
269
|
+
res.status(400).json({ error: 'path query parameter required' });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const cwd = await getCwd(sessionName);
|
|
274
|
+
let resolved = await validatePathNoSymlink(filePath, cwd);
|
|
275
|
+
if (!resolved) {
|
|
276
|
+
const home = process.env.HOME || '/root';
|
|
277
|
+
resolved = await validatePathNoSymlink(filePath, home);
|
|
278
|
+
}
|
|
279
|
+
if (!resolved) {
|
|
280
|
+
res.status(400).json({ error: 'Invalid path' });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const fileStat = await stat(resolved);
|
|
284
|
+
if (!fileStat.isFile()) {
|
|
285
|
+
res.status(400).json({ error: 'Not a file' });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (fileStat.size > MAX_DOC_SIZE) {
|
|
289
|
+
res.status(413).json({ error: 'File too large (max 10MB)' });
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const since = parseFloat(req.query.since) || 0;
|
|
293
|
+
if (since > 0 && fileStat.mtimeMs <= since) {
|
|
294
|
+
res.status(304).end();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const ext = extname(resolved).toLowerCase();
|
|
298
|
+
const isPdf = PDF_EXTENSIONS.has(ext);
|
|
299
|
+
const content = await readFile(resolved, isPdf ? undefined : 'utf-8');
|
|
300
|
+
res.json({
|
|
301
|
+
content: isPdf ? content.toString('base64') : content,
|
|
302
|
+
mtime: fileStat.mtimeMs,
|
|
303
|
+
size: fileStat.size,
|
|
304
|
+
encoding: isPdf ? 'base64' : 'utf-8',
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
console.error(`[api:file-content] ${sessionName}:`, err);
|
|
309
|
+
res.status(404).json({ error: 'File not found' });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
export default router;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { extractToken, checkAuth, resolveSession } from '../middleware/auth.js';
|
|
3
|
+
import { listSessions, killSession, buildSessionName, getCwd, getPaneCommand } from '../tmux.js';
|
|
4
|
+
import { getActiveSessionNames } from '../websocket.js';
|
|
5
|
+
import { deleteDraft } from '../db.js';
|
|
6
|
+
const router = Router();
|
|
7
|
+
// List sessions for a token
|
|
8
|
+
router.get('/api/sessions', async (req, res) => {
|
|
9
|
+
if (!checkAuth(req, res))
|
|
10
|
+
return;
|
|
11
|
+
const token = extractToken(req) || 'default';
|
|
12
|
+
const sessions = await listSessions(token);
|
|
13
|
+
const activeNames = getActiveSessionNames();
|
|
14
|
+
const result = sessions.map((s) => ({
|
|
15
|
+
sessionId: s.sessionId,
|
|
16
|
+
sessionName: s.sessionName,
|
|
17
|
+
createdAt: s.createdAt,
|
|
18
|
+
active: activeNames.has(s.sessionName),
|
|
19
|
+
}));
|
|
20
|
+
res.json(result);
|
|
21
|
+
});
|
|
22
|
+
// Kill a specific session
|
|
23
|
+
router.delete('/api/sessions/:sessionId', async (req, res) => {
|
|
24
|
+
if (!checkAuth(req, res))
|
|
25
|
+
return;
|
|
26
|
+
const { sessionId } = req.params;
|
|
27
|
+
if (!/^[\w-]+$/.test(sessionId)) {
|
|
28
|
+
res.status(400).json({ error: 'Invalid sessionId' });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
const token = extractToken(req) || 'default';
|
|
32
|
+
const sessionName = buildSessionName(token, sessionId);
|
|
33
|
+
await killSession(sessionName);
|
|
34
|
+
deleteDraft(sessionName);
|
|
35
|
+
res.json({ ok: true });
|
|
36
|
+
});
|
|
37
|
+
// Get current working directory
|
|
38
|
+
router.get('/api/sessions/:sessionId/cwd', async (req, res) => {
|
|
39
|
+
const sessionName = resolveSession(req, res);
|
|
40
|
+
if (!sessionName)
|
|
41
|
+
return;
|
|
42
|
+
try {
|
|
43
|
+
const cwd = await getCwd(sessionName);
|
|
44
|
+
res.json({ cwd });
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
console.error(`[api:cwd] ${sessionName}:`, err);
|
|
48
|
+
res.status(404).json({ error: 'Session not found or not running' });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
// Get current pane command (to detect if claude is running)
|
|
52
|
+
router.get('/api/sessions/:sessionId/pane-command', async (req, res) => {
|
|
53
|
+
const sessionName = resolveSession(req, res);
|
|
54
|
+
if (!sessionName)
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
const command = await getPaneCommand(sessionName);
|
|
58
|
+
res.json({ command });
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
res.json({ command: '' });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
export default router;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { checkAuth, extractToken, tokenHash } from '../middleware/auth.js';
|
|
3
|
+
import { getSetting, saveSetting } from '../db.js';
|
|
4
|
+
import { safeTokenCompare } from '../auth.js';
|
|
5
|
+
const router = Router();
|
|
6
|
+
// Get font size
|
|
7
|
+
router.get('/api/settings/font-size', (req, res) => {
|
|
8
|
+
if (!checkAuth(req, res))
|
|
9
|
+
return;
|
|
10
|
+
const token = extractToken(req) || 'default';
|
|
11
|
+
const value = getSetting(tokenHash(token), 'font-size');
|
|
12
|
+
const fontSize = value !== null ? parseInt(value, 10) : 14;
|
|
13
|
+
res.json({ fontSize: isNaN(fontSize) ? 14 : fontSize });
|
|
14
|
+
});
|
|
15
|
+
// Save font size
|
|
16
|
+
router.put('/api/settings/font-size', (req, res) => {
|
|
17
|
+
if (!checkAuth(req, res))
|
|
18
|
+
return;
|
|
19
|
+
const token = extractToken(req) || 'default';
|
|
20
|
+
const { fontSize } = req.body;
|
|
21
|
+
if (typeof fontSize !== 'number' || fontSize < 10 || fontSize > 24) {
|
|
22
|
+
res.status(400).json({ error: 'fontSize must be a number between 10 and 24' });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
saveSetting(tokenHash(token), 'font-size', String(fontSize));
|
|
26
|
+
res.json({ ok: true });
|
|
27
|
+
});
|
|
28
|
+
// Get tabs layout
|
|
29
|
+
router.get('/api/settings/tabs-layout', (req, res) => {
|
|
30
|
+
if (!checkAuth(req, res))
|
|
31
|
+
return;
|
|
32
|
+
const token = extractToken(req) || 'default';
|
|
33
|
+
const value = getSetting(tokenHash(token), 'tabs-layout');
|
|
34
|
+
let layout = null;
|
|
35
|
+
if (value) {
|
|
36
|
+
try {
|
|
37
|
+
layout = JSON.parse(value);
|
|
38
|
+
}
|
|
39
|
+
catch { /* corrupt data */ }
|
|
40
|
+
}
|
|
41
|
+
res.json({ layout });
|
|
42
|
+
});
|
|
43
|
+
// Save tabs layout (supports both Authorization header and body token for sendBeacon)
|
|
44
|
+
router.put('/api/settings/tabs-layout', (req, res) => {
|
|
45
|
+
const { layout, token: bodyToken } = req.body;
|
|
46
|
+
const authToken = process.env.AUTH_TOKEN || '';
|
|
47
|
+
let token;
|
|
48
|
+
if (authToken) {
|
|
49
|
+
token = extractToken(req);
|
|
50
|
+
if (!token && bodyToken) {
|
|
51
|
+
if (!safeTokenCompare(bodyToken, authToken)) {
|
|
52
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
token = bodyToken;
|
|
56
|
+
}
|
|
57
|
+
if (!token) {
|
|
58
|
+
res.status(401).json({ error: 'Unauthorized' });
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
token = extractToken(req) || bodyToken || 'default';
|
|
64
|
+
}
|
|
65
|
+
if (!layout || typeof layout !== 'object') {
|
|
66
|
+
res.status(400).json({ error: 'layout must be an object' });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
saveSetting(tokenHash(token), 'tabs-layout', JSON.stringify(layout));
|
|
70
|
+
res.json({ ok: true });
|
|
71
|
+
});
|
|
72
|
+
export default router;
|
package/server/package.json
CHANGED