tina4-nodejs 3.11.32 → 3.12.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/package.json +1 -1
- package/packages/cli/src/bin.ts +1 -1
- package/packages/core/public/js/frond.js +600 -0
- package/packages/core/public/js/frond.min.js +1 -1
- package/packages/core/src/auth.ts +8 -8
- package/packages/core/src/devAdmin.ts +8 -8
- package/packages/core/src/devMailbox.ts +3 -3
- package/packages/core/src/index.ts +1 -1
- package/packages/core/src/mcp.test.ts +7 -7
- package/packages/core/src/mcp.ts +1 -1
- package/packages/core/src/messenger.ts +6 -11
- package/packages/core/src/server.ts +193 -19
- package/packages/core/src/sessionHandlers/databaseHandler.ts +2 -2
- package/packages/frond/src/engine.ts +1 -1
- package/packages/orm/src/baseModel.ts +4 -4
- package/packages/orm/src/database.ts +6 -6
- package/packages/swagger/src/generator.ts +3 -3
package/package.json
CHANGED
package/packages/cli/src/bin.ts
CHANGED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
var _frondModule = (() => {
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/js/frond.ts
|
|
21
|
+
var frond_exports = {};
|
|
22
|
+
__export(frond_exports, {
|
|
23
|
+
frond: () => frond
|
|
24
|
+
});
|
|
25
|
+
var _token = null;
|
|
26
|
+
function request(url, options) {
|
|
27
|
+
let opts;
|
|
28
|
+
if (typeof options === "function") {
|
|
29
|
+
opts = { onSuccess: options };
|
|
30
|
+
} else {
|
|
31
|
+
opts = options || {};
|
|
32
|
+
}
|
|
33
|
+
const method = (opts.method || "GET").toUpperCase();
|
|
34
|
+
const xhr = new XMLHttpRequest();
|
|
35
|
+
xhr.open(method, url, true);
|
|
36
|
+
if (_token !== null) {
|
|
37
|
+
xhr.setRequestHeader("Authorization", "Bearer " + _token);
|
|
38
|
+
}
|
|
39
|
+
if (opts.headers) {
|
|
40
|
+
for (const key in opts.headers) {
|
|
41
|
+
if (Object.prototype.hasOwnProperty.call(opts.headers, key)) {
|
|
42
|
+
xhr.setRequestHeader(key, opts.headers[key]);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
let body = null;
|
|
47
|
+
if (opts.body !== void 0 && opts.body !== null) {
|
|
48
|
+
if (opts.body instanceof FormData) {
|
|
49
|
+
body = opts.body;
|
|
50
|
+
} else if (typeof opts.body === "object") {
|
|
51
|
+
body = JSON.stringify(opts.body);
|
|
52
|
+
xhr.setRequestHeader("Content-Type", "application/json; charset=UTF-8");
|
|
53
|
+
} else if (typeof opts.body === "string") {
|
|
54
|
+
body = opts.body;
|
|
55
|
+
xhr.setRequestHeader("Content-Type", "text/plain; charset=UTF-8");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
xhr.onload = function() {
|
|
59
|
+
const freshToken = xhr.getResponseHeader("FreshToken");
|
|
60
|
+
if (freshToken && freshToken !== "") {
|
|
61
|
+
_token = freshToken;
|
|
62
|
+
}
|
|
63
|
+
let content = xhr.response;
|
|
64
|
+
try {
|
|
65
|
+
content = JSON.parse(content);
|
|
66
|
+
} catch {
|
|
67
|
+
}
|
|
68
|
+
if (xhr.responseURL) {
|
|
69
|
+
const requested = new URL(url, window.location.href).href;
|
|
70
|
+
if (xhr.responseURL !== requested) {
|
|
71
|
+
window.location.href = xhr.responseURL;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (xhr.status >= 200 && xhr.status < 400) {
|
|
76
|
+
if (opts.onSuccess) opts.onSuccess(content, xhr.status, xhr);
|
|
77
|
+
} else {
|
|
78
|
+
if (opts.onError) opts.onError(xhr.status, xhr);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
xhr.onerror = function() {
|
|
82
|
+
if (opts.onError) opts.onError(xhr.status, xhr);
|
|
83
|
+
};
|
|
84
|
+
xhr.send(body);
|
|
85
|
+
}
|
|
86
|
+
function inject(html, target) {
|
|
87
|
+
if (!html) return "";
|
|
88
|
+
const parser = new DOMParser();
|
|
89
|
+
const wrapped = html.includes("<html>") ? html : "<body>" + html + "</body></html>";
|
|
90
|
+
const doc = parser.parseFromString(wrapped, "text/html");
|
|
91
|
+
const body = doc.querySelector("body");
|
|
92
|
+
const scripts = body.querySelectorAll("script");
|
|
93
|
+
scripts.forEach(function(s) {
|
|
94
|
+
s.remove();
|
|
95
|
+
});
|
|
96
|
+
if (target !== null) {
|
|
97
|
+
const el = document.getElementById(target);
|
|
98
|
+
if (!el) return "";
|
|
99
|
+
if (body.children.length > 0) {
|
|
100
|
+
el.replaceChildren.apply(el, Array.from(body.children));
|
|
101
|
+
} else {
|
|
102
|
+
el.innerHTML = body.innerHTML;
|
|
103
|
+
}
|
|
104
|
+
scripts.forEach(function(script) {
|
|
105
|
+
const ns = document.createElement("script");
|
|
106
|
+
ns.type = "text/javascript";
|
|
107
|
+
ns.async = true;
|
|
108
|
+
if (script.src) {
|
|
109
|
+
ns.src = script.src;
|
|
110
|
+
} else {
|
|
111
|
+
ns.textContent = script.textContent;
|
|
112
|
+
}
|
|
113
|
+
el.appendChild(ns);
|
|
114
|
+
});
|
|
115
|
+
return "";
|
|
116
|
+
}
|
|
117
|
+
scripts.forEach(function(script) {
|
|
118
|
+
const ns = document.createElement("script");
|
|
119
|
+
ns.type = "text/javascript";
|
|
120
|
+
ns.async = true;
|
|
121
|
+
ns.textContent = script.textContent;
|
|
122
|
+
document.body.appendChild(ns);
|
|
123
|
+
});
|
|
124
|
+
return body.innerHTML;
|
|
125
|
+
}
|
|
126
|
+
function load(url, target, callback) {
|
|
127
|
+
const targetId = target || "content";
|
|
128
|
+
request(url, {
|
|
129
|
+
method: "GET",
|
|
130
|
+
onSuccess: function(data, _status) {
|
|
131
|
+
if (document.getElementById(targetId)) {
|
|
132
|
+
const html = inject(data, targetId);
|
|
133
|
+
if (callback) callback(html, data);
|
|
134
|
+
} else {
|
|
135
|
+
if (callback) callback(data);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
function post(url, data, target, callback) {
|
|
141
|
+
const targetId = target || "content";
|
|
142
|
+
request(url, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
body: data,
|
|
145
|
+
onSuccess: function(responseData) {
|
|
146
|
+
let html = "";
|
|
147
|
+
if (responseData && responseData.message !== void 0) {
|
|
148
|
+
html = inject(responseData.message, targetId);
|
|
149
|
+
} else if (document.getElementById(targetId)) {
|
|
150
|
+
html = inject(responseData, targetId);
|
|
151
|
+
} else {
|
|
152
|
+
if (callback) callback(responseData);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (callback) callback(html, responseData);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
var form = {
|
|
160
|
+
/**
|
|
161
|
+
* Collect all form field values into a FormData object.
|
|
162
|
+
*
|
|
163
|
+
* Handles inputs, selects, textareas, file uploads (including
|
|
164
|
+
* multi-file), checkboxes, and radio buttons. Updates formToken
|
|
165
|
+
* hidden fields automatically.
|
|
166
|
+
*
|
|
167
|
+
* @param formId - DOM id of the form (without '#').
|
|
168
|
+
* @returns Populated FormData instance.
|
|
169
|
+
*/
|
|
170
|
+
collect: function(formId) {
|
|
171
|
+
const fd = new FormData();
|
|
172
|
+
const elements = document.querySelectorAll("#" + formId + " select, #" + formId + " input, #" + formId + " textarea");
|
|
173
|
+
for (let i = 0; i < elements.length; i++) {
|
|
174
|
+
const el = elements[i];
|
|
175
|
+
if (el.name === "formToken" && _token !== null) {
|
|
176
|
+
el.value = _token;
|
|
177
|
+
}
|
|
178
|
+
if (!el.name) continue;
|
|
179
|
+
if (el.type === "file") {
|
|
180
|
+
const files = el.files;
|
|
181
|
+
if (files) {
|
|
182
|
+
for (let f = 0; f < files.length; f++) {
|
|
183
|
+
const file = files[f];
|
|
184
|
+
if (file !== void 0) {
|
|
185
|
+
let name = el.name;
|
|
186
|
+
if (files.length > 1 && !name.includes("[")) {
|
|
187
|
+
name = name + "[]";
|
|
188
|
+
}
|
|
189
|
+
fd.append(name, file, file.name);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} else if (el.type === "checkbox" || el.type === "radio") {
|
|
194
|
+
if (el.checked) {
|
|
195
|
+
fd.append(el.name, el.value);
|
|
196
|
+
} else if (el.type !== "radio") {
|
|
197
|
+
fd.append(el.name, "0");
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
fd.append(el.name, el.value === "" ? "" : el.value);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return fd;
|
|
204
|
+
},
|
|
205
|
+
/**
|
|
206
|
+
* Collect form data and POST it to a URL. Inject response into target.
|
|
207
|
+
*
|
|
208
|
+
* @param formId - DOM id of the form.
|
|
209
|
+
* @param url - URL to POST to.
|
|
210
|
+
* @param target - DOM id to inject response into (default: "message").
|
|
211
|
+
* @param callback - Optional callback.
|
|
212
|
+
*/
|
|
213
|
+
submit: function(formId, url, target, callback) {
|
|
214
|
+
const data = form.collect(formId);
|
|
215
|
+
post(url, data, target || "message", callback);
|
|
216
|
+
},
|
|
217
|
+
/**
|
|
218
|
+
* Load a form via the given action and inject response HTML.
|
|
219
|
+
*
|
|
220
|
+
* Accepts friendly names: "create", "edit" map to GET; "delete" maps
|
|
221
|
+
* to DELETE.
|
|
222
|
+
*
|
|
223
|
+
* @param action - HTTP method or friendly name.
|
|
224
|
+
* @param url - URL to fetch.
|
|
225
|
+
* @param target - DOM id to inject into (default: "form").
|
|
226
|
+
* @param callback - Optional callback.
|
|
227
|
+
*/
|
|
228
|
+
show: function(action, url, target, callback) {
|
|
229
|
+
let method = action.toUpperCase();
|
|
230
|
+
if (action === "create" || action === "edit") method = "GET";
|
|
231
|
+
if (action === "delete") method = "DELETE";
|
|
232
|
+
const targetId = target || "form";
|
|
233
|
+
request(url, {
|
|
234
|
+
method,
|
|
235
|
+
onSuccess: function(data) {
|
|
236
|
+
let html = "";
|
|
237
|
+
if (data && data.message !== void 0) {
|
|
238
|
+
html = inject(data.message, targetId);
|
|
239
|
+
} else if (document.getElementById(targetId)) {
|
|
240
|
+
html = inject(data, targetId);
|
|
241
|
+
} else {
|
|
242
|
+
if (callback) callback(data);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (callback) callback(html);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
function wsConnect(url, options) {
|
|
251
|
+
const opts = {
|
|
252
|
+
reconnect: true,
|
|
253
|
+
reconnectDelay: 1e3,
|
|
254
|
+
maxReconnectDelay: 3e4,
|
|
255
|
+
maxReconnectAttempts: Infinity,
|
|
256
|
+
protocols: [],
|
|
257
|
+
onOpen: function() {
|
|
258
|
+
},
|
|
259
|
+
onClose: function() {
|
|
260
|
+
},
|
|
261
|
+
onError: function() {
|
|
262
|
+
},
|
|
263
|
+
...options || {}
|
|
264
|
+
};
|
|
265
|
+
let socket = null;
|
|
266
|
+
let intentionalClose = false;
|
|
267
|
+
let currentDelay = opts.reconnectDelay;
|
|
268
|
+
let attempts = 0;
|
|
269
|
+
let reconnectTimer = null;
|
|
270
|
+
const listeners = {
|
|
271
|
+
message: [],
|
|
272
|
+
open: [],
|
|
273
|
+
close: [],
|
|
274
|
+
error: []
|
|
275
|
+
};
|
|
276
|
+
const managed = {
|
|
277
|
+
status: "connecting",
|
|
278
|
+
send: function(data) {
|
|
279
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
280
|
+
throw new Error("[frond] WebSocket is not connected");
|
|
281
|
+
}
|
|
282
|
+
socket.send(typeof data === "string" ? data : JSON.stringify(data));
|
|
283
|
+
},
|
|
284
|
+
on: function(event, handler) {
|
|
285
|
+
if (!listeners[event]) listeners[event] = [];
|
|
286
|
+
listeners[event].push(handler);
|
|
287
|
+
return function() {
|
|
288
|
+
const arr = listeners[event];
|
|
289
|
+
const idx = arr.indexOf(handler);
|
|
290
|
+
if (idx >= 0) arr.splice(idx, 1);
|
|
291
|
+
};
|
|
292
|
+
},
|
|
293
|
+
close: function(code, reason) {
|
|
294
|
+
intentionalClose = true;
|
|
295
|
+
if (reconnectTimer) {
|
|
296
|
+
clearTimeout(reconnectTimer);
|
|
297
|
+
reconnectTimer = null;
|
|
298
|
+
}
|
|
299
|
+
if (socket) {
|
|
300
|
+
socket.close(code || 1e3, reason || "");
|
|
301
|
+
}
|
|
302
|
+
managed.status = "closed";
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
function parseMessage(data) {
|
|
306
|
+
if (typeof data !== "string") return data;
|
|
307
|
+
try {
|
|
308
|
+
return JSON.parse(data);
|
|
309
|
+
} catch {
|
|
310
|
+
return data;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function scheduleReconnect() {
|
|
314
|
+
if (!opts.reconnect || attempts >= opts.maxReconnectAttempts) return;
|
|
315
|
+
attempts++;
|
|
316
|
+
managed.status = "reconnecting";
|
|
317
|
+
reconnectTimer = setTimeout(function() {
|
|
318
|
+
reconnectTimer = null;
|
|
319
|
+
connect();
|
|
320
|
+
}, currentDelay);
|
|
321
|
+
currentDelay = Math.min(currentDelay * 2, opts.maxReconnectDelay);
|
|
322
|
+
}
|
|
323
|
+
function connect() {
|
|
324
|
+
managed.status = attempts > 0 ? "reconnecting" : "connecting";
|
|
325
|
+
try {
|
|
326
|
+
socket = new WebSocket(url, opts.protocols);
|
|
327
|
+
} catch {
|
|
328
|
+
managed.status = "closed";
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
socket.onopen = function() {
|
|
332
|
+
managed.status = "open";
|
|
333
|
+
attempts = 0;
|
|
334
|
+
currentDelay = opts.reconnectDelay;
|
|
335
|
+
opts.onOpen();
|
|
336
|
+
for (const fn of listeners.open) fn();
|
|
337
|
+
};
|
|
338
|
+
socket.onmessage = function(event) {
|
|
339
|
+
const parsed = parseMessage(event.data);
|
|
340
|
+
for (const fn of listeners.message) fn(parsed);
|
|
341
|
+
};
|
|
342
|
+
socket.onclose = function(event) {
|
|
343
|
+
managed.status = "closed";
|
|
344
|
+
opts.onClose(event.code, event.reason);
|
|
345
|
+
for (const fn of listeners.close) fn(event.code, event.reason);
|
|
346
|
+
if (!intentionalClose) {
|
|
347
|
+
scheduleReconnect();
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
socket.onerror = function(event) {
|
|
351
|
+
opts.onError(event);
|
|
352
|
+
for (const fn of listeners.error) fn(event);
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
connect();
|
|
356
|
+
return managed;
|
|
357
|
+
}
|
|
358
|
+
function sseConnect(url, options) {
|
|
359
|
+
const opts = {
|
|
360
|
+
reconnect: true,
|
|
361
|
+
reconnectDelay: 1e3,
|
|
362
|
+
maxReconnectDelay: 3e4,
|
|
363
|
+
maxReconnectAttempts: Infinity,
|
|
364
|
+
events: [],
|
|
365
|
+
json: true,
|
|
366
|
+
onOpen: function() {
|
|
367
|
+
},
|
|
368
|
+
onClose: function() {
|
|
369
|
+
},
|
|
370
|
+
onError: function() {
|
|
371
|
+
},
|
|
372
|
+
...options || {}
|
|
373
|
+
};
|
|
374
|
+
let source = null;
|
|
375
|
+
let intentionalClose = false;
|
|
376
|
+
let currentDelay = opts.reconnectDelay;
|
|
377
|
+
let attempts = 0;
|
|
378
|
+
let reconnectTimer = null;
|
|
379
|
+
const listeners = {
|
|
380
|
+
message: [],
|
|
381
|
+
open: [],
|
|
382
|
+
close: [],
|
|
383
|
+
error: []
|
|
384
|
+
};
|
|
385
|
+
const managed = {
|
|
386
|
+
status: "connecting",
|
|
387
|
+
on: function(event, handler) {
|
|
388
|
+
if (!listeners[event]) listeners[event] = [];
|
|
389
|
+
listeners[event].push(handler);
|
|
390
|
+
return function() {
|
|
391
|
+
const arr = listeners[event];
|
|
392
|
+
const idx = arr.indexOf(handler);
|
|
393
|
+
if (idx >= 0) arr.splice(idx, 1);
|
|
394
|
+
};
|
|
395
|
+
},
|
|
396
|
+
close: function() {
|
|
397
|
+
intentionalClose = true;
|
|
398
|
+
if (reconnectTimer) {
|
|
399
|
+
clearTimeout(reconnectTimer);
|
|
400
|
+
reconnectTimer = null;
|
|
401
|
+
}
|
|
402
|
+
if (source) {
|
|
403
|
+
source.close();
|
|
404
|
+
source = null;
|
|
405
|
+
}
|
|
406
|
+
managed.status = "closed";
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
function parseData(raw) {
|
|
410
|
+
if (!opts.json) return raw;
|
|
411
|
+
try {
|
|
412
|
+
return JSON.parse(raw);
|
|
413
|
+
} catch {
|
|
414
|
+
return raw;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function dispatch(data, eventName) {
|
|
418
|
+
for (const fn of listeners.message) fn(data, eventName || void 0);
|
|
419
|
+
}
|
|
420
|
+
function scheduleReconnect() {
|
|
421
|
+
if (!opts.reconnect || attempts >= opts.maxReconnectAttempts) return;
|
|
422
|
+
attempts++;
|
|
423
|
+
managed.status = "reconnecting";
|
|
424
|
+
reconnectTimer = setTimeout(function() {
|
|
425
|
+
reconnectTimer = null;
|
|
426
|
+
connect();
|
|
427
|
+
}, currentDelay);
|
|
428
|
+
currentDelay = Math.min(currentDelay * 2, opts.maxReconnectDelay);
|
|
429
|
+
}
|
|
430
|
+
function connect() {
|
|
431
|
+
managed.status = attempts > 0 ? "reconnecting" : "connecting";
|
|
432
|
+
try {
|
|
433
|
+
source = new EventSource(url);
|
|
434
|
+
} catch {
|
|
435
|
+
managed.status = "closed";
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
source.onopen = function() {
|
|
439
|
+
managed.status = "open";
|
|
440
|
+
attempts = 0;
|
|
441
|
+
currentDelay = opts.reconnectDelay;
|
|
442
|
+
opts.onOpen();
|
|
443
|
+
for (const fn of listeners.open) fn(null);
|
|
444
|
+
};
|
|
445
|
+
source.onmessage = function(event) {
|
|
446
|
+
dispatch(parseData(event.data), null);
|
|
447
|
+
};
|
|
448
|
+
for (const name of opts.events) {
|
|
449
|
+
source.addEventListener(name, function(e) {
|
|
450
|
+
dispatch(parseData(e.data), name);
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
source.onerror = function(event) {
|
|
454
|
+
opts.onError(event);
|
|
455
|
+
for (const fn of listeners.error) fn(event);
|
|
456
|
+
if (source && source.readyState === 2) {
|
|
457
|
+
source = null;
|
|
458
|
+
managed.status = "closed";
|
|
459
|
+
opts.onClose();
|
|
460
|
+
for (const fn of listeners.close) fn(null);
|
|
461
|
+
if (!intentionalClose) {
|
|
462
|
+
scheduleReconnect();
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
connect();
|
|
468
|
+
return managed;
|
|
469
|
+
}
|
|
470
|
+
var cookie = {
|
|
471
|
+
/**
|
|
472
|
+
* Set a browser cookie.
|
|
473
|
+
*
|
|
474
|
+
* @param name - Cookie name.
|
|
475
|
+
* @param value - Cookie value.
|
|
476
|
+
* @param days - Optional lifetime in days.
|
|
477
|
+
*/
|
|
478
|
+
set: function(name, value, days) {
|
|
479
|
+
let expires = "";
|
|
480
|
+
if (days) {
|
|
481
|
+
const d = /* @__PURE__ */ new Date();
|
|
482
|
+
d.setTime(d.getTime() + days * 24 * 60 * 60 * 1e3);
|
|
483
|
+
expires = "; expires=" + d.toUTCString();
|
|
484
|
+
}
|
|
485
|
+
document.cookie = name + "=" + (value || "") + expires + "; path=/";
|
|
486
|
+
},
|
|
487
|
+
/**
|
|
488
|
+
* Retrieve a cookie value by name.
|
|
489
|
+
*
|
|
490
|
+
* @param name - Cookie name.
|
|
491
|
+
* @returns Cookie value, or null if not found.
|
|
492
|
+
*/
|
|
493
|
+
get: function(name) {
|
|
494
|
+
const nameEQ = name + "=";
|
|
495
|
+
const parts = document.cookie.split(";");
|
|
496
|
+
for (let i = 0; i < parts.length; i++) {
|
|
497
|
+
let c = parts[i];
|
|
498
|
+
while (c.charAt(0) === " ") c = c.substring(1);
|
|
499
|
+
if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length);
|
|
500
|
+
}
|
|
501
|
+
return null;
|
|
502
|
+
},
|
|
503
|
+
/**
|
|
504
|
+
* Delete a cookie by name.
|
|
505
|
+
*
|
|
506
|
+
* @param name - Cookie name.
|
|
507
|
+
*/
|
|
508
|
+
remove: function(name) {
|
|
509
|
+
document.cookie = name + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/";
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
function message(text, type) {
|
|
513
|
+
const el = document.getElementById("message");
|
|
514
|
+
if (!el) return;
|
|
515
|
+
const alertType = type || "info";
|
|
516
|
+
el.innerHTML = '<div class="alert alert-' + alertType + ' alert-dismissible">' + text + '<button type="button" class="btn-close" data-t4-dismiss="alert">×</button></div>';
|
|
517
|
+
}
|
|
518
|
+
function popup(url, title, w, h) {
|
|
519
|
+
const dualLeft = window.screenLeft !== void 0 ? window.screenLeft : window.screenX;
|
|
520
|
+
const dualTop = window.screenTop !== void 0 ? window.screenTop : window.screenY;
|
|
521
|
+
const width = window.innerWidth || document.documentElement.clientWidth || screen.width;
|
|
522
|
+
const height = window.innerHeight || document.documentElement.clientHeight || screen.height;
|
|
523
|
+
const zoom = width / window.screen.availWidth;
|
|
524
|
+
const left = (width - w) / 2 / zoom + dualLeft;
|
|
525
|
+
const top = (height - h) / 2 / zoom + dualTop;
|
|
526
|
+
const win = window.open(
|
|
527
|
+
url,
|
|
528
|
+
title,
|
|
529
|
+
"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width=" + w / zoom + ",height=" + h / zoom + ",top=" + top + ",left=" + left
|
|
530
|
+
);
|
|
531
|
+
if (window.focus && win) win.focus();
|
|
532
|
+
return win;
|
|
533
|
+
}
|
|
534
|
+
function report(url) {
|
|
535
|
+
if (url.indexOf("No data available") >= 0) {
|
|
536
|
+
window.alert("No data available for this report.");
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
window.open(
|
|
540
|
+
url,
|
|
541
|
+
"_blank",
|
|
542
|
+
"toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0"
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
function graphql(url, query, variables, callback) {
|
|
546
|
+
request(url, {
|
|
547
|
+
method: "POST",
|
|
548
|
+
body: { query, variables: variables || {} },
|
|
549
|
+
onSuccess: function(response) {
|
|
550
|
+
if (callback) {
|
|
551
|
+
callback(response.data || null, response.errors || void 0);
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
onError: function(status) {
|
|
555
|
+
if (callback) {
|
|
556
|
+
callback(null, [{ message: "GraphQL request failed with status " + status }]);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
var frond = {
|
|
562
|
+
/** Core HTTP request. */
|
|
563
|
+
request,
|
|
564
|
+
/** GET + inject HTML into target element. */
|
|
565
|
+
load,
|
|
566
|
+
/** POST + inject HTML into target element. */
|
|
567
|
+
post,
|
|
568
|
+
/** Parse HTML string, inject into element, execute scripts. */
|
|
569
|
+
inject,
|
|
570
|
+
/** Form helpers: collect, submit, show. */
|
|
571
|
+
form,
|
|
572
|
+
/** WebSocket with auto-reconnect. */
|
|
573
|
+
ws: wsConnect,
|
|
574
|
+
/** Server-Sent Events with auto-reconnect. */
|
|
575
|
+
sse: sseConnect,
|
|
576
|
+
/** Cookie helpers: get, set, remove. */
|
|
577
|
+
cookie,
|
|
578
|
+
/** Display alert message in #message element. */
|
|
579
|
+
message,
|
|
580
|
+
/** Open centred popup window. */
|
|
581
|
+
popup,
|
|
582
|
+
/** Open PDF report in new window. */
|
|
583
|
+
report,
|
|
584
|
+
/** Execute a GraphQL query/mutation. */
|
|
585
|
+
graphql,
|
|
586
|
+
/** Current bearer token (read/write). */
|
|
587
|
+
get token() {
|
|
588
|
+
return _token;
|
|
589
|
+
},
|
|
590
|
+
set token(value) {
|
|
591
|
+
_token = value;
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
if (typeof window !== "undefined") {
|
|
595
|
+
window.frond = frond;
|
|
596
|
+
}
|
|
597
|
+
return __toCommonJS(frond_exports);
|
|
598
|
+
})();
|
|
599
|
+
/* Frond v2.1.3 — tina4.com */
|
|
600
|
+
//# sourceMappingURL=frond.js.map
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
var _frondModule=(()=>{var b=Object.defineProperty;var k=Object.getOwnPropertyDescriptor;var x=Object.getOwnPropertyNames;var C=Object.prototype.hasOwnProperty;var O=(o,s)=>{for(var e in s)b(o,e,{get:s[e],enumerable:!0})},M=(o,s,e,t)=>{if(s&&typeof s=="object"||typeof s=="function")for(let n of x(s))!C.call(o,n)&&n!==e&&b(o,n,{get:()=>s[n],enumerable:!(t=k(s,n))||t.enumerable});return o};var q=o=>M(b({},"__esModule",{value:!0}),o);var j={};O(j,{frond:()=>R});var g=null;function w(o,s){let e;typeof s=="function"?e={onSuccess:s}:e=s||{};let t=(e.method||"GET").toUpperCase(),n=new XMLHttpRequest;if(n.open(t,o,!0),g!==null&&n.setRequestHeader("Authorization","Bearer "+g),e.headers)for(let r in e.headers)Object.prototype.hasOwnProperty.call(e.headers,r)&&n.setRequestHeader(r,e.headers[r]);let i=null;e.body!==void 0&&e.body!==null&&(e.body instanceof FormData?i=e.body:typeof e.body=="object"?(i=JSON.stringify(e.body),n.setRequestHeader("Content-Type","application/json; charset=UTF-8")):typeof e.body=="string"&&(i=e.body,n.setRequestHeader("Content-Type","text/plain; charset=UTF-8"))),n.onload=function(){let r=n.getResponseHeader("FreshToken");r&&r!==""&&(g=r);let u=n.response;try{u=JSON.parse(u)}catch{}if(n.responseURL){let c=new URL(o,window.location.href).href;if(n.responseURL!==c){window.location.href=n.responseURL;return}}n.status>=200&&n.status<400?e.onSuccess&&e.onSuccess(u,n.status,n):e.onError&&e.onError(n.status,n)},n.onerror=function(){e.onError&&e.onError(n.status,n)},n.send(i)}function h(o,s){if(!o)return"";let e=new DOMParser,t=o.includes("<html>")?o:"<body>"+o+"</body></html>",i=e.parseFromString(t,"text/html").querySelector("body"),r=i.querySelectorAll("script");if(r.forEach(function(u){u.remove()}),s!==null){let u=document.getElementById(s);return u&&(i.children.length>0?u.replaceChildren.apply(u,Array.from(i.children)):u.innerHTML=i.innerHTML,r.forEach(function(c){let d=document.createElement("script");d.type="text/javascript",d.async=!0,c.src?d.src=c.src:d.textContent=c.textContent,u.appendChild(d)})),""}return r.forEach(function(u){let c=document.createElement("script");c.type="text/javascript",c.async=!0,c.textContent=u.textContent,document.body.appendChild(c)}),i.innerHTML}function H(o,s,e){let t=s||"content";w(o,{method:"GET",onSuccess:function(n,i){if(document.getElementById(t)){let r=h(n,t);e&&e(r,n)}else e&&e(n)}})}function S(o,s,e,t){let n=e||"content";w(o,{method:"POST",body:s,onSuccess:function(i){let r="";if(i&&i.message!==void 0)r=h(i.message,n);else if(document.getElementById(n))r=h(i,n);else{t&&t(i);return}t&&t(r,i)}})}var T={collect:function(o){let s=new FormData,e=document.querySelectorAll("#"+o+" select, #"+o+" input, #"+o+" textarea");for(let t=0;t<e.length;t++){let n=e[t];if(n.name==="formToken"&&g!==null&&(n.value=g),!!n.name)if(n.type==="file"){let i=n.files;if(i)for(let r=0;r<i.length;r++){let u=i[r];if(u!==void 0){let c=n.name;i.length>1&&!c.includes("[")&&(c=c+"[]"),s.append(c,u,u.name)}}}else n.type==="checkbox"||n.type==="radio"?n.checked?s.append(n.name,n.value):n.type!=="radio"&&s.append(n.name,"0"):s.append(n.name,n.value===""?"":n.value)}return s},submit:function(o,s,e,t){let n=T.collect(o);S(s,n,e||"message",t)},show:function(o,s,e,t){let n=o.toUpperCase();(o==="create"||o==="edit")&&(n="GET"),o==="delete"&&(n="DELETE");let i=e||"form";w(s,{method:n,onSuccess:function(r){let u="";if(r&&r.message!==void 0)u=h(r.message,i);else if(document.getElementById(i))u=h(r,i);else{t&&t(r);return}t&&t(u)}})}};function L(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,protocols:[],onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",send:function(l){if(!t||t.readyState!==WebSocket.OPEN)throw new Error("[frond] WebSocket is not connected");t.send(typeof l=="string"?l:JSON.stringify(l))},on:function(l,a){return c[l]||(c[l]=[]),c[l].push(a),function(){let f=c[l],m=f.indexOf(a);m>=0&&f.splice(m,1)}},close:function(l,a){n=!0,u&&(clearTimeout(u),u=null),t&&t.close(l||1e3,a||""),d.status="closed"}};function y(l){if(typeof l!="string")return l;try{return JSON.parse(l)}catch{return l}}function p(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,v()},i),i=Math.min(i*2,e.maxReconnectDelay))}function v(){d.status=r>0?"reconnecting":"connecting";try{t=new WebSocket(o,e.protocols)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let l of c.open)l()},t.onmessage=function(l){let a=y(l.data);for(let f of c.message)f(a)},t.onclose=function(l){d.status="closed",e.onClose(l.code,l.reason);for(let a of c.close)a(l.code,l.reason);n||p()},t.onerror=function(l){e.onError(l);for(let a of c.error)a(l)}}return v(),d}function D(o,s){let e={reconnect:!0,reconnectDelay:1e3,maxReconnectDelay:3e4,maxReconnectAttempts:1/0,events:[],json:!0,onOpen:function(){},onClose:function(){},onError:function(){},...s||{}},t=null,n=!1,i=e.reconnectDelay,r=0,u=null,c={message:[],open:[],close:[],error:[]},d={status:"connecting",on:function(a,f){return c[a]||(c[a]=[]),c[a].push(f),function(){let m=c[a],E=m.indexOf(f);E>=0&&m.splice(E,1)}},close:function(){n=!0,u&&(clearTimeout(u),u=null),t&&(t.close(),t=null),d.status="closed"}};function y(a){if(!e.json)return a;try{return JSON.parse(a)}catch{return a}}function p(a,f){for(let m of c.message)m(a,f||void 0)}function v(){!e.reconnect||r>=e.maxReconnectAttempts||(r++,d.status="reconnecting",u=setTimeout(function(){u=null,l()},i),i=Math.min(i*2,e.maxReconnectDelay))}function l(){d.status=r>0?"reconnecting":"connecting";try{t=new EventSource(o)}catch{d.status="closed";return}t.onopen=function(){d.status="open",r=0,i=e.reconnectDelay,e.onOpen();for(let a of c.open)a(null)},t.onmessage=function(a){p(y(a.data),null)};for(let a of e.events)t.addEventListener(a,function(f){p(y(f.data),a)});t.onerror=function(a){e.onError(a);for(let f of c.error)f(a);if(t&&t.readyState===2){t=null,d.status="closed",e.onClose();for(let f of c.close)f(null);n||v()}}}return l(),d}var W={set:function(o,s,e){let t="";if(e){let n=new Date;n.setTime(n.getTime()+e*24*60*60*1e3),t="; expires="+n.toUTCString()}document.cookie=o+"="+(s||"")+t+"; path=/"},get:function(o){let s=o+"=",e=document.cookie.split(";");for(let t=0;t<e.length;t++){let n=e[t];for(;n.charAt(0)===" ";)n=n.substring(1);if(n.indexOf(s)===0)return n.substring(s.length)}return null},remove:function(o){document.cookie=o+"=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/"}};function A(o,s){let e=document.getElementById("message");if(!e)return;let t=s||"info";e.innerHTML='<div class="alert alert-'+t+' alert-dismissible">'+o+'<button type="button" class="btn-close" data-t4-dismiss="alert">×</button></div>'}function I(o,s,e,t){let n=window.screenLeft!==void 0?window.screenLeft:window.screenX,i=window.screenTop!==void 0?window.screenTop:window.screenY,r=window.innerWidth||document.documentElement.clientWidth||screen.width,u=window.innerHeight||document.documentElement.clientHeight||screen.height,c=r/window.screen.availWidth,d=(r-e)/2/c+n,y=(u-t)/2/c+i,p=window.open(o,s,"directories=no,toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes,width="+e/c+",height="+t/c+",top="+y+",left="+d);return window.focus&&p&&p.focus(),p}function N(o){if(o.indexOf("No data available")>=0){window.alert("No data available for this report.");return}window.open(o,"_blank","toolbar=no,scrollbars=yes,resizable=yes,width=800,height=600,top=0,left=0")}function U(o,s,e,t){w(o,{method:"POST",body:{query:s,variables:e||{}},onSuccess:function(n){t&&t(n.data||null,n.errors||void 0)},onError:function(n){t&&t(null,[{message:"GraphQL request failed with status "+n}])}})}var R={request:w,load:H,post:S,inject:h,form:T,ws:L,sse:D,cookie:W,message:A,popup:I,report:N,graphql:U,get token(){return g},set token(o){g=o}};typeof window<"u"&&(window.frond=R);return q(j);})();
|
|
2
|
-
/* Frond v2 — tina4.com */
|
|
2
|
+
/* Frond v2.1.3 — tina4.com */
|
|
@@ -32,7 +32,7 @@ function base64urlDecode(str: string): Buffer {
|
|
|
32
32
|
/**
|
|
33
33
|
* Create a signed JWT token.
|
|
34
34
|
*
|
|
35
|
-
* Secret is always read from `process.env.
|
|
35
|
+
* Secret is always read from `process.env.TINA4_SECRET`.
|
|
36
36
|
* Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
|
|
37
37
|
*
|
|
38
38
|
* @param payload - Claims to encode (e.g. `{ userId: 1, role: "admin" }`)
|
|
@@ -50,15 +50,15 @@ export function getToken(
|
|
|
50
50
|
let resolvedSecret: string;
|
|
51
51
|
let resolvedExpiresIn: number;
|
|
52
52
|
if (typeof secretOrExpiresIn === "number") {
|
|
53
|
-
resolvedSecret = process.env.
|
|
53
|
+
resolvedSecret = process.env.TINA4_SECRET ?? "";
|
|
54
54
|
resolvedExpiresIn = secretOrExpiresIn;
|
|
55
55
|
} else {
|
|
56
|
-
resolvedSecret = secretOrExpiresIn ?? process.env.
|
|
56
|
+
resolvedSecret = secretOrExpiresIn ?? process.env.TINA4_SECRET ?? "";
|
|
57
57
|
resolvedExpiresIn = expiresIn;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
if (!resolvedSecret) {
|
|
61
|
-
console.warn("Auth:
|
|
61
|
+
console.warn("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)");
|
|
62
62
|
}
|
|
63
63
|
const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
|
|
64
64
|
|
|
@@ -81,13 +81,13 @@ export function getToken(
|
|
|
81
81
|
/**
|
|
82
82
|
* Validate a JWT token and return the decoded payload, or false if invalid/expired.
|
|
83
83
|
*
|
|
84
|
-
* Secret is always read from `process.env.
|
|
84
|
+
* Secret is always read from `process.env.TINA4_SECRET`.
|
|
85
85
|
* Algorithm is read from `process.env.TINA4_JWT_ALGORITHM` (default "HS256").
|
|
86
86
|
*/
|
|
87
87
|
export function validToken(token: string, secret?: string, algorithm?: string): boolean {
|
|
88
|
-
const resolvedSecret = secret ?? process.env.
|
|
88
|
+
const resolvedSecret = secret ?? process.env.TINA4_SECRET ?? "";
|
|
89
89
|
if (!resolvedSecret) {
|
|
90
|
-
console.warn("Auth:
|
|
90
|
+
console.warn("Auth: TINA4_SECRET not set in .env — using blank secret (insecure)");
|
|
91
91
|
}
|
|
92
92
|
const resolvedAlgorithm = algorithm ?? process.env.TINA4_JWT_ALGORITHM ?? "HS256";
|
|
93
93
|
try {
|
|
@@ -240,7 +240,7 @@ export function authMiddleware(secret?: string, algorithm: string = "HS256"): Mi
|
|
|
240
240
|
* Refresh a JWT token — validate the existing token then re-sign
|
|
241
241
|
* with a fresh expiry.
|
|
242
242
|
*
|
|
243
|
-
* Secret is always read from `process.env.
|
|
243
|
+
* Secret is always read from `process.env.TINA4_SECRET`.
|
|
244
244
|
*
|
|
245
245
|
* @param token - Existing JWT to refresh
|
|
246
246
|
* @param expiresIn - New lifetime in seconds (default 3600)
|
|
@@ -1206,9 +1206,9 @@ function parseEnvFile(): Record<string, string> {
|
|
|
1206
1206
|
const handleConnections: RouteHandler = (_req, res) => {
|
|
1207
1207
|
const env = parseEnvFile();
|
|
1208
1208
|
res.json({
|
|
1209
|
-
url: env.
|
|
1210
|
-
username: env.
|
|
1211
|
-
password: env.
|
|
1209
|
+
url: env.TINA4_DATABASE_URL ?? "",
|
|
1210
|
+
username: env.TINA4_DATABASE_USERNAME ?? "",
|
|
1211
|
+
password: env.TINA4_DATABASE_PASSWORD ? "***" : "",
|
|
1212
1212
|
});
|
|
1213
1213
|
};
|
|
1214
1214
|
|
|
@@ -1274,7 +1274,7 @@ const handleConnectionsSave: RouteHandler = (req, res) => {
|
|
|
1274
1274
|
try {
|
|
1275
1275
|
const envPath = join(process.cwd(), ".env");
|
|
1276
1276
|
const lines = existsSync(envPath) ? readFileSync(envPath, "utf-8").split("\n") : [];
|
|
1277
|
-
const keysFound: Record<string, boolean> = {
|
|
1277
|
+
const keysFound: Record<string, boolean> = { TINA4_DATABASE_URL: false, TINA4_DATABASE_USERNAME: false, TINA4_DATABASE_PASSWORD: false };
|
|
1278
1278
|
const newLines: string[] = [];
|
|
1279
1279
|
for (const line of lines) {
|
|
1280
1280
|
const trimmed = line.trim();
|
|
@@ -1283,12 +1283,12 @@ const handleConnectionsSave: RouteHandler = (req, res) => {
|
|
|
1283
1283
|
continue;
|
|
1284
1284
|
}
|
|
1285
1285
|
const key = trimmed.split("=", 1)[0].trim();
|
|
1286
|
-
if (key === "
|
|
1287
|
-
else if (key === "
|
|
1288
|
-
else if (key === "
|
|
1286
|
+
if (key === "TINA4_DATABASE_URL") { newLines.push(`TINA4_DATABASE_URL=${url}`); keysFound.TINA4_DATABASE_URL = true; }
|
|
1287
|
+
else if (key === "TINA4_DATABASE_USERNAME") { newLines.push(`TINA4_DATABASE_USERNAME=${username}`); keysFound.TINA4_DATABASE_USERNAME = true; }
|
|
1288
|
+
else if (key === "TINA4_DATABASE_PASSWORD") { newLines.push(`TINA4_DATABASE_PASSWORD=${password}`); keysFound.TINA4_DATABASE_PASSWORD = true; }
|
|
1289
1289
|
else { newLines.push(line); }
|
|
1290
1290
|
}
|
|
1291
|
-
const values: Record<string, string> = {
|
|
1291
|
+
const values: Record<string, string> = { TINA4_DATABASE_URL: url, TINA4_DATABASE_USERNAME: username, TINA4_DATABASE_PASSWORD: password };
|
|
1292
1292
|
for (const [key, found] of Object.entries(keysFound)) {
|
|
1293
1293
|
if (!found) newLines.push(`${key}=${values[key]}`);
|
|
1294
1294
|
}
|
|
@@ -57,7 +57,7 @@ export class DevMailbox {
|
|
|
57
57
|
const message: EmailMessage = {
|
|
58
58
|
id,
|
|
59
59
|
type: "outbox",
|
|
60
|
-
from: from ?? process.env.
|
|
60
|
+
from: from ?? process.env.TINA4_MAIL_FROM ?? "dev@localhost",
|
|
61
61
|
to: toList,
|
|
62
62
|
cc,
|
|
63
63
|
bcc,
|
|
@@ -286,7 +286,7 @@ export class DevMailbox {
|
|
|
286
286
|
*
|
|
287
287
|
* Returns DevMailbox when:
|
|
288
288
|
* - TINA4_DEBUG is "true", OR
|
|
289
|
-
* - No
|
|
289
|
+
* - No TINA4_MAIL_HOST is configured
|
|
290
290
|
*
|
|
291
291
|
* Returns a real Messenger otherwise (SMTP configured + not debug mode).
|
|
292
292
|
*
|
|
@@ -294,7 +294,7 @@ export class DevMailbox {
|
|
|
294
294
|
*/
|
|
295
295
|
export function createMessenger(): Messenger | DevMailbox {
|
|
296
296
|
const debug = process.env.TINA4_DEBUG;
|
|
297
|
-
const smtpHost = process.env.
|
|
297
|
+
const smtpHost = process.env.TINA4_MAIL_HOST;
|
|
298
298
|
|
|
299
299
|
// Force dev mode when TINA4_DEBUG is truthy
|
|
300
300
|
if (isTruthy(debug)) {
|
|
@@ -12,7 +12,7 @@ export type {
|
|
|
12
12
|
WebSocketRouteDefinition,
|
|
13
13
|
} from "./types.js";
|
|
14
14
|
|
|
15
|
-
export { startServer, resolvePortAndHost, handle, start, stop } from "./server.js";
|
|
15
|
+
export { startServer, resolvePortAndHost, handle, start, stop, httpReason, resolveTemplate, resetTemplateCache, templateAutoRoutingEnabled } from "./server.js";
|
|
16
16
|
export { background, stopAllBackgroundTasks, backgroundTaskCount } from "./background.js";
|
|
17
17
|
export { Router, RouteGroup, RouteRef, defaultRouter, runRouteMiddlewares } from "./router.js";
|
|
18
18
|
export { get, post, put, patch, del, any, websocket, del as delete } from "./router.js";
|
|
@@ -300,25 +300,25 @@ console.log("\nResource Registration and Read");
|
|
|
300
300
|
console.log("\nLocalhost Detection");
|
|
301
301
|
|
|
302
302
|
{
|
|
303
|
-
const oldHost = process.env.
|
|
303
|
+
const oldHost = process.env.TINA4_HOST_NAME;
|
|
304
304
|
|
|
305
|
-
process.env.
|
|
305
|
+
process.env.TINA4_HOST_NAME = "localhost:7148";
|
|
306
306
|
assert("isLocalhost — localhost", isLocalhost() === true);
|
|
307
307
|
|
|
308
|
-
process.env.
|
|
308
|
+
process.env.TINA4_HOST_NAME = "127.0.0.1:7148";
|
|
309
309
|
assert("isLocalhost — 127.0.0.1", isLocalhost() === true);
|
|
310
310
|
|
|
311
|
-
process.env.
|
|
311
|
+
process.env.TINA4_HOST_NAME = "0.0.0.0:7148";
|
|
312
312
|
assert("isLocalhost — 0.0.0.0", isLocalhost() === true);
|
|
313
313
|
|
|
314
|
-
process.env.
|
|
314
|
+
process.env.TINA4_HOST_NAME = "myserver.example.com:7148";
|
|
315
315
|
assert("isLocalhost — remote false", isLocalhost() === false);
|
|
316
316
|
|
|
317
317
|
// Restore
|
|
318
318
|
if (oldHost !== undefined) {
|
|
319
|
-
process.env.
|
|
319
|
+
process.env.TINA4_HOST_NAME = oldHost;
|
|
320
320
|
} else {
|
|
321
|
-
delete process.env.
|
|
321
|
+
delete process.env.TINA4_HOST_NAME;
|
|
322
322
|
}
|
|
323
323
|
}
|
|
324
324
|
|
package/packages/core/src/mcp.ts
CHANGED
|
@@ -157,7 +157,7 @@ export function schemaFromParams(params: McpToolParam[]): JsonSchema {
|
|
|
157
157
|
// ── Localhost detection ──────────────────────────────────────
|
|
158
158
|
|
|
159
159
|
export function isLocalhost(): boolean {
|
|
160
|
-
const hostEnv = process.env.
|
|
160
|
+
const hostEnv = process.env.TINA4_HOST_NAME || "localhost:7148";
|
|
161
161
|
const host = hostEnv.split(":")[0];
|
|
162
162
|
return ["localhost", "127.0.0.1", "0.0.0.0", "::1", ""].includes(host);
|
|
163
163
|
}
|
|
@@ -302,28 +302,24 @@ export class Messenger {
|
|
|
302
302
|
private imapPass: string;
|
|
303
303
|
|
|
304
304
|
constructor(options?: MessengerOptions) {
|
|
305
|
-
// Priority: constructor > TINA4_MAIL_* >
|
|
305
|
+
// Priority: constructor > TINA4_MAIL_* > sensible default.
|
|
306
|
+
// Legacy SMTP_*/IMAP_* env vars were removed in v3.12 — boot guard rejects them.
|
|
306
307
|
this.host = options?.host
|
|
307
308
|
?? process.env.TINA4_MAIL_HOST
|
|
308
|
-
?? process.env.SMTP_HOST
|
|
309
309
|
?? "localhost";
|
|
310
310
|
this.port = options?.port
|
|
311
|
-
?? parseInt(process.env.TINA4_MAIL_PORT ??
|
|
311
|
+
?? parseInt(process.env.TINA4_MAIL_PORT ?? "587", 10);
|
|
312
312
|
this.username = options?.username
|
|
313
313
|
?? process.env.TINA4_MAIL_USERNAME
|
|
314
|
-
?? process.env.SMTP_USERNAME
|
|
315
314
|
?? "";
|
|
316
315
|
this.password = options?.password
|
|
317
316
|
?? process.env.TINA4_MAIL_PASSWORD
|
|
318
|
-
?? process.env.SMTP_PASSWORD
|
|
319
317
|
?? "";
|
|
320
318
|
this.fromAddress = options?.fromAddress
|
|
321
319
|
?? process.env.TINA4_MAIL_FROM
|
|
322
|
-
?? process.env.SMTP_FROM
|
|
323
320
|
?? (this.username || "noreply@localhost");
|
|
324
321
|
this.fromName = options?.fromName
|
|
325
322
|
?? process.env.TINA4_MAIL_FROM_NAME
|
|
326
|
-
?? process.env.SMTP_FROM_NAME
|
|
327
323
|
?? "";
|
|
328
324
|
|
|
329
325
|
// Encryption: constructor > .env > backward-compat useTls > default "tls"
|
|
@@ -340,15 +336,14 @@ export class Messenger {
|
|
|
340
336
|
|
|
341
337
|
this.imapHost = options?.imapHost
|
|
342
338
|
?? process.env.TINA4_MAIL_IMAP_HOST
|
|
343
|
-
?? process.env.IMAP_HOST
|
|
344
339
|
?? "";
|
|
345
340
|
this.imapPort = options?.imapPort
|
|
346
|
-
?? parseInt(process.env.TINA4_MAIL_IMAP_PORT ??
|
|
341
|
+
?? parseInt(process.env.TINA4_MAIL_IMAP_PORT ?? "993", 10);
|
|
347
342
|
this.imapUser = options?.imapUser
|
|
348
|
-
?? process.env.
|
|
343
|
+
?? process.env.TINA4_MAIL_IMAP_USERNAME
|
|
349
344
|
?? this.username;
|
|
350
345
|
this.imapPass = options?.imapPass
|
|
351
|
-
?? process.env.
|
|
346
|
+
?? process.env.TINA4_MAIL_IMAP_PASSWORD
|
|
352
347
|
?? this.password;
|
|
353
348
|
}
|
|
354
349
|
|
|
@@ -47,6 +47,83 @@ const TINA4_VERSION = readPackageVersion();
|
|
|
47
47
|
/** Cache Frond instances by template directory to avoid repeated instantiation. */
|
|
48
48
|
const frondCache = new Map<string, InstanceType<any>>();
|
|
49
49
|
|
|
50
|
+
// ─── Legacy env var guard (v3.12 hard rename) ────────────────────────────
|
|
51
|
+
// All framework env vars now require the TINA4_ prefix. If any of these
|
|
52
|
+
// pre-3.12 names are present in the environment we refuse to boot —
|
|
53
|
+
// silently ignoring them would cause auth/db/mail to fall back to
|
|
54
|
+
// defaults with no warning. Each maps to its new TINA4_-prefixed
|
|
55
|
+
// canonical name.
|
|
56
|
+
const _LEGACY_ENV_VARS: Record<string, string> = {
|
|
57
|
+
DATABASE_URL: "TINA4_DATABASE_URL",
|
|
58
|
+
DATABASE_USERNAME: "TINA4_DATABASE_USERNAME",
|
|
59
|
+
DATABASE_PASSWORD: "TINA4_DATABASE_PASSWORD",
|
|
60
|
+
DB_URL: "TINA4_DATABASE_URL",
|
|
61
|
+
SECRET: "TINA4_SECRET",
|
|
62
|
+
API_KEY: "TINA4_API_KEY",
|
|
63
|
+
JWT_ALGORITHM: "TINA4_JWT_ALGORITHM",
|
|
64
|
+
SMTP_HOST: "TINA4_MAIL_HOST",
|
|
65
|
+
SMTP_PORT: "TINA4_MAIL_PORT",
|
|
66
|
+
SMTP_USERNAME: "TINA4_MAIL_USERNAME",
|
|
67
|
+
SMTP_PASSWORD: "TINA4_MAIL_PASSWORD",
|
|
68
|
+
SMTP_FROM: "TINA4_MAIL_FROM",
|
|
69
|
+
SMTP_FROM_NAME: "TINA4_MAIL_FROM_NAME",
|
|
70
|
+
IMAP_HOST: "TINA4_MAIL_IMAP_HOST",
|
|
71
|
+
IMAP_PORT: "TINA4_MAIL_IMAP_PORT",
|
|
72
|
+
IMAP_USER: "TINA4_MAIL_IMAP_USERNAME",
|
|
73
|
+
IMAP_PASS: "TINA4_MAIL_IMAP_PASSWORD",
|
|
74
|
+
HOST_NAME: "TINA4_HOST_NAME",
|
|
75
|
+
SWAGGER_TITLE: "TINA4_SWAGGER_TITLE",
|
|
76
|
+
SWAGGER_DESCRIPTION: "TINA4_SWAGGER_DESCRIPTION",
|
|
77
|
+
SWAGGER_VERSION: "TINA4_SWAGGER_VERSION",
|
|
78
|
+
ORM_PLURAL_TABLE_NAMES: "TINA4_ORM_PLURAL_TABLE_NAMES",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Refuse to boot if pre-3.12 un-prefixed env vars are still set.
|
|
83
|
+
*
|
|
84
|
+
* Tina4 v3.12 hard-renamed every framework-specific env var to use the
|
|
85
|
+
* `TINA4_` prefix. Booting silently with a legacy `DATABASE_URL` or
|
|
86
|
+
* `SECRET` would let auth, DB, or mail fall back to insecure defaults
|
|
87
|
+
* while the user thought their config was being read. Better to die
|
|
88
|
+
* loudly with a list of names to fix.
|
|
89
|
+
*
|
|
90
|
+
* Bypass with `TINA4_ALLOW_LEGACY_ENV=true` in CI / migration scripts
|
|
91
|
+
* that genuinely need both names set during a transition window.
|
|
92
|
+
*/
|
|
93
|
+
export function _checkLegacyEnvVars(): void {
|
|
94
|
+
if (isTruthy(process.env.TINA4_ALLOW_LEGACY_ENV)) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const found = Object.keys(_LEGACY_ENV_VARS)
|
|
98
|
+
.filter((name) => process.env[name] !== undefined)
|
|
99
|
+
.sort();
|
|
100
|
+
if (found.length === 0) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const bar = "─".repeat(72);
|
|
104
|
+
const lines: string[] = [
|
|
105
|
+
"",
|
|
106
|
+
bar,
|
|
107
|
+
"Tina4 v3.12 requires TINA4_ prefix on all framework env vars.",
|
|
108
|
+
"Your environment still has these legacy names:",
|
|
109
|
+
"",
|
|
110
|
+
];
|
|
111
|
+
for (const old of found) {
|
|
112
|
+
const next = _LEGACY_ENV_VARS[old];
|
|
113
|
+
lines.push(` ${old.padEnd(28)} → ${next}`);
|
|
114
|
+
}
|
|
115
|
+
lines.push(
|
|
116
|
+
"",
|
|
117
|
+
"Run `tina4 env-migrate` to rewrite your .env automatically,",
|
|
118
|
+
"or rename manually. See https://tina4.com/release/3.12.0",
|
|
119
|
+
"Set TINA4_ALLOW_LEGACY_ENV=true to bypass during migration.",
|
|
120
|
+
bar,
|
|
121
|
+
"",
|
|
122
|
+
);
|
|
123
|
+
process.stderr.write(lines.join("\n") + "\n");
|
|
124
|
+
process.exit(2);
|
|
125
|
+
}
|
|
126
|
+
|
|
50
127
|
/**
|
|
51
128
|
* Kill whatever process is listening on *port*.
|
|
52
129
|
* Uses lsof on macOS/Linux and netstat + taskkill on Windows.
|
|
@@ -231,23 +308,96 @@ function getGalleryDeployedState(): Record<string, boolean> {
|
|
|
231
308
|
return state;
|
|
232
309
|
}
|
|
233
310
|
|
|
311
|
+
/**
|
|
312
|
+
* Auto-routing scans this single subdirectory of src/templates/. Only files
|
|
313
|
+
* in src/templates/pages/ become URLs — everything else (partials, layouts,
|
|
314
|
+
* base.twig, errors, components, macros) is never URL-exposed and remains
|
|
315
|
+
* renderable only via {% include %} / {% extends %} / res.render().
|
|
316
|
+
*
|
|
317
|
+
* Convention adapted from Next.js' pages/ directory and Nuxt's pages/ folder.
|
|
318
|
+
* Explicit, secure by default, no skip lists to maintain.
|
|
319
|
+
*/
|
|
320
|
+
const TEMPLATE_PAGES_DIR = "pages";
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Honour TINA4_TEMPLATE_ROUTING=off|false|0|no|disabled as an explicit kill
|
|
324
|
+
* switch. Default: enabled. Drop a file in src/templates/pages/ and it serves
|
|
325
|
+
* at the matching URL — the zero-config Tina4 convention. Operators who want
|
|
326
|
+
* explicit-only routing can set TINA4_TEMPLATE_ROUTING=off and every URL
|
|
327
|
+
* must be registered via get() / post() (or be a static file).
|
|
328
|
+
*/
|
|
329
|
+
export function templateAutoRoutingEnabled(): boolean {
|
|
330
|
+
const val = (process.env.TINA4_TEMPLATE_ROUTING ?? "on").trim().toLowerCase();
|
|
331
|
+
return !["off", "false", "0", "no", "disabled"].includes(val);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* RFC 7231 / RFC 9110 status reason phrases. Used to write a correct HTTP
|
|
336
|
+
* status line — previously some paths wrote "HTTP/1.1 404 OK" because the
|
|
337
|
+
* canonical phrase wasn't being looked up per code.
|
|
338
|
+
*/
|
|
339
|
+
const HTTP_REASON_PHRASES: Record<number, string> = {
|
|
340
|
+
100: "Continue", 101: "Switching Protocols",
|
|
341
|
+
200: "OK", 201: "Created", 202: "Accepted", 204: "No Content",
|
|
342
|
+
206: "Partial Content",
|
|
343
|
+
301: "Moved Permanently", 302: "Found", 303: "See Other",
|
|
344
|
+
304: "Not Modified", 307: "Temporary Redirect", 308: "Permanent Redirect",
|
|
345
|
+
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
|
|
346
|
+
404: "Not Found", 405: "Method Not Allowed", 406: "Not Acceptable",
|
|
347
|
+
409: "Conflict", 410: "Gone", 413: "Content Too Large",
|
|
348
|
+
415: "Unsupported Media Type", 422: "Unprocessable Content",
|
|
349
|
+
429: "Too Many Requests",
|
|
350
|
+
500: "Internal Server Error", 501: "Not Implemented",
|
|
351
|
+
502: "Bad Gateway", 503: "Service Unavailable", 504: "Gateway Timeout",
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Return the canonical HTTP reason phrase for `status`. Falls back to a
|
|
356
|
+
* sensible label when an exotic status is used. Never returns an empty string.
|
|
357
|
+
*/
|
|
358
|
+
export function httpReason(status: number): string {
|
|
359
|
+
const phrase = HTTP_REASON_PHRASES[status];
|
|
360
|
+
if (phrase) return phrase;
|
|
361
|
+
return status >= 200 && status < 300 ? "OK" : "Error";
|
|
362
|
+
}
|
|
363
|
+
|
|
234
364
|
/** Template cache: url_path -> template_file. Null until first production lookup. */
|
|
235
365
|
let templateCache: Map<string, string> | null = null;
|
|
236
366
|
|
|
237
367
|
/**
|
|
238
|
-
*
|
|
368
|
+
* Reset the production template cache. Tests use this between scenarios so
|
|
369
|
+
* a fresh scan picks up fixture files in a tmp project.
|
|
370
|
+
*/
|
|
371
|
+
export function resetTemplateCache(): void {
|
|
372
|
+
templateCache = null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Resolve a URL path to a template file in src/templates/pages/.
|
|
377
|
+
*
|
|
378
|
+
* Only files inside `src/templates/pages/` auto-route from a URL. Anything
|
|
379
|
+
* in `src/templates/` outside `pages/` (partials, layouts, base.twig,
|
|
380
|
+
* errors, components) is never served standalone.
|
|
381
|
+
*
|
|
239
382
|
* Dev mode: checks filesystem every time for live changes.
|
|
240
383
|
* Production: uses a cached lookup built once at startup.
|
|
384
|
+
*
|
|
385
|
+
* The whole feature can be turned off with `TINA4_TEMPLATE_ROUTING=off`.
|
|
241
386
|
*/
|
|
242
|
-
function resolveTemplate(pathname: string, templatesDir: string): string | null {
|
|
243
|
-
|
|
387
|
+
export function resolveTemplate(pathname: string, templatesDir: string): string | null {
|
|
388
|
+
if (!templateAutoRoutingEnabled()) return null;
|
|
389
|
+
|
|
390
|
+
const cleanPath = pathname.replace(/^\/+/, "").replace(/\/+$/, "") || "index";
|
|
244
391
|
const isDev = (process.env.TINA4_DEBUG ?? "false").toLowerCase() === "true";
|
|
245
392
|
|
|
246
393
|
if (isDev) {
|
|
394
|
+
// Skip underscore-prefixed files even within pages/ — they're private
|
|
395
|
+
// by Hugo/Jekyll convention (helpers, fragments) and shouldn't auto-serve.
|
|
396
|
+
if (cleanPath.split("/").some((seg) => seg.startsWith("_"))) return null;
|
|
397
|
+
const pagesDir = resolve(templatesDir, TEMPLATE_PAGES_DIR);
|
|
247
398
|
for (const ext of [".twig", ".html"]) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return candidate;
|
|
399
|
+
if (existsSync(resolve(pagesDir, cleanPath + ext))) {
|
|
400
|
+
return `${TEMPLATE_PAGES_DIR}/${cleanPath}${ext}`;
|
|
251
401
|
}
|
|
252
402
|
}
|
|
253
403
|
return null;
|
|
@@ -256,21 +406,24 @@ function resolveTemplate(pathname: string, templatesDir: string): string | null
|
|
|
256
406
|
// Production: cached lookup
|
|
257
407
|
if (!templateCache) {
|
|
258
408
|
templateCache = new Map();
|
|
259
|
-
|
|
409
|
+
const pagesDir = resolve(templatesDir, TEMPLATE_PAGES_DIR);
|
|
410
|
+
if (existsSync(pagesDir)) {
|
|
260
411
|
const scan = (dir: string, prefix: string) => {
|
|
261
412
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
413
|
+
// Skip private files even within pages/ (e.g. pages/_helper.twig)
|
|
414
|
+
if (entry.name.startsWith("_")) continue;
|
|
262
415
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
263
416
|
if (entry.isDirectory()) {
|
|
264
417
|
scan(resolve(dir, entry.name), rel);
|
|
265
418
|
} else if (entry.name.endsWith(".twig") || entry.name.endsWith(".html")) {
|
|
266
419
|
const urlPath = rel.replace(/\.(twig|html)$/, "");
|
|
267
420
|
if (!templateCache!.has(urlPath)) {
|
|
268
|
-
templateCache!.set(urlPath, rel);
|
|
421
|
+
templateCache!.set(urlPath, `${TEMPLATE_PAGES_DIR}/${rel}`);
|
|
269
422
|
}
|
|
270
423
|
}
|
|
271
424
|
}
|
|
272
425
|
};
|
|
273
|
-
scan(
|
|
426
|
+
scan(pagesDir, "");
|
|
274
427
|
}
|
|
275
428
|
}
|
|
276
429
|
return templateCache.get(cleanPath) ?? null;
|
|
@@ -502,6 +655,9 @@ export async function startServer(config?: Tina4Config): Promise<{
|
|
|
502
655
|
// Load .env early so TINA4_DEBUG is available for cluster decision
|
|
503
656
|
loadEnv();
|
|
504
657
|
|
|
658
|
+
// Refuse to boot with pre-3.12 un-prefixed env vars set.
|
|
659
|
+
_checkLegacyEnvVars();
|
|
660
|
+
|
|
505
661
|
const resolved = resolvePortAndHost(config);
|
|
506
662
|
const host = resolved.host;
|
|
507
663
|
let port = resolved.port;
|
|
@@ -566,6 +722,9 @@ ${reset}
|
|
|
566
722
|
const modelsDir = resolve(base, config?.modelsDir ?? "src/models");
|
|
567
723
|
const ormDir = resolve(base, "src/orm");
|
|
568
724
|
const staticDir = resolve(base, config?.staticDir ?? "public");
|
|
725
|
+
// src/public is the second-tier static dir (Python parity). When the user
|
|
726
|
+
// ships a Vite/SPA build there, src/public/index.html auto-serves at "/".
|
|
727
|
+
const srcPublicDir = resolve(base, "src/public");
|
|
569
728
|
const templatesDir = resolve(base, config?.templatesDir ?? "src/templates");
|
|
570
729
|
|
|
571
730
|
// .env already loaded above for cluster decision
|
|
@@ -834,10 +993,14 @@ ${reset}
|
|
|
834
993
|
res.raw.end = wrappedEnd;
|
|
835
994
|
}
|
|
836
995
|
|
|
837
|
-
// Try static files first (project public dir, then framework built-in
|
|
996
|
+
// Try static files first (project public dir, src/public dir, then framework built-in)
|
|
997
|
+
// Index resolution: "/" or "/foo/" picks up index.html so SPA builds Just Work.
|
|
838
998
|
if (existsSync(staticDir) && tryServeStatic(staticDir, req, res)) {
|
|
839
999
|
return;
|
|
840
1000
|
}
|
|
1001
|
+
if (existsSync(srcPublicDir) && tryServeStatic(srcPublicDir, req, res)) {
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
841
1004
|
if (tryServeStatic(BUILTIN_PUBLIC_DIR, req, res)) {
|
|
842
1005
|
return;
|
|
843
1006
|
}
|
|
@@ -944,34 +1107,45 @@ ${reset}
|
|
|
944
1107
|
return;
|
|
945
1108
|
}
|
|
946
1109
|
|
|
947
|
-
// Try serving a template file (e.g. /hello -> src/templates/hello.twig
|
|
1110
|
+
// Try serving a template file (e.g. /hello -> src/templates/pages/hello.twig)
|
|
948
1111
|
if ((req.method ?? "GET") === "GET") {
|
|
949
1112
|
const tplFile = resolveTemplate(pathname, templatesDir);
|
|
950
1113
|
if (tplFile) {
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1114
|
+
// Render through Frond so {% include %} / {% extends %} work,
|
|
1115
|
+
// not raw readFileSync.
|
|
1116
|
+
if (frondEngine) {
|
|
1117
|
+
const html = frondEngine.render(tplFile, {});
|
|
1118
|
+
res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
|
|
1119
|
+
res.raw.end(html);
|
|
1120
|
+
} else {
|
|
1121
|
+
const html = readFileSync(resolve(templatesDir, tplFile), "utf-8");
|
|
1122
|
+
res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
|
|
1123
|
+
res.raw.end(html);
|
|
1124
|
+
}
|
|
954
1125
|
return;
|
|
955
1126
|
}
|
|
956
1127
|
|
|
957
|
-
//
|
|
958
|
-
|
|
1128
|
+
// Landing page renders only at "/" AND only when TINA4_DEBUG=true.
|
|
1129
|
+
// In production "/" with no static index.html and no pages/index.twig
|
|
1130
|
+
// falls through to a clean 404 — the framework's branded welcome,
|
|
1131
|
+
// gallery and version never leak to real users.
|
|
1132
|
+
if (pathname === "/" && isDevMode()) {
|
|
959
1133
|
const allRoutes = router.getRoutes().map((r) => ({
|
|
960
1134
|
method: r.method,
|
|
961
1135
|
pattern: r.pattern,
|
|
962
1136
|
flags: [] as string[],
|
|
963
1137
|
}));
|
|
964
1138
|
const html = renderLandingPage(allRoutes, port);
|
|
965
|
-
res.raw.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
1139
|
+
res.raw.writeHead(200, undefined, { "Content-Type": "text/html; charset=utf-8" });
|
|
966
1140
|
res.raw.end(html);
|
|
967
1141
|
return;
|
|
968
1142
|
}
|
|
969
1143
|
}
|
|
970
1144
|
|
|
971
|
-
// 404
|
|
1145
|
+
// 404 — pass canonical reason phrase so the status line is well-formed
|
|
972
1146
|
const html404 = await renderErrorPage(404, { path: pathname }, templatesDir);
|
|
973
1147
|
if (html404) {
|
|
974
|
-
res.raw.writeHead(404, { "Content-Type": "text/html; charset=utf-8" });
|
|
1148
|
+
res.raw.writeHead(404, httpReason(404), { "Content-Type": "text/html; charset=utf-8" });
|
|
975
1149
|
res.raw.end(html404);
|
|
976
1150
|
} else {
|
|
977
1151
|
res({ error: "Not Found", statusCode: 404, message: `No route found for ${req.method} ${pathname}` }, 404);
|
|
@@ -42,10 +42,10 @@ export class DatabaseSessionHandler implements SessionHandler {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* Resolve the database file path from
|
|
45
|
+
* Resolve the database file path from TINA4_DATABASE_URL or use the default.
|
|
46
46
|
*/
|
|
47
47
|
private resolveDbPath(): string {
|
|
48
|
-
const url = process.env.
|
|
48
|
+
const url = process.env.TINA4_DATABASE_URL;
|
|
49
49
|
if (url && url.startsWith("sqlite://")) {
|
|
50
50
|
// sqlite:///path/to/db or sqlite://./relative/path
|
|
51
51
|
return url.replace(/^sqlite:\/\//, "");
|
|
@@ -1271,7 +1271,7 @@ export function setFormTokenSessionId(sessionId: string): void {
|
|
|
1271
1271
|
}
|
|
1272
1272
|
|
|
1273
1273
|
function _buildFormTokenJwt(descriptor: string = ""): string {
|
|
1274
|
-
const secret = process.env.
|
|
1274
|
+
const secret = process.env.TINA4_SECRET || "tina4-default-secret";
|
|
1275
1275
|
const ttlMinutes = parseInt(process.env.TINA4_TOKEN_LIMIT || "60", 10);
|
|
1276
1276
|
|
|
1277
1277
|
const header = { alg: "HS256", typ: "JWT" };
|
|
@@ -21,11 +21,11 @@ export function camelToSnake(name: string): string {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
|
-
* Check whether
|
|
24
|
+
* Check whether TINA4_ORM_PLURAL_TABLE_NAMES is enabled in .env.
|
|
25
25
|
* When true, hasMany relationship keys get an "s" suffix (e.g. "posts" instead of "post").
|
|
26
26
|
*/
|
|
27
27
|
function _pluralRelKeys(): boolean {
|
|
28
|
-
const v = process.env.
|
|
28
|
+
const v = process.env.TINA4_ORM_PLURAL_TABLE_NAMES ?? "";
|
|
29
29
|
return /^(true|1|yes)$/i.test(v);
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -217,8 +217,8 @@ export class BaseModel {
|
|
|
217
217
|
try {
|
|
218
218
|
return getAdapter();
|
|
219
219
|
} catch {
|
|
220
|
-
// No adapter registered — try
|
|
221
|
-
const url = process.env.
|
|
220
|
+
// No adapter registered — try TINA4_DATABASE_URL auto-discovery
|
|
221
|
+
const url = process.env.TINA4_DATABASE_URL;
|
|
222
222
|
if (url) {
|
|
223
223
|
const parsed = parseDatabaseUrl(url);
|
|
224
224
|
if (parsed.type === "sqlite") {
|
|
@@ -822,21 +822,21 @@ async function createAdapterFromUrl(url: string, username?: string, password?: s
|
|
|
822
822
|
}
|
|
823
823
|
|
|
824
824
|
/**
|
|
825
|
-
* Initialize the database from a config object or
|
|
825
|
+
* Initialize the database from a config object or TINA4_DATABASE_URL env var.
|
|
826
826
|
* Now returns a Database wrapper instance.
|
|
827
827
|
*
|
|
828
828
|
* Priority:
|
|
829
829
|
* 1. config.url (explicit URL)
|
|
830
|
-
* 2. process.env.
|
|
830
|
+
* 2. process.env.TINA4_DATABASE_URL
|
|
831
831
|
* 3. config.type + config.path (legacy)
|
|
832
832
|
*/
|
|
833
833
|
export async function initDatabase(config?: DatabaseConfig): Promise<Database> {
|
|
834
|
-
// Resolve credentials: config.user > config.username > env
|
|
835
|
-
const resolvedUser = config?.user ?? config?.username ?? process.env.
|
|
836
|
-
const resolvedPassword = config?.password ?? process.env.
|
|
834
|
+
// Resolve credentials: config.user > config.username > env TINA4_DATABASE_USERNAME
|
|
835
|
+
const resolvedUser = config?.user ?? config?.username ?? process.env.TINA4_DATABASE_USERNAME;
|
|
836
|
+
const resolvedPassword = config?.password ?? process.env.TINA4_DATABASE_PASSWORD;
|
|
837
837
|
|
|
838
838
|
// Resolve from URL if provided
|
|
839
|
-
const url = config?.url ?? process.env.
|
|
839
|
+
const url = config?.url ?? process.env.TINA4_DATABASE_URL;
|
|
840
840
|
|
|
841
841
|
if (url) {
|
|
842
842
|
const adapter = await createAdapterFromUrl(url, resolvedUser, resolvedPassword);
|
|
@@ -15,9 +15,9 @@ export function generate(
|
|
|
15
15
|
const spec: OpenAPISpec = {
|
|
16
16
|
openapi: "3.0.3",
|
|
17
17
|
info: {
|
|
18
|
-
title: process.env.
|
|
19
|
-
version: "0.0.1",
|
|
20
|
-
description: "Auto-generated API documentation",
|
|
18
|
+
title: process.env.TINA4_SWAGGER_TITLE ?? "Tina4 API",
|
|
19
|
+
version: process.env.TINA4_SWAGGER_VERSION ?? "0.0.1",
|
|
20
|
+
description: process.env.TINA4_SWAGGER_DESCRIPTION ?? "Auto-generated API documentation",
|
|
21
21
|
},
|
|
22
22
|
paths: {},
|
|
23
23
|
components: { schemas: {} },
|