kreftjs 0.0.20-beta → 0.0.22-beta

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.
Files changed (64) hide show
  1. package/README.md +10 -1
  2. package/bin/artisan.js +4 -2
  3. package/bin/backend/cache.js +31 -5
  4. package/bin/backend/gc.js +193 -43
  5. package/bin/backend/http.js +6 -2
  6. package/bin/backend/index.js +6 -0
  7. package/bin/backend/mcp-client.js +133 -0
  8. package/bin/backend/mcp.js +17 -0
  9. package/bin/backend/model.js +15 -1
  10. package/bin/backend/shield.js +266 -0
  11. package/bin/backend/stream.js +74 -0
  12. package/bin/build.js +12 -5
  13. package/bin/db-api.js +9 -1
  14. package/bin/kreftjs.js +50 -12
  15. package/book/ar/16-virtual-list.md +131 -0
  16. package/book/ar/17-combobox.md +169 -0
  17. package/book/ar/18-tabs-offline.md +168 -0
  18. package/book/ar/19-wizard.md +196 -0
  19. package/book/ar/20-pipes-match-props.md +75 -0
  20. package/book/ar/README.md +12 -1
  21. package/book/kreft-book-ar.docx +0 -0
  22. package/docs/README.md +209 -13
  23. package/editors/sublime/Comments.tmPreferences +26 -0
  24. package/editors/sublime/Component.sublime-snippet +20 -0
  25. package/editors/sublime/Kreft.sublime-syntax +39 -0
  26. package/editors/sublime/LICENSE +95 -0
  27. package/editors/sublime/README.md +7 -0
  28. package/editors/sublime/action.sublime-snippet +10 -0
  29. package/editors/sublime/derive.sublime-snippet +8 -0
  30. package/editors/sublime/for.sublime-snippet +12 -0
  31. package/editors/sublime/k-case.sublime-snippet +8 -0
  32. package/editors/sublime/k-if.sublime-snippet +8 -0
  33. package/editors/sublime/k-match.sublime-snippet +12 -0
  34. package/editors/sublime/kreft-wait.sublime-snippet +12 -0
  35. package/editors/sublime/layout.sublime-snippet +8 -0
  36. package/editors/sublime/load.sublime-snippet +8 -0
  37. package/editors/sublime/on.sublime-snippet +10 -0
  38. package/editors/sublime/pipe.sublime-snippet +8 -0
  39. package/editors/sublime/prop-typed.sublime-snippet +8 -0
  40. package/editors/sublime/saved.sublime-snippet +8 -0
  41. package/editors/sublime/section.sublime-snippet +10 -0
  42. package/editors/sublime/state.sublime-snippet +8 -0
  43. package/editors/sublime/watch.sublime-snippet +10 -0
  44. package/editors/sublime/when.sublime-snippet +10 -0
  45. package/editors/sublime/yield.sublime-snippet +8 -0
  46. package/editors/vscode/snippets/kreftjs.json +143 -15
  47. package/editors/zed/LICENSE +95 -0
  48. package/editors/zed/README.md +17 -0
  49. package/editors/zed/extension.toml +11 -0
  50. package/editors/zed/languages/kreft/brackets.scm +4 -0
  51. package/editors/zed/languages/kreft/config.toml +17 -0
  52. package/editors/zed/languages/kreft/highlights.scm +18 -0
  53. package/editors/zed/languages/kreft/indents.scm +5 -0
  54. package/editors/zed/languages/kreft/injections.scm +14 -0
  55. package/editors/zed/languages/kreft/outline.scm +3 -0
  56. package/kreftjs.d.ts +1 -1
  57. package/kreftjs.js +219 -14
  58. package/package.json +3 -2
  59. package/server.d.ts +44 -0
  60. package/templates/app/backend/app/Auth/Session.js +8 -4
  61. package/templates/app/backend/app/Http/Middleware/Authenticate.js +7 -4
  62. package/templates/app/backend/database/seeders/DatabaseSeeder.js +4 -0
  63. package/templates/app/frontend/kreftjs.d.ts +1 -1
  64. package/templates/app/frontend/kreftjs.js +219 -14
