Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
201 lines (198 loc) · 12.3 KB
/
Copy pathserver.js
File metadata and controls
201 lines (198 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
import http from 'node:http';
import https from 'node:https';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { promises as fs } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { passwordGuard, SESSION_TTL } from './lib/auth.js';
import { parseConfig, loadTls, hostAllowlist, help } from './lib/config.js';
import { browseFolders } from './lib/folders.js';
import { AppError, openProject, projectView, saveEntry, addEntry, renameEntry, deleteEntry } from './lib/projects.js';
const here = path.dirname(fileURLToPath(import.meta.url));
const mime = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml' };
async function body(req, limit = 8 * 1024 * 1024) {
if (!req.headers['content-type']?.startsWith('application/json')) throw new AppError('Expected JSON.', 415);
// Buffers are concatenated before decoding so multi-byte characters survive chunk boundaries.
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > limit) throw new AppError('Request is too large.', 413);
chunks.push(chunk);
}
let parsed;
try { parsed = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { throw new AppError('Invalid JSON request.'); }
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new AppError('Expected a JSON object.');
return parsed;
}
/**
* Builds the HTTP or HTTPS server for one workspace.
* Every request must carry an allowed Host and, when present, a same-origin Origin. Each browser
* gets a cookie-backed session holding at most one open project, and writes within a session are
* serialised so two cannot overlap.
* @param {string} options.dataDir Where recent-project history is stored.
* @param {string} [options.password] Omit to leave the workspace unauthenticated.
* @param {object} [options.tls] key and cert pair; its presence selects HTTPS and secure cookies.
* @param {() => number} [options.now] Clock, injected by tests.
*/
export function createServer({ dataDir = path.join(here, '.data'), password, host = '127.0.0.1', allowedHosts = [], tls, now = Date.now } = {}) {
const sessions = new Map();
const verifyPassword = password === undefined ? null : passwordGuard(password, now);
const hosts = hostAllowlist(host, allowedHosts);
const protocol = tls ? 'https:' : 'http:';
const cookieName = tls ? '__Host-arb_session' : 'arb_session';
const cookieOptions = `HttpOnly; SameSite=Strict; Path=/${tls ? '; Secure' : ''}`;
function pruneSessions() {
for (const [id, value] of sessions) if (now() - value.lastUsed >= SESSION_TTL) sessions.delete(id);
}
function newSession(res, oldId) {
pruneSessions();
if (oldId) sessions.delete(oldId);
// Evicting the least recently used session keeps a client without cookies from wedging the server.
while (sessions.size >= 100) {
let evict;
for (const [id, value] of sessions) if (evict === undefined || value.lastUsed < sessions.get(evict).lastUsed) evict = id;
sessions.delete(evict);
}
const id = randomUUID();
const session = { project: null, busy: false, lastUsed: now() };
sessions.set(id, session);
res.setHeader('Set-Cookie', `${cookieName}=${id}; ${cookieOptions}`);
return session;
}
let recentQueue = Promise.resolve();
async function recents() {
try { const data = JSON.parse(await fs.readFile(path.join(dataDir, 'recent.json'), 'utf8')); return Array.isArray(data) ? data.filter((item) => typeof item.path === 'string').slice(0, 8) : []; }
catch (error) { if (error.code !== 'ENOENT') console.error('Could not load recent projects:', error.message); return []; }
}
async function remember(project) {
const write = recentQueue.then(async () => {
const existing = await recents();
const view = projectView(project);
const list = [{ path: view.path, name: view.name, openedAt: new Date().toISOString() }, ...existing.filter((item) => item.path !== view.path)].slice(0, 8);
await fs.mkdir(dataDir, { recursive: true });
const temp = path.join(dataDir, `recent-${randomUUID()}.tmp`);
try { await fs.writeFile(temp, JSON.stringify(list, null, 2)); await fs.rename(temp, path.join(dataDir, 'recent.json')); }
finally { await fs.rm(temp, { force: true }); }
});
recentQueue = write.catch(() => {});
await write;
}
const handler = async (req, res) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'no-referrer');
res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'");
res.setHeader('Cache-Control', 'no-store');
function json(data, status = 200) { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); }
try {
const authority = req.headers.host;
if (!authority || !/^(?:\[[a-fA-F0-9:]+\]|[a-zA-Z0-9.-]+)(?::\d+)?$/.test(authority)) throw new AppError('Invalid server address.', 403);
let origin;
try { origin = new URL(`${protocol}//${authority}`); } catch { throw new AppError('Invalid server address.', 403); }
if (!hosts.has(origin.hostname)) throw new AppError('Use this server’s localhost or configured LAN address.', 403);
const listeningPort = server.address()?.port;
if (listeningPort && Number(origin.port || (tls ? 443 : 80)) !== listeningPort) throw new AppError('Invalid server port.', 403);
if (req.headers.origin && req.headers.origin !== origin.origin) throw new AppError('Cross-origin requests are not allowed.', 403);
if (req.headers['sec-fetch-site'] === 'cross-site') throw new AppError('Cross-site requests are not allowed.', 403);
const url = new URL(req.url, origin);
if (url.origin !== origin.origin) throw new AppError('Invalid request address.', 403);
if (url.pathname.startsWith('/api/')) {
pruneSessions();
const cookieId = req.headers.cookie?.split(';').map((part) => part.trim()).find((part) => part.startsWith(`${cookieName}=`))?.slice(cookieName.length + 1);
let session = sessions.get(cookieId);
if (req.method === 'GET' && url.pathname === '/api/auth') {
if (session) session.lastUsed = now();
return json({ required: Boolean(verifyPassword), authenticated: !verifyPassword || Boolean(session), secure: Boolean(tls) });
}
if (req.method === 'POST' && url.pathname === '/api/auth/login') {
if (verifyPassword) {
const input = await body(req, 4096);
await verifyPassword(input.password, req.socket?.remoteAddress ?? 'unknown');
}
newSession(res, cookieId);
return json({ ok: true });
}
if (verifyPassword && !session) throw new AppError('Sign in to access this workspace.', 401);
if (req.method === 'POST' && url.pathname === '/api/auth/logout') {
await body(req, 4096);
if (cookieId) sessions.delete(cookieId);
res.setHeader('Set-Cookie', `${cookieName}=; ${cookieOptions}; Max-Age=0`);
return json({ ok: true });
}
if (!session) session = newSession(res);
session.lastUsed = now();
if (req.method === 'GET' && url.pathname === '/api/recent') return json({ projects: await recents() });
if (req.method === 'GET' && url.pathname === '/api/project') return json({ project: session.project ? projectView(session.project) : null });
if (req.method !== 'POST') throw new AppError('Endpoint not found.', 404);
if (session.busy) throw new AppError('Another project action is in progress. Try again.', 409);
session.busy = true;
try {
const input = await body(req);
if (url.pathname === '/api/browse') return json({ folder: await browseFolders(input.path) });
if (url.pathname === '/api/open') {
const project = await openProject(input.path);
let warning;
try { await remember(project); } catch (error) { console.error('Could not store project history:', error.message); warning = 'Project opened, but recent-project history could not be saved.'; }
session.project = project;
return json({ project: projectView(project), warning });
}
if (url.pathname === '/api/close') { session.project = null; return json({ ok: true }); }
if (url.pathname === '/api/entry') {
if (!session.project) throw new AppError('Open a project first.');
return json({ project: await addEntry(session.project, input) });
}
if (url.pathname === '/api/entry/rename' || url.pathname === '/api/entry/delete') {
if (!session.project) throw new AppError('Open a project first.');
const action = url.pathname.endsWith('/rename') ? renameEntry : deleteEntry;
return json({ project: await action(session.project, input) });
}
if (url.pathname === '/api/save') {
if (!session.project) throw new AppError('Open a project first.');
return json({ project: await saveEntry(session.project, input) });
}
throw new AppError('Endpoint not found.', 404);
} finally { session.busy = false; }
}
if (!['GET', 'HEAD'].includes(req.method)) throw new AppError('Method not allowed.', 405);
const assets = new Map([['/', 'index.html'], ['/app.js', 'app.js'], ['/theme.js', 'theme.js'], ['/styles.css', 'styles.css'], ['/favicon.svg', 'favicon.svg']]);
const file = assets.get(url.pathname);
if (!file) throw new AppError('Not found.', 404);
const bytes = await fs.readFile(path.join(here, 'public', file));
res.writeHead(200, { 'Content-Type': mime[path.extname(file)] });
res.end(req.method === 'HEAD' ? undefined : bytes);
} catch (error) {
if (!(error instanceof AppError)) console.error(error);
if (error.retryAfter) res.setHeader('Retry-After', String(error.retryAfter));
if (!res.headersSent) json({ error: error instanceof AppError ? error.message : 'Something went wrong. Check the server console and try again.' }, error.status ?? 500);
else res.end();
}
};
const server = tls ? https.createServer({ ...tls, minVersion: 'TLSv1.2' }, handler) : http.createServer(handler);
server.requestTimeout = 30000;
server.headersTimeout = 15000;
return server;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const config = parseConfig(process.argv.slice(2), process.env, here);
if (config.help) console.log(help);
else {
const tls = await loadTls(config);
const requiresPassword = config.password !== undefined;
const server = createServer({ password: config.password, host: config.host, allowedHosts: config.allowedHosts, tls });
// The guard holds only a salted hash from here on, so drop the plaintext the config still carries.
config.password = undefined;
server.on('error', (error) => { console.error(`Could not start ARB Studio: ${error.message}`); process.exitCode = 1; });
server.listen(config.port, config.host, () => {
const protocol = tls ? 'https' : 'http';
const displayHost = config.host === '0.0.0.0' || config.host === '::' ? '127.0.0.1' : config.host.includes(':') ? `[${config.host}]` : config.host;
console.log(`ARB Studio is ready at ${protocol}://${displayHost}:${config.port}\nListening on ${config.host}:${config.port} · Password ${requiresPassword ? 'required' : 'disabled'}\nPress Ctrl+C to stop.`);
if (!['127.0.0.1', '::1', 'localhost'].includes(config.host)) {
if (!requiresPassword) console.log('LAN access has no password protection. Anyone who can reach this server can edit project files.');
if (requiresPassword && !tls) console.log('Use --ssl for LAN connections: HTTP does not encrypt passwords or project data.');
for (const name of hostAllowlist(config.host, config.allowedHosts)) if (name !== 'localhost' && name !== '127.0.0.1' && name !== '[::1]') console.log(`LAN URL: ${protocol}://${name}:${config.port}`);
}
});
}
} catch (error) { console.error(`Could not start ARB Studio: ${error.message}`); process.exitCode = 1; }
}