enigmatic 0.33.0 → 0.35.0
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/{CLIENT_JS_DOCS.md → README.md} +163 -36
- package/bin/enigmatic.js +3 -0
- package/client/public/AGENTS.md +314 -0
- package/client/public/client.js +125 -0
- package/client/public/custom.js +34 -0
- package/client/public/index.html +197 -0
- package/clientserver.png +0 -0
- package/package.json +7 -9
- package/server/bun-server.js +119 -0
- package/__tests__/e2.test.js +0 -310
- package/__tests__/jest.config.js +0 -7
- package/__tests__/jest.setup.js +0 -9
- package/bun-server.js +0 -130
- package/public/client.css +0 -286
- package/public/client.js +0 -80
- package/public/custom.js +0 -29
- package/public/index.html +0 -45
- package/public/index2.html +0 -9
- package/public/theme.css +0 -9
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
const D = document, W = window, Enc = encodeURIComponent;
|
|
2
|
+
|
|
3
|
+
// 1. Unified Render Logic (Handles both State & Custom Elements)
|
|
4
|
+
const ren = async (el, v) => {
|
|
5
|
+
const f = W.custom?.[el.tagName.toLowerCase()];
|
|
6
|
+
if (f) {
|
|
7
|
+
const dataAttr = el.getAttribute('data');
|
|
8
|
+
const val = v !== undefined ? v : (dataAttr ? W.state[dataAttr] : undefined);
|
|
9
|
+
try {
|
|
10
|
+
if (f.render) {
|
|
11
|
+
el.innerHTML = await f.render.call(f, val);
|
|
12
|
+
} else if (typeof f === 'function') {
|
|
13
|
+
el.innerHTML = await f(val);
|
|
14
|
+
}
|
|
15
|
+
} catch(e) { console.error(e) }
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// 2. Proxies setup
|
|
20
|
+
const cProx = new Proxy({}, {
|
|
21
|
+
set(t, p, v) {
|
|
22
|
+
t[p] = v;
|
|
23
|
+
setTimeout(() => {
|
|
24
|
+
if (W.$$ && D.body) {
|
|
25
|
+
W.$$(p).forEach(el => ren(el));
|
|
26
|
+
}
|
|
27
|
+
}, 0);
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
Object.defineProperty(W, 'custom', {
|
|
32
|
+
get: () => cProx,
|
|
33
|
+
set: v => {
|
|
34
|
+
Object.keys(v || {}).forEach(k => cProx[k] = v[k]);
|
|
35
|
+
// Defer initialization to ensure DOM and functions are ready
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
if (W.initCustomElements && D.body) W.initCustomElements();
|
|
38
|
+
}, 50);
|
|
39
|
+
},
|
|
40
|
+
configurable: true
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const sProx = new Proxy({}, {
|
|
44
|
+
set(o, p, v) {
|
|
45
|
+
o[p] = v;
|
|
46
|
+
W.$$(`[data="${p}"]`).forEach(el => ren(el, v));
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// 3. API & DOM Helpers
|
|
52
|
+
const req = (m, k, b) => fetch(`${W.api_url}/${k ? Enc(k) : ''}`, {
|
|
53
|
+
method: m, body: b instanceof Blob || typeof b === 'string' ? b : JSON.stringify(b)
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const toJson = (r) => {
|
|
57
|
+
const ct = (r.headers.get('content-type') || '').toLowerCase();
|
|
58
|
+
if (!ct.includes('application/json')) return r.text().then((t) => { throw new Error('Server returned non-JSON (HTML?): ' + (t.slice(0, 60) || r.status)); });
|
|
59
|
+
return r.json();
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
Object.assign(W, {
|
|
63
|
+
$: s => D.querySelector(s),
|
|
64
|
+
$$: s => D.querySelectorAll(s),
|
|
65
|
+
$c: s => $0.closest(s),
|
|
66
|
+
state: sProx,
|
|
67
|
+
get: k => req('GET', k).then(toJson),
|
|
68
|
+
set: (k, v) => req('POST', k, v).then(toJson),
|
|
69
|
+
put: (k, v) => req('PUT', k, v).then(toJson),
|
|
70
|
+
delete: k => req('DELETE', k).then(toJson),
|
|
71
|
+
purge: k => req('PURGE', k).then(toJson),
|
|
72
|
+
list: () => req('PROPFIND').then(toJson),
|
|
73
|
+
me: () => fetch(`${W.api_url}/me`, { credentials: "include" }).then((r) => (r.ok ? r.json() : null)),
|
|
74
|
+
login: () => W.location.href = `${W.api_url}/login`,
|
|
75
|
+
logout: () => W.location.href = `${W.api_url}/logout`,
|
|
76
|
+
download: async (k) => {
|
|
77
|
+
const r = await req('PATCH', k);
|
|
78
|
+
if (!r.ok) throw new Error('Download failed');
|
|
79
|
+
const a = D.createElement('a');
|
|
80
|
+
a.href = URL.createObjectURL(await r.blob());
|
|
81
|
+
a.download = k;
|
|
82
|
+
a.click();
|
|
83
|
+
URL.revokeObjectURL(a.href);
|
|
84
|
+
},
|
|
85
|
+
initCustomElements: () => {
|
|
86
|
+
if (!D.body) return;
|
|
87
|
+
Object.keys(W.custom || {}).forEach(t => {
|
|
88
|
+
const elements = W.$$(t);
|
|
89
|
+
if (elements.length > 0) {
|
|
90
|
+
elements.forEach(el => ren(el));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// 4. Initialization & Observers
|
|
97
|
+
const boot = () => {
|
|
98
|
+
if (W.initCustomElements) {
|
|
99
|
+
// Run immediately and also after a short delay to catch any elements added during script execution
|
|
100
|
+
W.initCustomElements();
|
|
101
|
+
setTimeout(() => W.initCustomElements(), 10);
|
|
102
|
+
}
|
|
103
|
+
if (D.body) {
|
|
104
|
+
new MutationObserver((mutations) => {
|
|
105
|
+
mutations.forEach(m => {
|
|
106
|
+
m.addedNodes.forEach(node => {
|
|
107
|
+
if (node.nodeType === 1) { // Element node
|
|
108
|
+
const tag = node.tagName?.toLowerCase();
|
|
109
|
+
if (tag && W.custom?.[tag]) ren(node);
|
|
110
|
+
// Also check children
|
|
111
|
+
node.querySelectorAll && Array.from(node.querySelectorAll('*')).forEach(child => {
|
|
112
|
+
const childTag = child.tagName?.toLowerCase();
|
|
113
|
+
if (childTag && W.custom?.[childTag]) ren(child);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
}).observe(D.body, { childList: true, subtree: true });
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
if (D.readyState === 'loading') {
|
|
122
|
+
D.addEventListener('DOMContentLoaded', boot);
|
|
123
|
+
} else {
|
|
124
|
+
setTimeout(boot, 0);
|
|
125
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
window.custom = {
|
|
2
|
+
"hello-world": (data) => `Hello ${data}`,
|
|
3
|
+
"hello-world-2": {
|
|
4
|
+
prop: (data) => `${data} World`,
|
|
5
|
+
render: function(data) {
|
|
6
|
+
return this.prop(data);
|
|
7
|
+
}
|
|
8
|
+
},
|
|
9
|
+
"file-widget": async () => {
|
|
10
|
+
try {
|
|
11
|
+
const list = await window.list();
|
|
12
|
+
const style = `<style>.w-c{font:13px sans-serif;border:1px solid #ddd;border-radius:6px;overflow:hidden;max-width:320px}.w-i{display:flex;justify-content:space-between;padding:8px 12px;border-bottom:1px solid #f0f0f0;align-items:center}.w-i:hover{background:#f9f9f9}.w-d{border:none;background:none;cursor:pointer;opacity:.5;transition:.2s}.w-d:hover{opacity:1}.w-u{display:block;padding:10px;background:#f5f5f5;text-align:center;cursor:pointer;color:#555;font-weight:600;transition:.2s}.w-u:hover{background:#eee}.w-e{padding:20px;text-align:center;color:#999}</style>`;
|
|
13
|
+
|
|
14
|
+
const items = Array.isArray(list) ? list.map(item => `
|
|
15
|
+
<div class="w-i">
|
|
16
|
+
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-right:10px">${item.name}</span>
|
|
17
|
+
<button class="w-d" onclick="window.download('${item.name}')" title="Download">⬇️</button>
|
|
18
|
+
<button class="w-d" onclick="(async()=>{await window.purge('${item.name}');location.reload()})()" title="Delete">🗑️</button>
|
|
19
|
+
</div>`
|
|
20
|
+
).join('') : '';
|
|
21
|
+
|
|
22
|
+
const upload = `
|
|
23
|
+
<label class="w-u">
|
|
24
|
+
📂 Upload
|
|
25
|
+
<input type="file" style="display:none" onchange="(async()=>{const f=this.files[0];if(f){await window.put(f.name,f);location.reload()})()">
|
|
26
|
+
</label>`;
|
|
27
|
+
|
|
28
|
+
return style + `<div class="w-c">${items || '<div class="w-e">No files</div>'}${upload}</div>`;
|
|
29
|
+
} catch (err) {
|
|
30
|
+
const style = `<style>.w-c{font:13px sans-serif;border:1px solid #ddd;border-radius:6px;overflow:hidden;max-width:320px}.w-e{padding:20px;text-align:center;color:#999}</style>`;
|
|
31
|
+
return style + `<div class="w-c"><div class="w-e">Please <button onclick="window.login()" style="background:#007bff;color:white;border:none;padding:5px 10px;border-radius:3px;cursor:pointer">Login</button> to view files</div></div>`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Enigmatic Webapp</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #f1f5f9;
|
|
10
|
+
--surface: #ffffff;
|
|
11
|
+
--text: #0f172a;
|
|
12
|
+
--text-muted: #64748b;
|
|
13
|
+
--accent: #6366f1;
|
|
14
|
+
--accent-hover: #4f46e5;
|
|
15
|
+
--border: #e2e8f0;
|
|
16
|
+
--input-bg: #f8fafc;
|
|
17
|
+
--radius: 12px;
|
|
18
|
+
--radius-sm: 8px;
|
|
19
|
+
--shadow: 0 1px 3px rgba(0,0,0,.06);
|
|
20
|
+
--shadow-md: 0 4px 12px rgba(0,0,0,.06);
|
|
21
|
+
}
|
|
22
|
+
* { box-sizing: border-box; }
|
|
23
|
+
body {
|
|
24
|
+
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
|
25
|
+
font-size: 15px;
|
|
26
|
+
line-height: 1.6;
|
|
27
|
+
max-width: 600px;
|
|
28
|
+
margin: 0 auto;
|
|
29
|
+
padding: 32px 24px;
|
|
30
|
+
background: var(--bg);
|
|
31
|
+
color: var(--text);
|
|
32
|
+
-webkit-font-smoothing: antialiased;
|
|
33
|
+
}
|
|
34
|
+
h1 {
|
|
35
|
+
font-size: 1.75rem;
|
|
36
|
+
font-weight: 700;
|
|
37
|
+
letter-spacing: -0.02em;
|
|
38
|
+
margin: 0 0 28px;
|
|
39
|
+
color: var(--text);
|
|
40
|
+
}
|
|
41
|
+
section {
|
|
42
|
+
background: var(--surface);
|
|
43
|
+
border-radius: var(--radius);
|
|
44
|
+
padding: 20px;
|
|
45
|
+
margin-bottom: 20px;
|
|
46
|
+
box-shadow: var(--shadow);
|
|
47
|
+
border: 1px solid var(--border);
|
|
48
|
+
}
|
|
49
|
+
section h2 {
|
|
50
|
+
font-size: 0.8125rem;
|
|
51
|
+
font-weight: 600;
|
|
52
|
+
letter-spacing: 0.02em;
|
|
53
|
+
text-transform: uppercase;
|
|
54
|
+
color: var(--text-muted);
|
|
55
|
+
margin: 0 0 14px;
|
|
56
|
+
}
|
|
57
|
+
section p { margin: 0 0 12px; color: var(--text-muted); font-size: 0.9375rem; }
|
|
58
|
+
input {
|
|
59
|
+
font-size: 14px;
|
|
60
|
+
padding: 10px 14px;
|
|
61
|
+
border-radius: var(--radius-sm);
|
|
62
|
+
border: 1px solid var(--border);
|
|
63
|
+
background: var(--input-bg);
|
|
64
|
+
color: var(--text);
|
|
65
|
+
width: 100%;
|
|
66
|
+
max-width: 200px;
|
|
67
|
+
transition: border-color .15s, box-shadow .15s;
|
|
68
|
+
}
|
|
69
|
+
input:focus {
|
|
70
|
+
outline: none;
|
|
71
|
+
border-color: var(--accent);
|
|
72
|
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, .15);
|
|
73
|
+
}
|
|
74
|
+
input::placeholder { color: var(--text-muted); opacity: .8; }
|
|
75
|
+
button {
|
|
76
|
+
font-size: 14px;
|
|
77
|
+
font-weight: 500;
|
|
78
|
+
padding: 10px 16px;
|
|
79
|
+
border-radius: var(--radius-sm);
|
|
80
|
+
border: none;
|
|
81
|
+
cursor: pointer;
|
|
82
|
+
transition: background .15s, transform .05s;
|
|
83
|
+
}
|
|
84
|
+
button:active { transform: scale(0.98); }
|
|
85
|
+
button:not(.secondary) {
|
|
86
|
+
background: var(--accent);
|
|
87
|
+
color: #fff;
|
|
88
|
+
margin-right: 8px;
|
|
89
|
+
}
|
|
90
|
+
button:not(.secondary):hover { background: var(--accent-hover); }
|
|
91
|
+
button.secondary {
|
|
92
|
+
background: var(--input-bg);
|
|
93
|
+
color: var(--text);
|
|
94
|
+
border: 1px solid var(--border);
|
|
95
|
+
}
|
|
96
|
+
button.secondary:hover { background: var(--border); }
|
|
97
|
+
#auth-status { margin-right: 12px; color: var(--text-muted); font-size: 14px; }
|
|
98
|
+
.row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-bottom: 10px; }
|
|
99
|
+
.row:last-of-type { margin-bottom: 0; }
|
|
100
|
+
#kv-result {
|
|
101
|
+
margin-top: 12px;
|
|
102
|
+
padding: 12px 14px;
|
|
103
|
+
background: var(--input-bg);
|
|
104
|
+
border-radius: var(--radius-sm);
|
|
105
|
+
font-size: 13px;
|
|
106
|
+
font-family: ui-monospace, monospace;
|
|
107
|
+
white-space: pre-wrap;
|
|
108
|
+
word-break: break-all;
|
|
109
|
+
min-height: 24px;
|
|
110
|
+
border: 1px solid var(--border);
|
|
111
|
+
}
|
|
112
|
+
.error { color: #dc2626; }
|
|
113
|
+
.reactive-split { display: flex; gap: 20px; align-items: flex-start; flex-wrap: wrap; }
|
|
114
|
+
.reactive-demo { flex: 1; min-width: 180px; }
|
|
115
|
+
.reactive-snippet {
|
|
116
|
+
flex: 1;
|
|
117
|
+
min-width: 220px;
|
|
118
|
+
font-size: 12px;
|
|
119
|
+
background: #1e293b;
|
|
120
|
+
color: #e2e8f0;
|
|
121
|
+
border-radius: var(--radius-sm);
|
|
122
|
+
padding: 14px;
|
|
123
|
+
overflow-x: auto;
|
|
124
|
+
border: none;
|
|
125
|
+
}
|
|
126
|
+
.reactive-snippet pre { margin: 0; font-family: ui-monospace, monospace; white-space: pre-wrap; word-break: break-word; line-height: 1.5; }
|
|
127
|
+
</style>
|
|
128
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
129
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
130
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
131
|
+
</head>
|
|
132
|
+
<body>
|
|
133
|
+
<h1>Enigmatic Webapp</h1>
|
|
134
|
+
|
|
135
|
+
<section>
|
|
136
|
+
<h2>Auth</h2>
|
|
137
|
+
<div class="row">
|
|
138
|
+
<span id="auth-status">Checking…</span>
|
|
139
|
+
<button onclick="window.login()">Login</button>
|
|
140
|
+
<button class="secondary" onclick="window.logout()">Logout</button>
|
|
141
|
+
</div>
|
|
142
|
+
</section>
|
|
143
|
+
|
|
144
|
+
<section>
|
|
145
|
+
<h2>Reactive state</h2>
|
|
146
|
+
<p>Edit the message; the custom element updates automatically.</p>
|
|
147
|
+
<div class="reactive-split">
|
|
148
|
+
<div class="reactive-demo">
|
|
149
|
+
<div class="row">
|
|
150
|
+
<input type="text" id="msg-input" placeholder="Message" value="World">
|
|
151
|
+
<button onclick="window.state.message = window.$('#msg-input').value">Update</button>
|
|
152
|
+
</div>
|
|
153
|
+
<p style="margin: 12px 0 0; font-size: 14px;"><hello-world data="message"></hello-world></p>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="reactive-snippet">
|
|
156
|
+
<pre><!-- HTML: data="message" binds to state.message -->
|
|
157
|
+
<hello-world data="message"></hello-world>
|
|
158
|
+
|
|
159
|
+
// JS: updating state re-renders the element
|
|
160
|
+
window.state.message = "World";</pre>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</section>
|
|
164
|
+
|
|
165
|
+
<section>
|
|
166
|
+
<h2>KV storage</h2>
|
|
167
|
+
<div class="row">
|
|
168
|
+
<input type="text" id="kv-key" placeholder="Key" value="greeting">
|
|
169
|
+
<input type="text" id="kv-value" placeholder="Value" value="Hello from KV">
|
|
170
|
+
</div>
|
|
171
|
+
<div class="row">
|
|
172
|
+
<button onclick="window.get(window.$('#kv-key').value).then(r => { window.$('#kv-result').textContent = JSON.stringify(r); window.$('#kv-result').classList.remove('error'); }).catch(e => { window.$('#kv-result').textContent = e.message; window.$('#kv-result').classList.add('error'); })">Get</button>
|
|
173
|
+
<button onclick="window.set(window.$('#kv-key').value, window.$('#kv-value').value).then(r => { window.$('#kv-result').textContent = JSON.stringify(r); window.$('#kv-result').classList.remove('error'); }).catch(e => { window.$('#kv-result').textContent = e.message; window.$('#kv-result').classList.add('error'); })">Set</button>
|
|
174
|
+
<button onclick="window.delete(window.$('#kv-key').value).then(r => { window.$('#kv-result').textContent = JSON.stringify(r); window.$('#kv-result').classList.remove('error'); }).catch(e => { window.$('#kv-result').textContent = e.message; window.$('#kv-result').classList.add('error'); })">Delete</button>
|
|
175
|
+
</div>
|
|
176
|
+
<div id="kv-result"></div>
|
|
177
|
+
</section>
|
|
178
|
+
|
|
179
|
+
<section>
|
|
180
|
+
<h2>Files</h2>
|
|
181
|
+
<file-widget></file-widget>
|
|
182
|
+
</section>
|
|
183
|
+
|
|
184
|
+
<script src="client.js"></script>
|
|
185
|
+
<script src="custom.js"></script>
|
|
186
|
+
<script>
|
|
187
|
+
window.api_url = window.api_url || window.location.origin;
|
|
188
|
+
window.state.message = window.$('#msg-input').value;
|
|
189
|
+
|
|
190
|
+
window.me().then(function(u) {
|
|
191
|
+
window.$('#auth-status').textContent = u ? ('Logged in as ' + (u.email || u.sub)) : 'Not logged in';
|
|
192
|
+
}).catch(function() {
|
|
193
|
+
window.$('#auth-status').textContent = 'Not logged in';
|
|
194
|
+
});
|
|
195
|
+
</script>
|
|
196
|
+
</body>
|
|
197
|
+
</html>
|
package/clientserver.png
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "enigmatic",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
"start": "bun --hot ./bun-server.js",
|
|
7
|
-
"test": "jest --config __tests__/jest.config.js"
|
|
3
|
+
"version": "0.35.0",
|
|
4
|
+
"bin": {
|
|
5
|
+
"enigmatic": "./bin/enigmatic.js"
|
|
8
6
|
},
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
"
|
|
7
|
+
"unpkg": "./client/public/client.js",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "bun run bin/enigmatic.js",
|
|
10
|
+
"hot": "bun --hot bin/enigmatic.js"
|
|
13
11
|
}
|
|
14
12
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { S3Client } from "bun";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { appendFile, mkdir, readFile } from "fs/promises";
|
|
4
|
+
|
|
5
|
+
const dir = import.meta.dir;
|
|
6
|
+
const certsDir = join(dir, "certs");
|
|
7
|
+
const publicDir = join(dir, "..", "client", "public");
|
|
8
|
+
const kvDir = join(dir, "kv");
|
|
9
|
+
const sessions = new Map();
|
|
10
|
+
const userKv = {};
|
|
11
|
+
|
|
12
|
+
const kvPath = (sub) => join(kvDir, `${String(sub).replace(/[^a-zA-Z0-9_-]/g, "_")}.jsonl`);
|
|
13
|
+
const json = (d, s = 200, h = {}) => new Response(JSON.stringify(d), { status: s, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PURGE, PROPFIND, PATCH, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization, Cookie", "Access-Control-Allow-Credentials": "true", "Content-Type": "application/json", ...h } });
|
|
14
|
+
const redir = (url, cookie) => new Response(null, { status: 302, headers: { Location: url, ...(cookie && { "Set-Cookie": cookie }) } });
|
|
15
|
+
|
|
16
|
+
async function getUserMap(sub) {
|
|
17
|
+
if (userKv[sub]) return userKv[sub];
|
|
18
|
+
const m = new Map();
|
|
19
|
+
try {
|
|
20
|
+
let buf = await readFile(kvPath(sub), "utf8");
|
|
21
|
+
if (buf.charCodeAt(0) === 0xfeff) buf = buf.slice(1);
|
|
22
|
+
const lines = buf.trim().split("\n").filter(Boolean);
|
|
23
|
+
for (const line of lines) {
|
|
24
|
+
try {
|
|
25
|
+
const row = JSON.parse(line);
|
|
26
|
+
if (Array.isArray(row) && row.length >= 2) {
|
|
27
|
+
m.set(row[0], row[1]);
|
|
28
|
+
} else if (row?.action === "update" && row.key !== undefined) {
|
|
29
|
+
m.set(row.key, row.value);
|
|
30
|
+
} else if (row?.action === "delete" && row.key !== undefined) {
|
|
31
|
+
m.delete(row.key);
|
|
32
|
+
}
|
|
33
|
+
} catch (_) { /* skip malformed line */ }
|
|
34
|
+
}
|
|
35
|
+
} catch (_) { /* file missing or unreadable */ }
|
|
36
|
+
userKv[sub] = m;
|
|
37
|
+
return m;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function appendKvLog(sub, action, key, value) {
|
|
41
|
+
await mkdir(kvDir, { recursive: true });
|
|
42
|
+
const ts = new Date().toISOString();
|
|
43
|
+
const row = action === "update" ? { action, key, value, timestamp: ts } : { action, key, timestamp: ts };
|
|
44
|
+
await appendFile(kvPath(sub), JSON.stringify(row) + "\n");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function saveUserKv(sub, action, key, value) {
|
|
48
|
+
await appendKvLog(sub, action, key, value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const s3 = new S3Client({
|
|
52
|
+
accessKeyId: Bun.env.CLOUDFLARE_ACCESS_KEY_ID,
|
|
53
|
+
secretAccessKey: Bun.env.CLOUDFLARE_SECRET_ACCESS_KEY,
|
|
54
|
+
bucket: Bun.env.CLOUDFLARE_BUCKET_NAME,
|
|
55
|
+
endpoint: Bun.env.CLOUDFLARE_PUBLIC_URL
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export default {
|
|
59
|
+
async fetch(req) {
|
|
60
|
+
const url = new URL(req.url), key = url.pathname.slice(1), cb = `${url.origin}/callback`;
|
|
61
|
+
const token = req.headers.get("Cookie")?.match(/token=([^;]+)/)?.[1];
|
|
62
|
+
const user = (Bun.env.TEST_MODE === "1" && token === Bun.env.TEST_SESSION_ID) ? { sub: "test-user" } : (token ? sessions.get(token) : null);
|
|
63
|
+
|
|
64
|
+
if (req.method === "OPTIONS") return json(null, 204);
|
|
65
|
+
|
|
66
|
+
if (req.method === "GET") {
|
|
67
|
+
const p = url.pathname === "/" ? "index.html" : url.pathname.slice(1);
|
|
68
|
+
if (p === "index.html" || /\.[a-z0-9]+$/i.test(p)) {
|
|
69
|
+
const f = Bun.file(join(publicDir, p));
|
|
70
|
+
if (await f.exists()) return new Response(f);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (url.pathname === "/login") return Response.redirect(`https://${Bun.env.AUTH0_DOMAIN}/authorize?${new URLSearchParams({ response_type: "code", client_id: Bun.env.AUTH0_CLIENT_ID, redirect_uri: cb, scope: "openid email profile" })}`);
|
|
75
|
+
|
|
76
|
+
if (url.pathname === "/callback") {
|
|
77
|
+
const code = url.searchParams.get("code");
|
|
78
|
+
if (!code) return json({ error: "No code" }, 400);
|
|
79
|
+
const tRes = await fetch(`https://${Bun.env.AUTH0_DOMAIN}/oauth/token`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "authorization_code", client_id: Bun.env.AUTH0_CLIENT_ID, client_secret: Bun.env.AUTH0_CLIENT_SECRET, code, redirect_uri: cb }) });
|
|
80
|
+
if (!tRes.ok) return json({ error: "Auth error" }, 401);
|
|
81
|
+
const tokens = await tRes.json();
|
|
82
|
+
const userInfo = await (await fetch(`https://${Bun.env.AUTH0_DOMAIN}/userinfo`, { headers: { Authorization: `Bearer ${tokens.access_token}` } })).json();
|
|
83
|
+
const sid = crypto.randomUUID();
|
|
84
|
+
sessions.set(sid, { ...userInfo, login_time: new Date().toISOString(), access_token_expires_at: tokens.expires_in ? new Date(Date.now() + tokens.expires_in * 1000).toISOString() : null });
|
|
85
|
+
return redir(url.origin, `token=${sid}; HttpOnly; Path=/; Secure; SameSite=Lax; Max-Age=86400`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (url.pathname === "/me") return user ? json(user) : json({ error: "Unauthorized" }, 401);
|
|
89
|
+
if (!token || !user) return json({ error: "Unauthorized" }, 401);
|
|
90
|
+
if (url.pathname === "/logout") { sessions.delete(token); return redir(url.origin, "token=; Max-Age=0; Path=/"); }
|
|
91
|
+
|
|
92
|
+
const m = await getUserMap(user.sub);
|
|
93
|
+
switch (req.method) {
|
|
94
|
+
case "GET": return json(m.get(key) ?? null);
|
|
95
|
+
case "POST":
|
|
96
|
+
const val = await req.text();
|
|
97
|
+
const v = (() => { try { return JSON.parse(val); } catch { return val; } })();
|
|
98
|
+
m.set(key, v);
|
|
99
|
+
await saveUserKv(user.sub, "update", key, v);
|
|
100
|
+
return json({ key, value: v });
|
|
101
|
+
case "DELETE": m.delete(key); await saveUserKv(user.sub, "delete", key); return json({ status: "Deleted" });
|
|
102
|
+
case "PUT": await s3.write(`${user.sub}/${key}`, req.body); return json({ status: "Saved to R2" });
|
|
103
|
+
case "PURGE": await s3.delete(`${user.sub}/${key}`); return json({ status: "Deleted from R2" });
|
|
104
|
+
case "PROPFIND":
|
|
105
|
+
const list = await s3.list({ prefix: `${user.sub}/` });
|
|
106
|
+
const items = Array.isArray(list) ? list : (list?.contents || []);
|
|
107
|
+
return json(items.map((i) => ({ name: i.key?.split("/").pop() || i.name || i.Key, lastModified: i.lastModified || i.LastModified, size: i.size || i.Size || 0 })));
|
|
108
|
+
case "PATCH":
|
|
109
|
+
try {
|
|
110
|
+
if (!(await s3.exists(`${user.sub}/${key}`))) return json({ error: "File not found" }, 404);
|
|
111
|
+
const f = await s3.file(`${user.sub}/${key}`);
|
|
112
|
+
return f ? new Response(f.stream(), { headers: f.headers }) : json({ error: "File not found" }, 404);
|
|
113
|
+
} catch (e) { return json({ error: "File not found", details: e.message }, 404); }
|
|
114
|
+
default: return json({ error: "Method not allowed" }, 405);
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
port: 3000,
|
|
118
|
+
tls: { cert: Bun.file(join(certsDir, "cert.pem")), key: Bun.file(join(certsDir, "key.pem")) }
|
|
119
|
+
};
|