@@ -0,0 +1,266 @@
1
+ /*
2
+ * Kreft Shield: automatic protection against floods and abuse from a single address.
3
+ *
4
+ * SHIELD=true in .env turns it on (off by default). Changes to .env apply without a restart.
5
+ *
6
+ * What it does, per visitor address (the same req.ip() that throttle uses, so TRUSTED_PROXIES applies):
7
+ * 1. Rate limit: more than SHIELD_MAX_REQUESTS (300) in SHIELD_WINDOW_SEC (10) seconds → 429 + Retry-After.
8
+ * Responses that look like probing (401, 403, 404, 405) count SHIELD_ERROR_WEIGHT (5) times, so scanners
9
+ * and password guessers hit the limit sooner than normal visitors.
10
+ * 2. Concurrency: more than SHIELD_MAX_CONCURRENT (60) requests of one address in flight at once → 429.
11
+ * 3. Ban: SHIELD_STRIKES (3) rate-limit hits within SHIELD_STRIKE_WINDOW_MIN (10) minutes → the address is
12
+ * refused for SHIELD_BAN_MINUTES (15), doubling at every repeat (up to 24 hours). Banned requests are
13
+ * answered with a bare 429 before any work is done.
14
+ * 4. Connections: more than SHIELD_MAX_CONNECTIONS (100) open sockets from one address are closed at once, which
15
+ * stops slow-connection floods (slowloris) that never finish a request. Needs Shield.watch(server) (kreft does it).
16
+ * 5. Overload: when the event loop lags by more than SHIELD_MAX_LAG_MS (300), limits shrink to a quarter
17
+ * so the server keeps answering instead of falling over.
18
+ *
19
+ * Never limited: SHIELD_ALLOW="1.2.3.4,5.6.7.8" and, unless SHIELD_LOCAL=true, this computer (127.0.0.1, ::1).
20
+ * Bans live in memory: a restart clears them. For a flood bigger than one process can absorb, put a firewall
21
+ * or CDN in front; the Shield protects the application, it does not replace network-level DDoS protection.
22
+ *
23
+ * const { Shield } = require('kreftjs/server')
24
+ * Shield.bans() Shield.unban('1.2.3.4') Shield.ban('1.2.3.4', 60) Shield.status() Shield.on('ban', fn)
25
+ */
26
+ const { EventEmitter } = require('events');
27
+
28
+ const events = new EventEmitter();
29
+ const ips = new Map(); // address → { windowStart, count, struck, strikes: [ms], level, lastBanAt, bannedUntil, inflight, last }
30
+ const MAX_ENTRIES = 100000;
31
+ const DAY = 86400000;
32
+
33
+ let cfgAt = 0;
34
+ let cfg = null;
35
+ let lagMonitor = null;
36
+ let lagMs = 0;
37
+ let timers = [];
38
+ let warnedProxy = false;
39
+ const conns = new Map(); // direct socket address → open connections
40
+ const stats = { blocked: 0, limited: 0, bans: 0 };
41
+
42
+ const num = (v, d) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : d);
43
+ const flag = (v) => ['true', '1', 'on', 'yes'].includes(String(v || '').trim().toLowerCase());
44
+
45
+ function config() {
46
+ const now = Date.now();
47
+ if (cfg && now - cfgAt < 2000) return cfg;
48
+ const e = process.env;
49
+ cfg = {
50
+ enabled: flag(e.SHIELD),
51
+ maxRequests: num(e.SHIELD_MAX_REQUESTS, 300),
52
+ windowMs: num(e.SHIELD_WINDOW_SEC, 10) * 1000,
53
+ errorWeight: num(e.SHIELD_ERROR_WEIGHT, 5),
54
+ maxConcurrent: num(e.SHIELD_MAX_CONCURRENT, 60),
55
+ maxConnections: num(e.SHIELD_MAX_CONNECTIONS, 100),
56
+ strikes: num(e.SHIELD_STRIKES, 3),
57
+ strikeWindowMs: num(e.SHIELD_STRIKE_WINDOW_MIN, 10) * 60000,
58
+ banMs: num(e.SHIELD_BAN_MINUTES, 15) * 60000,
59
+ maxLagMs: num(e.SHIELD_MAX_LAG_MS, 300),
60
+ local: flag(e.SHIELD_LOCAL),
61
+ allow: new Set(String(e.SHIELD_ALLOW || '').split(',').map((x) => x.trim().replace(/^::ffff:/, '')).filter(Boolean)),
62
+ };
63
+ cfgAt = now;
64
+ return cfg;
65
+ }
66
+
67
+ const isLoopback = (ip) => ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
68
+ const normalize = (ip) => String(ip || '').replace(/^::ffff:/, '');
69
+
70
+ // One IPv6 customer owns a whole /64 (billions of addresses): counting single addresses would let an attacker
71
+ // rotate through them and never be limited. IPv6 visitors are counted by their /64 prefix, IPv4 by address.
72
+ function keyOf(ip) {
73
+ if (!ip.includes(':')) return ip;
74
+ const s = ip.split('%')[0];
75
+ if (s.includes('.')) return ip; // an embedded IPv4 form: leave as is
76
+ const dbl = s.includes('::');
77
+ const [h, t] = s.split('::');
78
+ const head = h ? h.split(':') : [];
79
+ const tail = dbl && t ? t.split(':') : [];
80
+ const fill = dbl ? 8 - head.length - tail.length : 0;
81
+ if (fill < 0 || (!dbl && head.length !== 8)) return ip;
82
+ const groups = [...head, ...Array(fill).fill('0'), ...tail].map((x) => parseInt(x, 16));
83
+ if (groups.length !== 8 || groups.some((x) => Number.isNaN(x))) return ip;
84
+ return groups.slice(0, 4).map((x) => x.toString(16)).join(':') + '::/64';
85
+ }
86
+
87
+ function entry(ip, now) {
88
+ let e = ips.get(ip);
89
+ if (!e) {
90
+ if (ips.size >= MAX_ENTRIES) prune(true);
91
+ e = { windowStart: now, count: 0, struck: false, strikes: [], level: 0, lastBanAt: 0, bannedUntil: 0, inflight: 0, last: now };
92
+ ips.set(ip, e);
93
+ }
94
+ e.last = now;
95
+ return e;
96
+ }
97
+
98
+ // Drops addresses that are idle and not banned. `hard` also drops the oldest when the table is full.
99
+ function prune(hard = false) {
100
+ const now = Date.now();
101
+ for (const [ip, e] of ips) {
102
+ if (e.bannedUntil > now || e.inflight > 0) continue;
103
+ if (now - e.last > 10 * 60000 && (!e.lastBanAt || now - e.lastBanAt > DAY)) ips.delete(ip);
104
+ }
105
+ if (hard && ips.size >= MAX_ENTRIES) {
106
+ for (const [ip, e] of ips) { if (e.bannedUntil <= now && e.inflight === 0) { ips.delete(ip); if (ips.size < MAX_ENTRIES * 0.9) break; } }
107
+ }
108
+ }
109
+
110
+ function ensureRunning() {
111
+ if (timers.length) return;
112
+ try {
113
+ const { monitorEventLoopDelay } = require('perf_hooks');
114
+ lagMonitor = monitorEventLoopDelay({ resolution: 20 });
115
+ lagMonitor.enable();
116
+ } catch (_) { lagMonitor = null; }
117
+ const t1 = setInterval(() => {
118
+ if (lagMonitor) { lagMs = lagMonitor.percentile(99) / 1e6; lagMonitor.reset(); }
119
+ const c = config();
120
+ if (c.enabled && lagMs > c.maxLagMs && !ensureRunning.warned) {
121
+ ensureRunning.warned = true;
122
+ console.warn(` [Kreft Shield] The server is overloaded (event loop lag ${Math.round(lagMs)} ms): per-address limits are cut to a quarter.`);
123
+ } else if (lagMs <= c.maxLagMs) ensureRunning.warned = false;
124
+ }, 5000);
125
+ const t2 = setInterval(() => prune(), 60000);
126
+ timers = [t1, t2];
127
+ timers.forEach((t) => t.unref && t.unref());
128
+ try { require('./gc.js').onMemoryPressure('shield', () => { const before = ips.size; prune(true); return before - ips.size; }); } catch (_) {}
129
+ }
130
+
131
+ function banEntry(ip, e, ms, reason) {
132
+ const now = Date.now();
133
+ e.bannedUntil = now + ms;
134
+ e.lastBanAt = now;
135
+ e.level++;
136
+ e.strikes = [];
137
+ stats.bans++;
138
+ const info = { ip, minutes: Math.round(ms / 60000), reason, until: new Date(e.bannedUntil).toISOString(), level: e.level };
139
+ console.warn(` [Kreft Shield] Banned ${ip} for ${info.minutes} min (${reason}).`);
140
+ events.emit('ban', info);
141
+ return info;
142
+ }
143
+
144
+ function refuse(res, seconds, why) {
145
+ stats.blocked++;
146
+ try {
147
+ res.writeHead(429, { 'Retry-After': String(Math.max(1, Math.ceil(seconds))), 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', Connection: 'close' });
148
+ res.end(why);
149
+ } catch (_) { try { res.destroy(); } catch (__) {} }
150
+ return true;
151
+ }
152
+
153
+ /**
154
+ * Call first for every request. Returns true when it already answered (the request must stop there).
155
+ */
156
+ function guard(req, res) {
157
+ const c = config();
158
+ if (!c.enabled) return false;
159
+ ensureRunning();
160
+ let ip;
161
+ try { ip = normalize(require('./http.js').Request.prototype.ip.call({ raw: req })); } catch (_) { ip = normalize(req.socket && req.socket.remoteAddress); }
162
+ if (!ip) return false;
163
+ // Behind a proxy that is not in TRUSTED_PROXIES every visitor looks like the proxy: one address (all users would
164
+ // share one limit and one ban) or, for a proxy on this computer, loopback (which is never limited). Say so once.
165
+ if (!warnedProxy && req.headers['x-forwarded-for'] && ip === normalize(req.socket && req.socket.remoteAddress)) {
166
+ warnedProxy = true;
167
+ console.warn(` [Kreft Shield] Requests carry X-Forwarded-For, but ${ip} is not in TRUSTED_PROXIES, so the real visitor address is unknown and the Shield cannot tell visitors apart. Behind nginx, Caddy or a CDN, set TRUSTED_PROXIES in .env.`);
168
+ }
169
+ if (c.allow.has(ip) || (isLoopback(ip) && !c.local)) return false;
170
+
171
+ const now = Date.now();
172
+ const key = keyOf(ip);
173
+ const e = entry(key, now);
174
+
175
+ if (e.bannedUntil > now) return refuse(res, (e.bannedUntil - now) / 1000, 'Too many requests. Try again later.');
176
+ if (e.bannedUntil && e.bannedUntil <= now) e.bannedUntil = 0;
177
+ if (e.level && e.lastBanAt && now - e.lastBanAt > DAY) e.level = 0;
178
+
179
+ if (now - e.windowStart >= c.windowMs) { e.windowStart = now; e.count = 0; e.struck = false; }
180
+ e.strikes = e.strikes.filter((t) => now - t < c.strikeWindowMs);
181
+
182
+ const overloaded = lagMs > c.maxLagMs;
183
+ const limit = Math.max(5, Math.floor(c.maxRequests / (overloaded ? 4 : 1)));
184
+ const concurrent = Math.max(3, Math.floor(c.maxConcurrent / (overloaded ? 4 : 1)));
185
+ e.count++;
186
+
187
+ const overRate = e.count > limit;
188
+ const overConc = e.inflight >= concurrent;
189
+ if (overRate || overConc) {
190
+ stats.limited++;
191
+ if (!e.struck) {
192
+ e.struck = true;
193
+ e.strikes.push(now);
194
+ if (e.strikes.length >= c.strikes) {
195
+ const ms = Math.min(DAY, c.banMs * 2 ** e.level);
196
+ banEntry(key, e, ms, `${e.strikes.length} rate-limit hits in ${Math.round(c.strikeWindowMs / 60000)} min`);
197
+ return refuse(res, ms / 1000, 'Too many requests. Try again later.');
198
+ }
199
+ }
200
+ return refuse(res, (e.windowStart + c.windowMs - now) / 1000, 'Too many requests. Slow down.');
201
+ }
202
+
203
+ e.inflight++;
204
+ let done = false;
205
+ const finish = () => { if (done) return; done = true; e.inflight = Math.max(0, e.inflight - 1); };
206
+ res.once('close', finish);
207
+ res.once('finish', () => {
208
+ // probing (wrong login, missing pages, forbidden) costs more, so scanners trip the limit early
209
+ const s = res.statusCode;
210
+ if (s === 401 || s === 403 || s === 404 || s === 405) e.count += c.errorWeight - 1;
211
+ finish();
212
+ });
213
+ return false;
214
+ }
215
+
216
+ const Shield = {
217
+ guard,
218
+ enabled: () => config().enabled,
219
+ on: (event, fn) => { events.on(event, fn); return Shield; },
220
+ off: (event, fn) => { events.off(event, fn); return Shield; },
221
+ /**
222
+ * Caps open connections per direct address (call once on the http.Server). Connections from this computer or from a
223
+ * proxy in TRUSTED_PROXIES are not capped here: they carry many visitors, and the request limits tell those apart.
224
+ */
225
+ watch(server) {
226
+ server.on('connection', (sock) => {
227
+ const c = config();
228
+ if (!c.enabled) return;
229
+ const ip = normalize(sock.remoteAddress);
230
+ const trusted = String(process.env.TRUSTED_PROXIES || '').split(',').map((x) => normalize(x.trim())).filter(Boolean);
231
+ if (!ip || c.allow.has(ip) || (isLoopback(ip) && !c.local) || trusted.includes('*') || trusted.includes(ip)) return;
232
+ const key = keyOf(ip);
233
+ const n = (conns.get(key) || 0) + 1;
234
+ if (n > c.maxConnections) { stats.blocked++; sock.destroy(); return; }
235
+ conns.set(key, n);
236
+ sock.once('close', () => { const m = (conns.get(key) || 1) - 1; if (m <= 0) conns.delete(key); else conns.set(key, m); });
237
+ });
238
+ return Shield;
239
+ },
240
+ /** Currently banned addresses. */
241
+ bans() {
242
+ const now = Date.now();
243
+ const out = [];
244
+ for (const [ip, e] of ips) if (e.bannedUntil > now) out.push({ ip, until: new Date(e.bannedUntil).toISOString(), level: e.level, secondsLeft: Math.ceil((e.bannedUntil - now) / 1000) });
245
+ return out;
246
+ },
247
+ ban(ip, minutes = 60, reason = 'manual') { const a = keyOf(normalize(ip)); return banEntry(a, entry(a, Date.now()), minutes * 60000, reason); },
248
+ unban(ip) {
249
+ const e = ips.get(keyOf(normalize(ip)));
250
+ if (!e) return false;
251
+ e.bannedUntil = 0; e.level = 0; e.strikes = []; e.count = 0; e.struck = false;
252
+ return true;
253
+ },
254
+ status() {
255
+ const c = config();
256
+ return { enabled: c.enabled, tracked: ips.size, banned: Shield.bans().length, ...stats, eventLoopLagMs: Math.round(lagMs), overloaded: lagMs > c.maxLagMs, limits: { maxRequests: c.maxRequests, windowSec: c.windowMs / 1000, maxConcurrent: c.maxConcurrent, strikes: c.strikes, banMinutes: c.banMs / 60000 } };
257
+ },
258
+ /** For tests: forget everything and stop the timers. */
259
+ reset() {
260
+ ips.clear(); conns.clear(); warnedProxy = false; cfg = null; lagMs = 0; stats.blocked = stats.limited = stats.bans = 0;
261
+ timers.forEach(clearInterval); timers = [];
262
+ if (lagMonitor) { try { lagMonitor.disable(); } catch (_) {} lagMonitor = null; }
263
+ },
264
+ };
265
+
266
+ module.exports = { Shield, _keyOf: keyOf };
@@ -0,0 +1,74 @@
1
+ // Sending a file the way a video player needs it: byte ranges (206), so the browser can seek and Safari plays at all.
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ const MEDIA_TYPES = {
6
+ '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.ogv': 'video/ogg', '.mov': 'video/quicktime', '.mkv': 'video/x-matroska',
7
+ '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.wav': 'audio/wav', '.opus': 'audio/ogg', '.flac': 'audio/flac',
8
+ '.m3u8': 'application/vnd.apple.mpegurl', '.ts': 'video/mp2t', '.m4s': 'video/iso.segment', '.mpd': 'application/dash+xml', '.vtt': 'text/vtt',
9
+ };
10
+
11
+ // "bytes=0-99", "bytes=100-", "bytes=-500" -> { start, end } | null (no range) | false (cannot be satisfied)
12
+ function parseRange(header, size) {
13
+ if (!header) return null;
14
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header).trim()); // one range only; several ranges are answered with the whole file
15
+ if (!m || (m[1] === '' && m[2] === '')) return null;
16
+ let start, end;
17
+ if (m[1] === '') { // the last N bytes
18
+ const n = Number(m[2]);
19
+ if (n === 0) return false;
20
+ start = Math.max(0, size - n); end = size - 1;
21
+ } else {
22
+ start = Number(m[1]);
23
+ end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1);
24
+ }
25
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) return false;
26
+ return { start, end };
27
+ }
28
+
29
+ // sendFile(req, res, file, { type, headers, status, download })
30
+ function sendFile(req, res, file, opts = {}) {
31
+ req = req || res.req;
32
+ let stat;
33
+ try { stat = fs.statSync(file); if (!stat.isFile()) throw new Error('not a file'); } catch (_) {
34
+ if (!res.headersSent) res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
35
+ return res.end('Not found');
36
+ }
37
+ const size = stat.size;
38
+ const type = opts.type || MEDIA_TYPES[path.extname(file).toLowerCase()] || 'application/octet-stream';
39
+ const etag = `W/"${size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
40
+ const headers = {
41
+ 'Content-Type': type,
42
+ 'Accept-Ranges': 'bytes',
43
+ 'Last-Modified': stat.mtime.toUTCString(),
44
+ ETag: etag,
45
+ 'X-Content-Type-Options': 'nosniff',
46
+ ...(opts.download ? { 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(path.basename(file))}` } : {}),
47
+ ...(opts.headers || {}),
48
+ };
49
+ const method = (req && req.method) || 'GET';
50
+ const h = (req && req.headers) || {};
51
+
52
+ if (h['if-none-match'] === etag && !h.range) { res.writeHead(304, headers); return res.end(); }
53
+
54
+ let range = parseRange(h.range, size);
55
+ // If-Range: only honor the range when the file is still the one the client started with
56
+ if (range && h['if-range'] && h['if-range'] !== etag && h['if-range'] !== headers['Last-Modified']) range = null;
57
+ if (range === false) {
58
+ res.writeHead(416, { 'Content-Range': `bytes */${size}`, 'Content-Type': 'text/plain; charset=utf-8' });
59
+ return res.end('Range not satisfiable');
60
+ }
61
+
62
+ let status = opts.status || 200, start = 0, end = size - 1;
63
+ if (range) { status = 206; ({ start, end } = range); headers['Content-Range'] = `bytes ${start}-${end}/${size}`; }
64
+ headers['Content-Length'] = size === 0 ? 0 : end - start + 1;
65
+ res.writeHead(status, headers);
66
+ if (method === 'HEAD' || size === 0) return res.end();
67
+
68
+ const stream = fs.createReadStream(file, { start, end });
69
+ stream.on('error', () => res.destroy());
70
+ res.on('close', () => stream.destroy()); // the viewer seeked or left: stop reading
71
+ stream.pipe(res);
72
+ }
73
+
74
+ module.exports = { sendFile, parseRange, MEDIA_TYPES };
package/bin/build.js CHANGED
@@ -25,7 +25,7 @@ const path = require('path');
25
25
  const Kreftjs = require('../kreftjs.js');
