tinywebui 0.1.3 → 0.1.5

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 (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -63
  3. package/package.json +25 -8
  4. package/public/app.js +40 -14
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -49
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +83 -20
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -138
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -451
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +98 -66
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -100
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +428 -392
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. package/src/set-password.js +0 -43
@@ -0,0 +1 @@
1
+ export {};
package/src/http.js CHANGED
@@ -1,61 +1,64 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, join, normalize } from 'node:path';
4
-
5
4
  const PUBLIC_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
6
5
  const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml' };
7
-
8
6
  export function json(res, code, payload) {
9
- const body = JSON.stringify(payload);
10
- res.writeHead(code, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
11
- res.end(body);
7
+ const body = JSON.stringify(payload);
8
+ res.writeHead(code, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
9
+ res.end(body);
12
10
  }
13
-
14
11
  /** An error that carries its own HTTP status to the handler's catch. */
15
12
  export class HttpError extends Error {
16
- constructor(status, message) { super(message); this.status = status; }
13
+ status;
14
+ constructor(status, message) { super(message); this.status = status; }
17
15
  }
18
-
19
16
  // Body caps. Most requests are a few KB; uploads and turns with images carry
20
17
  // base64 (4/3 of the file), so they get room for their own per-file limits.
21
18
  export const BODY_LIMIT = 1024 * 1024;
22
- export const UPLOAD_LIMIT = 8 * 1024 * 1024; // one 5 MB file, base64
23
- export const TURN_LIMIT = 60 * 1024 * 1024; // up to 8 images of 5 MB, base64
24
-
25
- /** Reads a JSON body, refusing more than `limit` bytes (413) or bad JSON (400). */
19
+ export const UPLOAD_LIMIT = 8 * 1024 * 1024; // one 5 MB file, base64
20
+ export const TURN_LIMIT = 60 * 1024 * 1024; // up to 8 images of 5 MB, base64
21
+ /** True when a body field is one of the allowed values, which also tells TypeScript which. */
22
+ export const oneOf = (value, allowed) => typeof value === 'string' && allowed.includes(value);
23
+ /** Reads a JSON object body, refusing more than `limit` bytes (413), bad JSON or anything but an object (400). */
26
24
  export async function readJson(req, limit = BODY_LIMIT) {
27
- const declared = Number(req.headers['content-length']);
28
- if (declared > limit) throw new HttpError(413, `request body exceeds ${limit} bytes`);
29
- const chunks = [];
30
- let size = 0;
31
- for await (const c of req) {
32
- size += c.length;
33
- if (size > limit) throw new HttpError(413, `request body exceeds ${limit} bytes`);
34
- chunks.push(c);
35
- }
36
- try {
37
- return JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
38
- } catch {
39
- throw new HttpError(400, 'request body is not valid JSON');
40
- }
25
+ const declared = Number(req.headers['content-length']);
26
+ if (declared > limit)
27
+ throw new HttpError(413, `request body exceeds ${limit} bytes`);
28
+ const chunks = [];
29
+ let size = 0;
30
+ for await (const c of req) {
31
+ size += c.length;
32
+ if (size > limit)
33
+ throw new HttpError(413, `request body exceeds ${limit} bytes`);
34
+ chunks.push(c);
35
+ }
36
+ let body;
37
+ try {
38
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
39
+ }
40
+ catch {
41
+ throw new HttpError(400, 'request body is not valid JSON');
42
+ }
43
+ if (!body || typeof body !== 'object' || Array.isArray(body))
44
+ throw new HttpError(400, 'request body must be a JSON object');
45
+ return body;
41
46
  }
42
-
43
47
  // Sent on every response. The page loads only its own scripts; the one
44
48
  // outside origin is the Fall Fairy theme's Google Fonts.
45
49
  export const SECURITY_HEADERS = {
46
- 'content-security-policy': [
47
- "default-src 'self'", "script-src 'self'", "connect-src 'self'",
48
- "style-src 'self' https://fonts.googleapis.com",
49
- "font-src 'self' data: https://fonts.gstatic.com",
50
- "img-src 'self' data: blob:", "object-src 'none'", "base-uri 'none'",
51
- "frame-ancestors 'none'", "form-action 'self'"
52
- ].join('; '),
53
- 'x-content-type-options': 'nosniff',
54
- 'x-frame-options': 'DENY',
55
- 'referrer-policy': 'no-referrer',
56
- 'cross-origin-opener-policy': 'same-origin'
50
+ 'content-security-policy': [
51
+ "default-src 'self'", "script-src 'self'", "connect-src 'self'",
52
+ "style-src 'self' https://fonts.googleapis.com",
53
+ "font-src 'self' data: https://fonts.gstatic.com",
54
+ "img-src 'self' data: blob:", "object-src 'none'", "base-uri 'none'",
55
+ "frame-ancestors 'none'", "form-action 'self'"
56
+ ].join('; '),
57
+ 'x-content-type-options': 'nosniff',
58
+ 'x-frame-options': 'DENY',
59
+ 'referrer-policy': 'no-referrer',
60
+ 'cross-origin-opener-policy': 'same-origin'
57
61
  };
58
-
59
62
  /**
60
63
  * Cross-site request forgery guard for anything that changes state. Browsers
61
64
  * send Origin on cross-origin POSTs (and Sec-Fetch-Site on all modern ones),
@@ -63,33 +66,62 @@ export const SECURITY_HEADERS = {
63
66
  * gateway's included -- or poke a tier-1 instance on localhost.
64
67
  */
65
68
  export function crossSite(req, cfg) {
66
- if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return false;
67
- const origin = req.headers.origin;
68
- if (origin && origin !== 'null') {
69
- let host;
70
- try { host = new URL(origin).host; } catch { return true; }
71
- if (host === req.headers.host) return false;
72
- return !(cfg.allowedOrigins || []).includes(origin);
73
- }
74
- if (origin === 'null') return true;
75
- return req.headers['sec-fetch-site'] === 'cross-site';
69
+ if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS')
70
+ return false;
71
+ const origin = req.headers.origin;
72
+ if (origin && origin !== 'null') {
73
+ let host;
74
+ try {
75
+ host = new URL(origin).host;
76
+ }
77
+ catch {
78
+ return true;
79
+ }
80
+ if (host === req.headers.host)
81
+ return false;
82
+ return !(cfg.allowedOrigins || []).includes(origin);
83
+ }
84
+ if (origin === 'null')
85
+ return true;
86
+ return req.headers['sec-fetch-site'] === 'cross-site';
87
+ }
88
+ /**
89
+ * DNS-rebinding guard. With no login, a hostile site can point its own name at
90
+ * 127.0.0.1 and pass the same-origin check above (Origin and Host both name it),
91
+ * so a no-login install only answers to loopback names and allowedHosts.
92
+ * Logged-in modes are safe: the attacker's origin has no session cookie.
93
+ */
94
+ export function badHost(req, cfg) {
95
+ if (cfg.authMode !== 'local')
96
+ return false;
97
+ let hostname;
98
+ try {
99
+ hostname = new URL(`http://${req.headers.host}`).hostname.toLowerCase();
100
+ }
101
+ catch {
102
+ return true;
103
+ }
104
+ if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '127.0.0.1' || hostname === '[::1]')
105
+ return false;
106
+ return !(cfg.allowedHosts || []).some((h) => h.toLowerCase() === hostname);
76
107
  }
77
-
78
108
  export async function serveStatic(req, res) {
79
- // Resolve the index BEFORE normalising: on Windows normalize('/') returns a
80
- // lone backslash, so a check for '/' after it never matches and the root
81
- // request lands on the directory itself.
82
- const pathname = req.url.split('?')[0];
83
- const rel = normalize(pathname === '/' ? 'index.html' : pathname)
84
- .replace(/^(\.\.[/\\])+/, '');
85
- const file = join(PUBLIC_DIR, rel);
86
- if (!file.startsWith(PUBLIC_DIR)) return json(res, 403, { error: 'forbidden' });
87
- try {
88
- const data = await readFile(file);
89
- const ext = file.slice(file.lastIndexOf('.'));
90
- res.writeHead(200, { 'content-type': TYPES[ext] || 'application/octet-stream' });
91
- res.end(data);
92
- } catch {
93
- json(res, 404, { error: 'not found' });
94
- }
109
+ // Resolve the index BEFORE normalising: on Windows normalize('/') returns a
110
+ // lone backslash, so a check for '/' after it never matches and the root
111
+ // request lands on the directory itself.
112
+ const pathname = (req.url ?? '/').split('?')[0];
113
+ const rel = normalize(pathname === '/' ? 'index.html' : pathname)
114
+ .replace(/^(\.\.[/\\])+/, '');
115
+ const file = join(PUBLIC_DIR, rel);
116
+ if (!file.startsWith(PUBLIC_DIR))
117
+ return json(res, 403, { error: 'forbidden' });
118
+ try {
119
+ const data = await readFile(file);
120
+ const ext = file.slice(file.lastIndexOf('.'));
121
+ res.writeHead(200, { 'content-type': TYPES[ext] || 'application/octet-stream' });
122
+ res.end(data);
123
+ }
124
+ catch {
125
+ json(res, 404, { error: 'not found' });
126
+ }
95
127
  }
package/src/log.js CHANGED
@@ -7,18 +7,17 @@
7
7
  * filtered by level.
8
8
  */
9
9
  const LEVELS = { debug: 10, info: 20, warn: 30, error: 40, silent: Infinity };
10
-
11
10
  const threshold = () => LEVELS[String(process.env.TINYWEBUI_LOG_LEVEL || '').toLowerCase()] ?? LEVELS.info;
12
-
13
11
  export function logger(area) {
14
- const line = (level, write) => (message) => {
15
- if (LEVELS[level] < threshold()) return;
16
- write(`[tinywebui:${area}] ${level === 'info' ? '' : `${level}: `}${message}`);
17
- };
18
- return {
19
- debug: line('debug', console.log),
20
- info: line('info', console.log),
21
- warn: line('warn', console.error),
22
- error: line('error', console.error)
23
- };
12
+ const line = (level, write) => (message) => {
13
+ if (LEVELS[level] < threshold())
14
+ return;
15
+ write(`[tinywebui:${area}] ${level === 'info' ? '' : `${level}: `}${message}`);
16
+ };
17
+ return {
18
+ debug: line('debug', console.log),
19
+ info: line('info', console.log),
20
+ warn: line('warn', console.error),
21
+ error: line('error', console.error)
22
+ };
24
23
  }