-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
256 lines (226 loc) · 10.6 KB
/
Copy pathserver.js
File metadata and controls
256 lines (226 loc) · 10.6 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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
/*---------------------------------------------------------------------------------------------
* LevelCode — reference Settings Sync server
*
* A tiny, dependency-free implementation of the Code-OSS user-data-sync REST contract
* (the same protocol the editor's built-in Settings Sync speaks). Use it to:
* • develop/test LevelCode Sync end-to-end with no cloud backend, and
* • self-host the FREE sync tier (BYO storage).
* LevelCode Cloud implements the same contract for managed sync.
*
* Storage is file-backed and isolated per bearer token (account). Bodies are opaque text —
* the server never parses settings, so client-side E2E encryption is trivial to add later.
* Supports user-scoped resources AND profile collections (/v1/collection/{id}/resource/...).
*
* Run: PORT=9595 DATA_DIR=./.data node server.js
*--------------------------------------------------------------------------------------------*/
// @ts-check
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const PORT = parseInt(process.env.PORT || '9595', 10);
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '.data');
const LOG = require.main === module; // log requests only when run as a server (quiet under tests)
function shortHash(s) { return crypto.createHash('sha256').update(String(s)).digest('hex').slice(0, 16); }
function accountDir(token) { return path.join(DATA_DIR, shortHash(token)); }
function ensureDir(d) { fs.mkdirSync(d, { recursive: true }); }
/** Read the persisted session id for an account, or null (none until the first write). */
function getSession(acct) {
try { return fs.readFileSync(path.join(acct, 'session'), 'utf8') || null; } catch { return null; }
}
/** Ensure a session id exists (created at first write, rotated by clear()); returns it. */
function ensureSession(acct) {
let s = getSession(acct);
if (!s) {
s = crypto.randomBytes(8).toString('hex');
ensureDir(acct);
fs.writeFileSync(path.join(acct, 'session'), s, 'utf8');
}
return s;
}
// Resources live under a BASE dir: the account root (user resources) or a collection dir.
/** Read a resource's {ref, content} under `base`, or null. */
function readResource(base, type) {
try { return JSON.parse(fs.readFileSync(path.join(base, 'resource', type + '.json'), 'utf8')); } catch { return null; }
}
/** Write a resource under `base`, bumping its ref. Returns the new ref (string). */
function writeResource(base, type, content) {
const prev = readResource(base, type);
const nextRef = String((prev ? parseInt(prev.ref, 10) : 0) + 1);
ensureDir(path.join(base, 'resource'));
fs.writeFileSync(path.join(base, 'resource', type + '.json'), JSON.stringify({ ref: nextRef, content }), 'utf8');
// keep history so GET /resource/:type/:ref works (a self-host superset of the core contract)
ensureDir(path.join(base, 'history', type));
fs.writeFileSync(path.join(base, 'history', type, nextRef), content, 'utf8');
return nextRef;
}
function deleteResource(base, type) {
fs.rmSync(path.join(base, 'resource', type + '.json'), { force: true });
fs.rmSync(path.join(base, 'history', type), { recursive: true, force: true });
}
/** Map of {type: ref} for every resource currently stored under `base`. */
function latestOf(base) {
const out = {};
const rdir = path.join(base, 'resource');
if (fs.existsSync(rdir)) {
for (const f of fs.readdirSync(rdir)) {
if (!f.endsWith('.json')) { continue; }
const t = f.slice(0, -5);
const r = readResource(base, t);
if (r) { out[t] = r.ref; }
}
}
return out;
}
function collectionsDir(acct) { return path.join(acct, 'collections'); }
function collectionDir(acct, id) { return path.join(collectionsDir(acct), id); }
function listCollections(acct) {
try { return fs.readdirSync(collectionsDir(acct)).filter(id => fs.statSync(collectionDir(acct, id)).isDirectory()); }
catch { return []; }
}
/** Build the manifest for an account, or null if it has never been written (→ 204). */
function buildManifest(acct) {
const session = getSession(acct);
if (!session) { return null; }
const latest = latestOf(acct);
const collections = {};
for (const id of listCollections(acct)) { collections[id] = { latest: latestOf(collectionDir(acct, id)) }; }
const ref = shortHash(session + ':' + JSON.stringify(latest) + ':' + JSON.stringify(collections));
return { session, ref, latest, collections };
}
/** Build the bulk "download all" payload (IDownloadLatestDataType shape). */
function buildDownload(acct) {
const dump = (base) => {
const resources = {};
for (const t of Object.keys(latestOf(base))) {
const r = readResource(base, t);
if (r) { resources[t] = [{ ref: r.ref, content: r.content }]; }
}
return resources;
};
const collections = {};
for (const id of listCollections(acct)) { collections[id] = { resources: dump(collectionDir(acct, id)) }; }
return { resources: dump(acct), collections };
}
function send(res, status, body, headers) {
res.writeHead(status, Object.assign({ 'Cache-Control': 'no-store' }, headers || {}));
res.end(body);
}
function readBody(req) {
return new Promise((resolve) => {
let data = '';
req.on('data', (c) => { data += c; });
req.on('end', () => resolve(data));
});
}
/** GET a resource under `base`: ref='latest' → current (204+ETag '0' if none); else a historical ref. */
function resourceGet(req, res, base, type, ref) {
if (ref === 'latest') {
const rec = readResource(base, type);
if (!rec) { return send(res, 204, '', { 'ETag': '0' }); }
const inm = req.headers['if-none-match'];
if (inm && inm === rec.ref) { return send(res, 304, '', { 'ETag': rec.ref }); }
return send(res, 200, rec.content, { 'Content-Type': 'text/plain', 'ETag': rec.ref });
}
try { return send(res, 200, fs.readFileSync(path.join(base, 'history', type, ref), 'utf8'), { 'Content-Type': 'text/plain', 'ETag': ref }); }
catch { return send(res, 404, 'No such ref'); }
}
/** POST a resource under `base` with If-Match optimistic concurrency ('0' = non-existing ref). */
async function resourcePost(req, res, base, type) {
const prev = readResource(base, type);
const cur = prev ? prev.ref : '0';
const ifMatch = req.headers['if-match'];
if (ifMatch !== undefined && ifMatch !== '*' && ifMatch !== cur) {
return send(res, 412, 'Precondition Failed', { 'ETag': cur });
}
const content = await readBody(req);
return send(res, 200, '', { 'ETag': writeResource(base, type, content) });
}
const server = http.createServer(async (req, res) => {
if (LOG) {
res.on('finish', () => {
const u = req.url || '';
if (u !== '/' && u !== '/health') { // log everything (incl. misdirected /api/update → 404)
console.log('[sync] ' + (req.method || 'GET') + ' ' + u + ' → ' + res.statusCode);
}
});
}
const url = (req.url || '').split('?')[0];
const method = req.method || 'GET';
if (url === '/' || url === '/health') {
return send(res, 200, 'LevelCode reference sync server\n', { 'Content-Type': 'text/plain' });
}
const auth = req.headers['authorization'] || '';
const tok = /^Bearer\s+(.+)$/i.exec(String(auth));
if (!url.startsWith('/v1')) { return send(res, 404, 'Not found'); }
if (!tok) { return send(res, 401, 'Missing bearer token'); }
const acct = accountDir(tok[1]);
// GET /v1/manifest (204 with no body until the first write creates a session)
if (url === '/v1/manifest' && method === 'GET') {
const manifest = buildManifest(acct);
if (!manifest) { return send(res, 204, '', { 'ETag': '0' }); }
const inm = req.headers['if-none-match'];
if (inm && inm === manifest.ref) { return send(res, 304, ''); }
return send(res, 200, JSON.stringify(manifest), { 'Content-Type': 'application/json', 'ETag': manifest.ref });
}
// GET /v1/download/latest (bulk snapshot; 204 before any data)
if (url === '/v1/download/latest' && method === 'GET') {
if (!getSession(acct)) { return send(res, 204, ''); }
return send(res, 200, JSON.stringify(buildDownload(acct)), { 'Content-Type': 'application/json' });
}
// Collection-scoped resource: /v1/collection/{cid}/resource/{type}[/{ref}]
let m = /^\/v1\/collection\/([^/]+)\/resource\/([^/]+)(?:\/([^/]+))?$/.exec(url);
if (m) {
const base = collectionDir(acct, m[1]);
if (!fs.existsSync(base)) { return send(res, 404, 'No such collection'); }
if (method === 'GET' && m[3]) { return resourceGet(req, res, base, m[2], m[3]); }
if (method === 'POST' && !m[3]) { ensureSession(acct); return resourcePost(req, res, base, m[2]); }
if (method === 'DELETE') { deleteResource(base, m[2]); return send(res, 200, ''); }
return send(res, 404, 'Not found');
}
// Collections (user-data profiles): list / create / delete
if (url === '/v1/collection' && method === 'GET') {
return send(res, 200, JSON.stringify(listCollections(acct).map(id => ({ id }))), { 'Content-Type': 'application/json' });
}
if (url === '/v1/collection' && method === 'POST') {
ensureSession(acct);
const id = shortHash(crypto.randomBytes(8).toString('hex'));
ensureDir(collectionDir(acct, id));
return send(res, 200, id, { 'Content-Type': 'text/plain' });
}
if (url === '/v1/collection' && method === 'DELETE') {
fs.rmSync(collectionsDir(acct), { recursive: true, force: true });
return send(res, 200, '');
}
m = /^\/v1\/collection\/([^/]+)$/.exec(url);
if (m && method === 'DELETE') {
fs.rmSync(collectionDir(acct, m[1]), { recursive: true, force: true });
return send(res, 200, '');
}
// DELETE /v1/resource (clear all — the client's clear() / resetRemote()). Rotates the session.
if (url === '/v1/resource' && method === 'DELETE') {
fs.rmSync(path.join(acct, 'resource'), { recursive: true, force: true });
fs.rmSync(path.join(acct, 'history'), { recursive: true, force: true });
fs.rmSync(collectionsDir(acct), { recursive: true, force: true });
fs.rmSync(path.join(acct, 'session'), { force: true });
return send(res, 204, '');
}
// Top-level resource: /v1/resource/{type}[/{ref}]
m = /^\/v1\/resource\/([^/]+)(?:\/([^/]+))?$/.exec(url);
if (m) {
if (method === 'GET' && m[2]) { return resourceGet(req, res, acct, m[1], m[2]); }
if (method === 'POST' && !m[2]) { ensureSession(acct); return resourcePost(req, res, acct, m[1]); }
if (method === 'DELETE') { deleteResource(acct, m[1]); return send(res, 200, ''); }
}
return send(res, 404, 'Not found: ' + method + ' ' + url);
});
if (require.main === module) {
ensureDir(DATA_DIR);
server.listen(PORT, () => {
// eslint-disable-next-line no-console
console.log('LevelCode reference sync server on http://localhost:' + PORT + ' (data: ' + DATA_DIR + ')');
console.log('Watch this terminal — every editor sync request prints as a [sync] line.');
});
}
module.exports = { server, buildManifest, buildDownload, readResource, writeResource, accountDir };