26
26
 
27
27
  const { expandBlocks, prepareTemplate, splitKreft, splitInterp, exactBrace, stripBrace, interpToExpr, quoteBraceAttrs, expandSelfClosing,
28
- scopeCss, hashString, propType, simplePathRE, fnExpRE, forAliasRE, stripTypeScript } = Kreftjs.__internal;
28
+ scopeCss, hashString, propType, typeCheck, caseCond, simplePathRE, fnExpRE, forAliasRE, stripTypeScript } = Kreftjs.__internal;
29
29
 
30
30
  const deps = require('./deps.js');
31
31
  // component files: .kreft, or .kreftjs in a project from the 0.0.x betas
@@ -329,12 +329,16 @@ function compileFile(file, rel, root) {
329
329
 
330
330
  // props: keep the default as a function so every instance gets its own copy
331
331
  const props = Object.entries(sp.props).map(([p, expr]) => {
332
- if (expr == null) return `${JSON.stringify(p)}: {}`;
332
+ // `prop size: 'sm' | 'lg'`: the type text is kept, so the built app warns about a wrong value too
333
+ const ti = sp.propTypes && sp.propTypes[p];
334
+ const simple = ti && { string: 'String', number: 'Number', boolean: 'Boolean' }[ti.type.trim()];
335
+ const typed = ti ? `, typeText: ${JSON.stringify(ti.type)}, check: Kreftjs.__typeCheck(${JSON.stringify(ti.type)})${ti.required ? ', required: true' : ''}` : '';
336
+ if (expr == null) return `${JSON.stringify(p)}: { ${simple ? `type: ${simple}` : ''}${typed} }`.replace('{ , ', '{ ').replace('{ }', '{}');
333
337
  let sample;
334
338
  try { sample = new Function(`return (${expr})`)(); } catch (_) { /* uses something only known at runtime */ }
335
- if (typeof sample === 'function') return `${JSON.stringify(p)}: { type: Function, default: (${expr}) }`;
339
+ if (typeof sample === 'function') return `${JSON.stringify(p)}: { type: Function, default: (${expr})${typed} }`;
336
340
  const type = propType(sample);
337
- return `${JSON.stringify(p)}: { ${type ? `type: ${type.name}, ` : ''}default: function () { return (${expr}); } }`;
341
+ return `${JSON.stringify(p)}: { ${type ? `type: ${type.name}, ` : ''}default: function () { return (${expr}); }${typed} }`;
338
342
  });
339
343
 
340
344
  // imports: other .kreft files become component references; .js modules load with import()
@@ -439,12 +443,15 @@ function addRouterTemplates(fns) {
439
443
  addHandler(fns, 'navigate($event)');
440
444
  }
441
445
 
442
- const STRUCTURAL = new Set(['if', 'elif', 'else', 'each', 'key', 'ref', 'skip', 'cloak', 'slot', 'empty', 'once', 'animate', 'waiting', 'failed']);
446
+ const STRUCTURAL = new Set(['match', 'case', 'default', 'if', 'elif', 'else', 'each', 'key', 'ref', 'skip', 'cloak', 'slot', 'empty', 'once', 'animate', 'waiting', 'failed']);
443
447
 
444
448
  function collectExpressions(template, fns) {
445
449
  const html = prepareTemplate(template);
450
+ const subjects = []; // k-match subjects seen so far: a k-case is compared with one of them
446
451
  scanHtml(html, {
447
452
  attr(name, value) {
453
+ if (name === 'k-match') { const v = stripBrace(value); subjects.push(v); return addEval(fns, v); }
454
+ if (name === 'k-case') { const raw = stripBrace(value); for (const sub of subjects) addEval(fns, caseCond(sub, raw)); return; }
448
455
  if (name[0] === ':' || name === 'k-bind' || name.startsWith('k-bind:')) return addEval(fns, value);
449
456
  if (name[0] === '@' || name.startsWith('k-on:')) return addHandler(fns, stripBrace(value));
450
457
  if (name.startsWith('k-')) {
package/bin/db-api.js CHANGED
@@ -16,10 +16,14 @@
16
16
  * Which tables, and what each allows, comes from `tables` (kreftjs.config.json):
17
17
  * { "posts": true, "users": { "write": false, "hidden": ["password"] }, "logs": false }
18
18
  * `kreft dev` shows every table unless you list them; `kreft serve` shows only the tables you list.
19
+ * Credential-looking columns (password, token, secret, hash, …) are hidden by default ("show": ["token"] opens one), and
20
+ * tables of sessions, tokens and jobs are only opened when named in the list.
19
21
  */
20
22
  const { validate } = require('../kreftjs.js'); // the same rules as Kreftjs.validate in the browser
21
23
  const MAX_BODY = 1024 * 1024;
22
24
  const MAX_ROWS = 1000;
25
+ const SECRET_COL_RE = /(password|passwd|secret|token|api_?key|private_?key|recovery_?code|remember|two_factor|salt|hash)/i;
26
+ const SECRET_TABLE_RE = /^(personal_access_tokens|password_reset_tokens|password_resets|sessions|failed_jobs|jobs|job_batches|cache|cache_locks)$/i;
23
27
 
24
28
  function createDbApi(db, { tables: allow = null, production = false, prefix = '/api', log = console } = {}) {
25
29
  const isProd = Boolean(production || process.env.APP_ENV === 'production' || process.env.NODE_ENV === 'production');
@@ -29,13 +33,17 @@ function createDbApi(db, { tables: allow = null, production = false, prefix = '/
29
33
  const out = new Map();
30
34
  for (const t of all) {
31
35
  if (t.name === 'kreft_migrations') continue;
36
+ // tables that hold logins, tokens or queued payloads are never shown by default, even by `kreft dev` with no list:
37
+ // only a table named in kreftjs.config.json is opened
38
+ if (SECRET_TABLE_RE.test(t.name) && !(allow && allow[t.name] != null && allow[t.name] !== false)) continue;
32
39
  let rules = allow ? allow[t.name] : true;
33
40
  if (rules == null || rules === false) continue;
34
41
  if (rules === true) rules = {};
35
42
  const write = rules.write === undefined ? true : rules.write;
36
43
  out.set(t.name, {
37
44
  ...t,
38
- hidden: new Set(rules.hidden || []),
45
+ // columns that look like credentials are hidden unless "show": ["token"] lists them (a "hidden" list adds to them)
46
+ hidden: new Set([...t.columns.map((c) => c.name).filter((n) => SECRET_COL_RE.test(n) && !(rules.show || []).includes(n)), ...(rules.hidden || [])]),
39
47
  rules: rules.rules || null, // { "title": "required|min:3", "email": "required|email" }
40
48
  can: {
41
49
  read: rules.read !== false,
package/bin/kreftjs.js CHANGED
@@ -583,6 +583,30 @@ function copyFolder(from, to) {
583
583
  }
584
584
 
585
585
  /* ------------------------------------------------------------------ kreft vscode */
586
+ /* VS Code only loads extensions listed in extensions.json, and after "Uninstall" it keeps the folder in .obsolete and
587
+ deletes it on the next start. Copying the folder is not enough after an uninstall: list it again and clear .obsolete. */
588
+ function registerExtension(dir, folder, version) {
589
+ const id = 'kreft.kreftjs';
590
+ const location = path.join(dir, folder);
591
+ try {
592
+ const file = path.join(dir, 'extensions.json');
593
+ let list = [];
594
+ if (fs.existsSync(file)) { try { list = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { list = []; } }
595
+ if (!Array.isArray(list)) list = [];
596
+ list = list.filter((e) => !(e && e.identifier && /^kreft\.kreft(js)?$/.test(e.identifier.id)));
597
+ list.push({ identifier: { id }, version, location: { $mid: 1, fsPath: location, external: 'file://' + location, path: location, scheme: 'file' }, relativeLocation: folder, metadata: { installedTimestamp: Date.now(), pinned: false, source: 'resource', isApplicationScoped: false, isMachineScoped: false, isBuiltin: false, targetPlatform: 'undefined' } });
598
+ fs.writeFileSync(file, JSON.stringify(list));
599
+ } catch {}
600
+ try {
601
+ const file = path.join(dir, '.obsolete');
602
+ if (fs.existsSync(file)) {
603
+ const o = JSON.parse(fs.readFileSync(file, 'utf8'));
604
+ for (const k of Object.keys(o)) if (k.startsWith('kreft.kreftjs-') || k.startsWith('kreft.kreft-')) delete o[k];
605
+ fs.writeFileSync(file, JSON.stringify(o));
606
+ }
607
+ } catch {}
608
+ }
609
+
586
610
  function installVscode() {
587
611
  const os = require('os');
588
612
  const from = path.join(__dirname, '..', 'editors', 'vscode');
@@ -595,6 +619,7 @@ function installVscode() {
595
619
  fs.mkdirSync(dir, { recursive: true });
596
620
  fs.readdirSync(dir).filter((n) => n.startsWith('kreft.kreftjs-') || n.startsWith('kreft.kreft-')) /* the older extensions go too */.forEach((n) => fs.rmSync(path.join(dir, n), { recursive: true, force: true }));
597
621
  copyFolder(from, path.join(dir, `kreft.kreftjs-${ext.version}`));
622
+ registerExtension(dir, `kreft.kreftjs-${ext.version}`, ext.version);
598
623
  console.log(` Installed in ${path.join(dir, `kreft.kreftjs-${ext.version}`)}`);
599
624
  }
600
625
  // Ensure current project .vscode/settings.json maps .kreft files to Kreft and configures formatter
@@ -615,7 +640,7 @@ function installVscode() {
615
640
  /* ------------------------------------------------------------------ kreft dev */
616
641
  async function devServer(args, { production = false } = {}) {
617
642
  const isProd = Boolean(production || process.env.APP_ENV === 'production' || process.env.NODE_ENV === 'production');
618
- let port = production ? Number(process.env.PORT) || 8000 : 8000, folder = production ? 'dist' : '.', apiFile = null, delay = 0, saveApi = true, hot = !production, dbUrl = null, migrations = null, serverFolder = null, backendFolder = null, seedNew = false;
643
+ let port = production ? Number(process.env.PORT) || 8000 : 8000, folder = production ? 'dist' : '.', apiFile = null, delay = 0, saveApi = true, hot = !production, dbUrl = null, migrations = null, serverFolder = null, backendFolder = null, seedNew = false, force = false;
619
644
  // kreft dev only answers this computer; --host 0.0.0.0 shares it on your network.
620
645
  // kreft serve answers everyone (HOST in the environment can narrow it).
621
646
  let host = production ? process.env.HOST || '0.0.0.0' : '127.0.0.1';
@@ -630,6 +655,7 @@ async function devServer(args, { production = false } = {}) {
630
655
  else if (args[i] === '--server') serverFolder = path.resolve(args[++i]);
631
656
  else if (args[i] === '--backend') backendFolder = path.resolve(args[++i]);
632
657
  else if (args[i] === '--seed') seedNew = true;
658
+ else if (args[i] === '--force') force = true;
633
659
  else if (args[i] === '--host') host = args[++i];
634
660
  else if (/^\d+$/.test(args[i])) port = Number(args[i]);
635
661
  else folder = args[i];
@@ -655,6 +681,16 @@ async function devServer(args, { production = false } = {}) {
655
681
  const backendDir = backendFolder || (lay.split ? lay.backend : envDir);
656
682
  const apiRoutesFile = require('./backend/typescript.js').findFile(path.join(backendDir, 'routes', 'api')); // api.js or api.ts
657
683
  const hasBackend = Boolean(apiRoutesFile);
684
+ // Not a Kreft project and nothing to show: do not start a server on whatever folder this happens to be (an empty
685
+ // one, or your home folder). --force serves it anyway.
686
+ if (!force && !dbUrl && !apiFile && !serverFolder && !hasBackend && !lay.split) {
687
+ const names = (() => { try { return fs.readdirSync(root); } catch (_) { return []; } })();
688
+ const isSite = names.includes('index.html') || names.some((n) => /\.kreft$/.test(n)) || fs.existsSync(path.join(root, 'demo', 'index.html')) || fs.existsSync(path.join(config.dir || root, 'kreftjs.config.json'));
689
+ if (!isSite) {
690
+ console.error(` ${path.resolve(root)} is not a Kreft project: there is no index.html, no frontend/ folder and no .kreft pages.\n\n Make one: kreft new my-app (then: cd my-app, kreft dev)\n Or go into your project: cd path/to/your-app\n To serve this folder anyway: kreft ${production ? 'serve' : 'dev'} ${folder === '.' ? '' : folder + ' '}--force`);
691
+ process.exit(1);
692
+ }
693
+ }
658
694
  // kreftjs serve: the backend must not sit inside the folder that is served, or its files could be downloaded
659
695
  if (production && hasBackend && (backendDir === root || backendDir.startsWith(root + path.sep))) {
660
696
  console.error(` The backend (${backendDir}) is inside the folder being served (${root}).\n Serve the built site instead: kreft build, then kreft serve dist`);
@@ -736,7 +772,7 @@ async function devServer(args, { production = false } = {}) {
736
772
  '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.kreft': 'text/plain', '.kreftjs': 'text/plain',
737
773
  '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png',
738
774
  '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
739
- '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain', '.md': 'text/markdown',
775
+ '.ico': 'image/x-icon', ...(({ '.ts': _ts, ...media }) => media)(require('./backend/stream.js').MEDIA_TYPES), '.woff2': 'font/woff2', '.woff': 'font/woff', '.txt': 'text/plain', '.md': 'text/markdown',
740
776
  };
741
777
  const isFile = (p) => { try { return fs.statSync(p).isFile(); } catch (_) { return false; } };
742
778
 
@@ -754,12 +790,15 @@ async function devServer(args, { production = false } = {}) {
754
790
  }
755
791
  }
756
792
  const type = types[path.extname(file).toLowerCase()] || 'application/octet-stream';
757
- res.writeHead(opts.status || 200, {
793
+ const fileHeaders = {
758
794
  'Content-Type': type + (type.startsWith('text/') || type.endsWith('json') ? '; charset=utf-8' : ''),
759
795
  'Cache-Control': isProd ? 'no-cache' : 'no-store', // dev: always pick up your latest changes
760
796
  'X-Content-Type-Options': 'nosniff',
761
797
  ...prodHeaders,
762
- });
798
+ };
799
+ // everything but pages is sent with byte ranges (video and audio seek, Safari plays)
800
+ if (type !== 'text/html') return require('./backend/stream.js').sendFile(res.req, res, file, { status: opts.status, type: fileHeaders['Content-Type'], headers: fileHeaders });
801
+ res.writeHead(opts.status || 200, fileHeaders);
763
802
  if (!production && type === 'text/html') {
764
803
  let html = fs.readFileSync(file, 'utf8');
765
804
  // PUBLIC_ values from .env, as Kreftjs.env (kreft build puts them in app.js)
@@ -794,12 +833,9 @@ async function devServer(args, { production = false } = {}) {
794
833
  return res.end(opts.seo ? require('./backend/seo-pages.js').inject(html, opts.seo) : html);
795
834
  }
796
835
  if (type === 'text/html' && opts.seo) return res.end(require('./backend/seo-pages.js').inject(fs.readFileSync(file, 'utf8'), opts.seo));
797
- const stream = fs.createReadStream(file);
798
- stream.on('error', () => {
799
- if (!res.headersSent) res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
800
- res.end('Not found');
801
- });
802
- stream.pipe(res);
836
+ const page = fs.createReadStream(file); // a page in production: headers are written, send it whole
837
+ page.on('error', () => res.end('Not found'));
838
+ page.pipe(res);
803
839
  }
804
840
 
805
841
  // ----- hot reload: watch the folder, tell open pages what changed -----
@@ -1035,6 +1071,8 @@ async function devServer(args, { production = false } = {}) {
1035
1071
 
1036
1072
  const server = http.createServer(async (req, res) => {
1037
1073
  try {
1074
+ // SHIELD=true in .env: rate limits, bans and load shedding per visitor address (see backend/shield.js)
1075
+ if (require('./backend/shield.js').Shield.guard(req, res)) return;
1038
1076
  if (loopbackBind && !isLocalName(hostOnly(req.headers.host)) && !allowedHosts.has(hostOnly(req.headers.host))) {
1039
1077
  res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
1040
1078
  return res.end(`Blocked: this server only answers localhost. To reach it by the name "${hostOnly(req.headers.host)}", start it with KREFT_ALLOWED_HOSTS=${hostOnly(req.headers.host)} (or --host 0.0.0.0 to share it on your network).`);
@@ -1109,8 +1147,7 @@ async function devServer(args, { production = false } = {}) {
1109
1147
  const realPub = real(pub), realF = real(f);
1110
1148
  if ((f.startsWith(pub + path.sep)) && !secret(urlPath) && isFile(f) && realPub && realF && realF.startsWith(realPub + path.sep)) {
1111
1149
  const type = types[path.extname(f).toLowerCase()] || 'application/octet-stream';
1112
- res.writeHead(200, { 'Content-Type': type, 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': "default-src 'none'; img-src 'self'; style-src 'unsafe-inline'; sandbox", 'Cache-Control': 'public, max-age=3600' });
1113
- return fs.createReadStream(f).pipe(res);
1150
+ return require('./backend/stream.js').sendFile(req, res, f, { type, headers: { 'Content-Security-Policy': "default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'; sandbox", 'Cache-Control': /\.(m3u8|mpd)$/i.test(f) ? 'no-cache' : 'public, max-age=3600' } }); // a live playlist changes every few seconds
1114
1151
  }
1115
1152
  res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
1116
1153
  return res.end('Not found: ' + urlPath);
@@ -1153,6 +1190,7 @@ async function devServer(args, { production = false } = {}) {
1153
1190
 
1154
1191
  // if the port is taken (another server is still running), try the next one
1155
1192
  let tries = 0;
1193
+ require('./backend/shield.js').Shield.watch(server); // SHIELD=true: caps open connections per address
1156
1194
  server.on('error', (err) => {
1157
1195
  if (err.code === 'EADDRINUSE' && tries < 20) {
1158
1196
  console.log(` Port ${port + tries} is already in use by another program, trying ${port + tries + 1}…`);