tina4-nodejs 3.13.149 → 3.13.151
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/CLAUDE.md +2 -2
- package/package.json +5 -9
- package/packages/cli/dist/bin.js +1074 -521
- package/packages/core/dist/index.js +1071 -516
- package/packages/core/public/bundled-component-provenance.json +2 -2
- package/packages/core/public/js/tina4-dev-admin-split.html +202 -0
- package/packages/core/public/js/tina4-dev-admin.min.js +147 -138
- package/packages/core/src/aiClient.ts +2 -1
- package/packages/core/src/api.ts +2 -1
- package/packages/core/src/auth.ts +1 -1
- package/packages/core/src/cache.ts +6 -1
- package/packages/core/src/devAdmin.ts +209 -123
- package/packages/core/src/dispatchPipeline.ts +4 -0
- package/packages/core/src/docs.ts +5 -3
- package/packages/core/src/errorOverlay.ts +18 -9
- package/packages/core/src/index.ts +2 -1
- package/packages/core/src/mcp.ts +53 -16
- package/packages/core/src/messenger.ts +22 -30
- package/packages/core/src/metrics.ts +5 -1
- package/packages/core/src/middleware.ts +8 -1
- package/packages/core/src/optionalPackage.ts +2 -2
- package/packages/core/src/plan.ts +4 -4
- package/packages/core/src/push.ts +1 -1
- package/packages/core/src/request.ts +185 -25
- package/packages/core/src/router.ts +3 -2
- package/packages/core/src/server.ts +32 -10
- package/packages/core/src/session.ts +44 -0
- package/packages/core/src/slashTrim.ts +39 -0
- package/packages/core/src/wsdl.ts +66 -48
- package/packages/core/templates/crud/form.twig +5 -5
- package/packages/core/templates/crud/table.twig +2 -2
- package/packages/core/templates/errors/302.twig +4 -3
- package/packages/frond/dist/index.js +206 -64
- package/packages/frond/src/engine.ts +278 -69
- package/packages/frond/src/index.ts +1 -1
- package/packages/orm/dist/index.js +1055 -502
- package/packages/orm/src/crudRender.ts +5 -5
- package/packages/orm/src/crudTable.ts +1 -1
- package/packages/orm/src/database.ts +15 -29
- package/packages/orm/src/databaseResult.ts +11 -1
- package/packages/orm/src/realtime/index.ts +1 -0
- package/packages/orm/src/realtime/realtime.ts +2 -1
- package/packages/orm/src/realtime/s3Client.ts +276 -0
- package/packages/orm/src/realtime/storage.ts +19 -60
- package/packages/orm/src/sqlTranslator.ts +58 -42
- package/packages/swagger/dist/index.js +25 -4
- package/packages/swagger/src/generator.ts +7 -4
- package/packages/swagger/src/ui.ts +2 -1
- package/types/core/src/devAdmin.d.ts +40 -0
- package/types/core/src/index.d.ts +2 -1
- package/types/core/src/middleware.d.ts +1 -0
- package/types/core/src/optionalPackage.d.ts +2 -2
- package/types/core/src/request.d.ts +5 -1
- package/types/core/src/session.d.ts +25 -0
- package/types/core/src/slashTrim.d.ts +6 -0
- package/types/frond/src/engine.d.ts +11 -0
- package/types/frond/src/index.d.ts +1 -1
- package/types/orm/src/realtime/index.d.ts +1 -0
- package/types/orm/src/realtime/s3Client.d.ts +48 -0
- package/types/orm/src/realtime/storage.d.ts +9 -7
|
@@ -7,13 +7,14 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { createServer, IncomingMessage, ServerResponse } from "node:http";
|
|
10
|
+
import { trimSlashes } from "./slashTrim.js";
|
|
10
11
|
import { createTina4HttpServer, guardUnsafeHeaders, sendTransportRejection } from "./transport.js";
|
|
11
12
|
import { randomBytes } from "node:crypto";
|
|
12
13
|
import { resolve, dirname, join, relative } from "node:path";
|
|
13
14
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
14
15
|
import { isatty } from "node:tty";
|
|
15
16
|
import { fileURLToPath } from "node:url";
|
|
16
|
-
import { execFileSync,
|
|
17
|
+
import { execFileSync, execFile } from "node:child_process";
|
|
17
18
|
import cluster from "node:cluster";
|
|
18
19
|
import os from "node:os";
|
|
19
20
|
import type { Socket } from "node:net";
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
compressionEtagIntercept,
|
|
38
39
|
sessionAutoStart,
|
|
39
40
|
} from "./dispatchPipeline.js";
|
|
41
|
+
import { runWithFormTokenSession, currentFormTokenSessionId } from "./session.js";
|
|
40
42
|
import { createResponse, setDefaultTemplatesDir, wantsJson, negotiatedErrorBody } from "./response.js";
|
|
41
43
|
import { MiddlewareChain, MiddlewareRunner, cors, requestLogger, isMiddlewareClass, attachCsrfFromEnv, SecurityHeadersMiddleware } from "./middleware.js";
|
|
42
44
|
import { tryServeStatic } from "./static.js";
|
|
@@ -398,11 +400,24 @@ export function shouldOpenBrowser(isDevelopment: boolean, noBrowser = false): bo
|
|
|
398
400
|
* Open the user's default browser after a short delay so the server is ready.
|
|
399
401
|
*/
|
|
400
402
|
function openBrowser(url: string) {
|
|
401
|
-
//
|
|
403
|
+
// Only ever hand a well-formed http(s) dev URL to the OS browser launcher.
|
|
404
|
+
// TINA4_HOST / HOST are operator configuration, not remote input, but a value
|
|
405
|
+
// carrying shell or URL metacharacters must never reach the launch argv — an
|
|
406
|
+
// unusual host means "do not auto-open", never "run this". Host is a bare
|
|
407
|
+
// hostname / IPv4 / bracketed IPv6 literal; port is digits only. This guard
|
|
408
|
+
// is what sanitizes the env-derived host (js/indirect-command-line-injection,
|
|
409
|
+
// js/shell-command-constructed-from-input).
|
|
410
|
+
if (!/^https?:\/\/([A-Za-z0-9.-]+|\[[0-9A-Fa-f:]+\])(:\d{1,5})?\/?$/.test(url)) return;
|
|
411
|
+
// No shell on any platform: each launcher is an explicit binary and the URL
|
|
412
|
+
// is a single argv entry, never interpreted by a command processor. Windows
|
|
413
|
+
// opens via rundll32's FileProtocolHandler, NOT `cmd /c start` — `cmd` is a
|
|
414
|
+
// shell, so routing a host-derived URL through it is a command-injection sink
|
|
415
|
+
// (js/indirect-command-line-injection, js/shell-command-constructed-from-input);
|
|
416
|
+
// rundll32 is not, and it also avoids `start`'s argument-quoting pitfalls.
|
|
402
417
|
setTimeout(() => {
|
|
403
|
-
if (process.platform === "darwin")
|
|
404
|
-
else if (process.platform === "win32")
|
|
405
|
-
else
|
|
418
|
+
if (process.platform === "darwin") execFile("open", [url]);
|
|
419
|
+
else if (process.platform === "win32") execFile("rundll32", ["url.dll,FileProtocolHandler", url]);
|
|
420
|
+
else execFile("xdg-open", [url]);
|
|
406
421
|
}, 2000);
|
|
407
422
|
}
|
|
408
423
|
|
|
@@ -661,7 +676,7 @@ export function resetTemplateCache(): void {
|
|
|
661
676
|
export function resolveTemplate(pathname: string, templatesDir: string): string | null {
|
|
662
677
|
if (!templateAutoRoutingEnabled()) return null;
|
|
663
678
|
|
|
664
|
-
const cleanPath = pathname
|
|
679
|
+
const cleanPath = trimSlashes(pathname) || "index";
|
|
665
680
|
const isDev = (process.env.TINA4_DEBUG ?? "false").toLowerCase() === "true";
|
|
666
681
|
|
|
667
682
|
if (isDev) {
|
|
@@ -1078,7 +1093,10 @@ export function resolveHandlerArgs(
|
|
|
1078
1093
|
routeParams: Record<string, unknown>,
|
|
1079
1094
|
): unknown[] {
|
|
1080
1095
|
const fnStr = (handler as { toString(): string }).toString();
|
|
1081
|
-
|
|
1096
|
+
// ReDoS-safe: the old `(?:function\s*\w*)?\s*\(` had two `\s*` straddling `\w*`
|
|
1097
|
+
// before `\(`, so whitespace could be split two ways (O(n^2)). A single
|
|
1098
|
+
// optional name and one trailing `\s*` removes the ambiguity.
|
|
1099
|
+
const argMatch = fnStr.match(/^(?:async\s*)?(?:function(?:\s+\w+)?\s*)?\(([^)]*)\)/);
|
|
1082
1100
|
const argNames = argMatch?.[1]?.split(",").map((a: string) => a.trim().replace(/[:=].*/, "")) ?? [];
|
|
1083
1101
|
const filteredArgs = argNames.filter((n: string) => n.length > 0);
|
|
1084
1102
|
if (filteredArgs.length === 0) return [];
|
|
@@ -1705,8 +1723,9 @@ export async function buildDispatchContext(router: Router, base?: string): Promi
|
|
|
1705
1723
|
|
|
1706
1724
|
let frondEngine: DispatchContext["frondEngine"] = null;
|
|
1707
1725
|
try {
|
|
1708
|
-
const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
|
|
1726
|
+
const { Frond, setCspNonceProvider, setFormTokenSessionProvider } = await import("../../frond/src/engine.js");
|
|
1709
1727
|
setCspNonceProvider(currentCspNonce);
|
|
1728
|
+
setFormTokenSessionProvider(currentFormTokenSessionId);
|
|
1710
1729
|
frondEngine = new Frond(templatesDir);
|
|
1711
1730
|
} catch {
|
|
1712
1731
|
// Frond not available — template-route fallback stays inert, same guard startServer() uses.
|
|
@@ -1918,7 +1937,9 @@ export async function runDispatch(
|
|
|
1918
1937
|
// two requests interleaving on the one event loop never share it.
|
|
1919
1938
|
const requestNonce = generateNonce();
|
|
1920
1939
|
return Log.runWithRequestId(requestId, () =>
|
|
1921
|
-
runWithCspNonce(requestNonce, () =>
|
|
1940
|
+
runWithCspNonce(requestNonce, () =>
|
|
1941
|
+
runWithFormTokenSession(() => dispatchInner(ctx, rawReq, rawRes, requestId)),
|
|
1942
|
+
),
|
|
1922
1943
|
);
|
|
1923
1944
|
}
|
|
1924
1945
|
|
|
@@ -2156,8 +2177,9 @@ export async function startServer(config?: Tina4Config): Promise<{
|
|
|
2156
2177
|
let frondEngine: any = null;
|
|
2157
2178
|
setDefaultTemplatesDir(templatesDir);
|
|
2158
2179
|
try {
|
|
2159
|
-
const { Frond, setCspNonceProvider } = await import("../../frond/src/engine.js");
|
|
2180
|
+
const { Frond, setCspNonceProvider, setFormTokenSessionProvider } = await import("../../frond/src/engine.js");
|
|
2160
2181
|
setCspNonceProvider(currentCspNonce);
|
|
2182
|
+
setFormTokenSessionProvider(currentFormTokenSessionId);
|
|
2161
2183
|
frondEngine = new Frond(templatesDir);
|
|
2162
2184
|
|
|
2163
2185
|
// Always-on Frond {% live %} refresh endpoint. Re-renders a server-rendered
|
|
@@ -32,6 +32,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
32
32
|
* session.get("user"); // { name: "Alice" }
|
|
33
33
|
* session.destroy();
|
|
34
34
|
*/
|
|
35
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
35
36
|
import { createHash, randomBytes } from "node:crypto";
|
|
36
37
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync } from "node:fs";
|
|
37
38
|
import { join } from "node:path";
|
|
@@ -1060,3 +1061,46 @@ export function sessionCookieToSend(
|
|
|
1060
1061
|
const socketEncrypted = (rawReq.socket as { encrypted?: boolean } | undefined)?.encrypted === true;
|
|
1061
1062
|
return buildSessionCookie(newSid, ttl, undefined, forwardedProto, socketEncrypted);
|
|
1062
1063
|
}
|
|
1064
|
+
|
|
1065
|
+
// ── Form-token session (CSRF binding) ─────────────────────────────────
|
|
1066
|
+
/**
|
|
1067
|
+
* The session of the request being dispatched, for CSRF form-token binding.
|
|
1068
|
+
*
|
|
1069
|
+
* Frond's form_token() reads it through the provider core registers
|
|
1070
|
+
* (setFormTokenSessionProvider), so a token is bound to the session that
|
|
1071
|
+
* rendered it. It lives in an AsyncLocalStorage, like the CSP nonce, so two
|
|
1072
|
+
* requests interleaving on the one event loop never bind to each other's session.
|
|
1073
|
+
*/
|
|
1074
|
+
interface BindableSession {
|
|
1075
|
+
getSessionId(): string | null;
|
|
1076
|
+
isFresh(): boolean;
|
|
1077
|
+
set(key: string, value: unknown): void;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const sessionStore = new AsyncLocalStorage<{ session: BindableSession | null }>();
|
|
1081
|
+
|
|
1082
|
+
/** Run `fn` with a request scope the dispatcher publishes its session into. */
|
|
1083
|
+
export function runWithFormTokenSession<T>(fn: () => T): T {
|
|
1084
|
+
return sessionStore.run({ session: null }, fn);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Publish the current request's session (no-op outside a request scope). */
|
|
1088
|
+
export function setFormTokenSession(session: BindableSession | null): void {
|
|
1089
|
+
const store = sessionStore.getStore();
|
|
1090
|
+
if (store) store.session = session;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* The current request's session id, or "" outside a request.
|
|
1095
|
+
*
|
|
1096
|
+
* A session that holds nothing is sent no cookie, so the form's own POST would
|
|
1097
|
+
* arrive under a newly minted id and fail its own token. Rendering a bound
|
|
1098
|
+
* token therefore stores a marker, which makes the session persist.
|
|
1099
|
+
*/
|
|
1100
|
+
export function currentFormTokenSessionId(): string {
|
|
1101
|
+
const session = sessionStore.getStore()?.session;
|
|
1102
|
+
const sid = session?.getSessionId();
|
|
1103
|
+
if (!session || !sid) return "";
|
|
1104
|
+
if (session.isFresh()) session.set("_tina4_csrf", true);
|
|
1105
|
+
return sid;
|
|
1106
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Copyright (c) 2026 Code Infinity
|
|
3
|
+
SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
This Source Code Form is subject to the terms of the Mozilla Public
|
|
5
|
+
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
6
|
+
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ReDoS-safe edge-character trimming, shared by core/orm/swagger.
|
|
10
|
+
//
|
|
11
|
+
// `/\/+$/` and `/^\/+|\/+$/g` (and the slug's `/^-+|-+$/g`) are polynomial on V8:
|
|
12
|
+
// a long run of the character followed by a different tail makes the anchored
|
|
13
|
+
// `+` backtrack O(n^2), so a crafted request path of ~80k slashes hangs the
|
|
14
|
+
// event loop for seconds. These run on attacker-reachable paths (the router, the
|
|
15
|
+
// server pathname), so the fix matters. A single character scan strips the same
|
|
16
|
+
// run in one linear pass with no backtracking.
|
|
17
|
+
|
|
18
|
+
const SLASH = 47; // '/'.charCodeAt(0)
|
|
19
|
+
|
|
20
|
+
/** Strip the given char code from the end only (the `/\/+$/` replacement). */
|
|
21
|
+
export function rtrimSlashes(s: string): string {
|
|
22
|
+
let end = s.length;
|
|
23
|
+
while (end > 0 && s.charCodeAt(end - 1) === SLASH) end--;
|
|
24
|
+
return end === s.length ? s : s.slice(0, end);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Strip the given char code from both ends (linear, no backtracking). */
|
|
28
|
+
export function trimChar(s: string, code: number): string {
|
|
29
|
+
let start = 0;
|
|
30
|
+
let end = s.length;
|
|
31
|
+
while (start < end && s.charCodeAt(start) === code) start++;
|
|
32
|
+
while (end > start && s.charCodeAt(end - 1) === code) end--;
|
|
33
|
+
return start === 0 && end === s.length ? s : s.slice(start, end);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Strip leading AND trailing '/' (the ReDoS-safe `/^\/+|\/+$/g` replacement). */
|
|
37
|
+
export function trimSlashes(s: string): string {
|
|
38
|
+
return trimChar(s, SLASH);
|
|
39
|
+
}
|
|
@@ -143,65 +143,83 @@ const ATTRIBUTE_VALUE = /=\s*(?:"([^"<]*)"|'([^'<]*)')/g;
|
|
|
143
143
|
function parseXmlDocument(xml: string): XmlElement {
|
|
144
144
|
const stack: XmlElement[] = [];
|
|
145
145
|
let root: XmlElement | null = null;
|
|
146
|
-
let position = 0;
|
|
147
146
|
|
|
148
147
|
const addText = (text: string): void => {
|
|
149
148
|
const current = stack[stack.length - 1];
|
|
150
149
|
if (current.children.length === 0) current.text += text;
|
|
151
150
|
};
|
|
152
151
|
|
|
153
|
-
|
|
154
|
-
|
|
152
|
+
// One handler per markup construct. Each is entered at the "<" that opens the
|
|
153
|
+
// construct and returns the position just past it; they share stack/root/addText
|
|
154
|
+
// through this closure. Splitting the old six-arm if/else out of the scan loop
|
|
155
|
+
// keeps each rule small and named (cyclomatic complexity stays low per function).
|
|
156
|
+
const skipComment = (at: number): number => {
|
|
157
|
+
const close = xml.indexOf("-->", at + 4);
|
|
158
|
+
if (close === -1) malformed();
|
|
159
|
+
return close + 3;
|
|
160
|
+
};
|
|
161
|
+
const readCData = (at: number): number => {
|
|
162
|
+
const close = xml.indexOf("]]>", at + 9);
|
|
163
|
+
if (close === -1 || stack.length === 0) malformed();
|
|
164
|
+
addText(xml.slice(at + 9, close));
|
|
165
|
+
return close + 3;
|
|
166
|
+
};
|
|
167
|
+
const skipProcessingInstruction = (at: number): number => {
|
|
168
|
+
const close = xml.indexOf("?>", at + 2);
|
|
169
|
+
if (close === -1) malformed();
|
|
170
|
+
const target = xml.slice(at + 2, close).split(/\s/, 1)[0];
|
|
171
|
+
// The XML declaration is only legal as the very first thing in the document.
|
|
172
|
+
if (target.toLowerCase() === "xml" && at !== 0) malformed();
|
|
173
|
+
return close + 2;
|
|
174
|
+
};
|
|
175
|
+
const closeElement = (at: number): number => {
|
|
176
|
+
END_TAG.lastIndex = at;
|
|
177
|
+
const match = END_TAG.exec(xml);
|
|
178
|
+
const open = stack.pop();
|
|
179
|
+
if (!match || !open || open.qualifiedName !== match[1]) malformed();
|
|
180
|
+
return END_TAG.lastIndex;
|
|
181
|
+
};
|
|
182
|
+
const openElement = (at: number): number => {
|
|
183
|
+
START_TAG.lastIndex = at;
|
|
184
|
+
const match = START_TAG.exec(xml);
|
|
185
|
+
if (!match || (stack.length === 0 && root !== null)) malformed(); // bad tag, or a second root
|
|
186
|
+
for (const attribute of match[2].matchAll(ATTRIBUTE_VALUE)) decodeEntities(attribute[1] ?? attribute[2]);
|
|
187
|
+
const qualifiedName = match[1];
|
|
188
|
+
const element: XmlElement = {
|
|
189
|
+
name: qualifiedName.slice(qualifiedName.indexOf(":") + 1),
|
|
190
|
+
qualifiedName,
|
|
191
|
+
children: [],
|
|
192
|
+
text: "",
|
|
193
|
+
};
|
|
194
|
+
if (stack.length > 0) stack[stack.length - 1].children.push(element);
|
|
195
|
+
else root = element;
|
|
196
|
+
if (match[3] !== "/") stack.push(element);
|
|
197
|
+
return START_TAG.lastIndex;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Consume the text run before the next "<" (entities decoded inside an element,
|
|
201
|
+
// only whitespace tolerated outside the root), then dispatch on the construct.
|
|
202
|
+
const readText = (at: number): number => {
|
|
203
|
+
const nextTag = xml.indexOf("<", at);
|
|
155
204
|
const textEnd = nextTag === -1 ? xml.length : nextTag;
|
|
156
|
-
if (textEnd >
|
|
157
|
-
const raw = xml.slice(
|
|
205
|
+
if (textEnd > at) {
|
|
206
|
+
const raw = xml.slice(at, textEnd);
|
|
158
207
|
if (stack.length > 0) addText(decodeEntities(raw));
|
|
159
208
|
else if (raw.trim() !== "") malformed(); // text outside the root element
|
|
160
|
-
position = textEnd;
|
|
161
|
-
if (nextTag === -1) break;
|
|
162
209
|
}
|
|
210
|
+
return textEnd;
|
|
211
|
+
};
|
|
163
212
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const close = xml.indexOf("?>", position + 2);
|
|
175
|
-
if (close === -1) malformed();
|
|
176
|
-
const target = xml.slice(position + 2, close).split(/\s/, 1)[0];
|
|
177
|
-
// The XML declaration is only legal as the very first thing in the document.
|
|
178
|
-
if (target.toLowerCase() === "xml" && position !== 0) malformed();
|
|
179
|
-
position = close + 2;
|
|
180
|
-
} else if (xml.startsWith("<!", position)) {
|
|
181
|
-
malformed(); // any other declaration; DOCTYPE was refused before parsing
|
|
182
|
-
} else if (xml.startsWith("</", position)) {
|
|
183
|
-
END_TAG.lastIndex = position;
|
|
184
|
-
const match = END_TAG.exec(xml);
|
|
185
|
-
const open = stack.pop();
|
|
186
|
-
if (!match || !open || open.qualifiedName !== match[1]) malformed();
|
|
187
|
-
position = END_TAG.lastIndex;
|
|
188
|
-
} else {
|
|
189
|
-
START_TAG.lastIndex = position;
|
|
190
|
-
const match = START_TAG.exec(xml);
|
|
191
|
-
if (!match || (stack.length === 0 && root !== null)) malformed(); // bad tag, or a second root
|
|
192
|
-
for (const attribute of match[2].matchAll(ATTRIBUTE_VALUE)) decodeEntities(attribute[1] ?? attribute[2]);
|
|
193
|
-
const qualifiedName = match[1];
|
|
194
|
-
const element: XmlElement = {
|
|
195
|
-
name: qualifiedName.slice(qualifiedName.indexOf(":") + 1),
|
|
196
|
-
qualifiedName,
|
|
197
|
-
children: [],
|
|
198
|
-
text: "",
|
|
199
|
-
};
|
|
200
|
-
if (stack.length > 0) stack[stack.length - 1].children.push(element);
|
|
201
|
-
else root = element;
|
|
202
|
-
if (match[3] !== "/") stack.push(element);
|
|
203
|
-
position = START_TAG.lastIndex;
|
|
204
|
-
}
|
|
213
|
+
let position = 0;
|
|
214
|
+
while (position < xml.length) {
|
|
215
|
+
position = readText(position);
|
|
216
|
+
if (position >= xml.length) break;
|
|
217
|
+
if (xml.startsWith("<!--", position)) position = skipComment(position);
|
|
218
|
+
else if (xml.startsWith("<![CDATA[", position)) position = readCData(position);
|
|
219
|
+
else if (xml.startsWith("<?", position)) position = skipProcessingInstruction(position);
|
|
220
|
+
else if (xml.startsWith("<!", position)) malformed(); // any other declaration; DOCTYPE refused before parsing
|
|
221
|
+
else if (xml.startsWith("</", position)) position = closeElement(position);
|
|
222
|
+
else position = openElement(position);
|
|
205
223
|
}
|
|
206
224
|
|
|
207
225
|
if (root === null || stack.length > 0) malformed();
|
|
@@ -3,23 +3,23 @@
|
|
|
3
3
|
|
|
4
4
|
Override: drop your own src/templates/crud/form.twig to change how inputs render.
|
|
5
5
|
|
|
6
|
-
fields - one per input: { id, name, label, value, placeholder,
|
|
6
|
+
fields - one per input: { id, name, label, value, placeholder, required (bool),
|
|
7
7
|
and exactly ONE control flag: input (with type) | textarea | checkbox
|
|
8
|
-
(
|
|
9
|
-
wrap - render the <form> wrapper;
|
|
8
|
+
(checked bool) | select (options_html) }.
|
|
9
|
+
wrap - render the <form> wrapper; form_id/action/form_method set it
|
|
10
10
|
edit - add the hidden primary-key input (edit modal)
|
|
11
11
|
modal_footer- render the Cancel/Save modal buttons; submit_button - a plain Submit
|
|
12
12
|
|
|
13
13
|
No inline styles or on*= handlers: the modal buttons carry data-crud-action and
|
|
14
14
|
are wired by the page script's delegated listener (ADR-0088).
|
|
15
15
|
#}
|
|
16
|
-
{% if wrap %}<form{{
|
|
16
|
+
{% if wrap %}<form{% if form_id %} id="{{ form_id }}"{% endif %} action="{{ action }}" method="{{ form_method }}" class="needs-validation" data-crud-form="1">{% endif %}
|
|
17
17
|
{% if method_override %}<input type="hidden" name="_method" value="{{ method_override }}">{% endif %}
|
|
18
18
|
{% if edit %}<input type="hidden" id="{{ mode }}-pk-value" name="{{ pk }}">{% endif %}
|
|
19
19
|
{% if modal_footer %}<div class="crud-errors" data-crud-errors hidden></div>{% endif %}
|
|
20
20
|
{% for field in fields %}<div class="mb-3">
|
|
21
21
|
<label for="{{ field.id }}" class="form-label">{{ field.label }}</label>
|
|
22
|
-
{% if field.textarea %}<textarea class="form-control" id="{{ field.id }}" name="{{ field.name }}"{
|
|
22
|
+
{% if field.textarea %}<textarea class="form-control" id="{{ field.id }}" name="{{ field.name }}"{% if field.required %} required{% endif %}>{{ field.value }}</textarea>{% endif %}{% if field.checkbox %}<div class="form-check"><input class="form-check-input" type="checkbox" id="{{ field.id }}" name="{{ field.name }}"{% if field.checked %} checked{% endif %}></div>{% endif %}{% if field.select %}<select class="form-select" id="{{ field.id }}" name="{{ field.name }}"{% if field.required %} required{% endif %}>{{ field.options_html|raw }}</select>{% endif %}{% if field.input %}<input type="{{ field.type }}" class="form-control" id="{{ field.id }}" name="{{ field.name }}" value="{{ field.value }}" placeholder="{{ field.placeholder }}"{% if field.required %} required{% endif %}>{% endif %}
|
|
23
23
|
</div>
|
|
24
24
|
{% endfor %}
|
|
25
25
|
{% if modal_footer %}<div class="modal-footer"><button type="button" class="btn btn-secondary" data-crud-action="close" data-crud-modal="{{ mode }}">Cancel</button><button type="button" class="btn btn-primary" data-crud-action="save" data-crud-mode="{{ mode }}">Save</button></div>{% endif %}{% if submit_button %}<button type="submit" class="btn btn-primary">Submit</button>{% endif %}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
empty - true when there are no rows
|
|
12
12
|
colspan - column count + 1 (for the empty row)
|
|
13
13
|
editable - inline-edit table (Save/Delete); readonly - view table (Edit/Delete)
|
|
14
|
-
table_name,
|
|
14
|
+
table_name, table_id, inline_script
|
|
15
15
|
|
|
16
16
|
Alignment: each header/cell carries a tina4-css class (text-start for text,
|
|
17
17
|
text-end for numbers). Actions is always text-end. No inline styles (ADR-0088).
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
re-renders <tbody data-crud-body> over fetch() as the user searches/sorts/pages.
|
|
21
21
|
#}
|
|
22
22
|
<div class="table-responsive">
|
|
23
|
-
<table class="table table-striped table-hover"{{
|
|
23
|
+
<table class="table table-striped table-hover"{% if table_id %} id="{{ table_id }}"{% endif %}>
|
|
24
24
|
<thead class="table-dark"><tr>
|
|
25
25
|
{% for header in headers %}{% if header.sortable %}<th class="{{ header.align }}"><a class="sort-link" href="{{ header.url }}" data-crud-sort="{{ header.col }}" data-crud-dir="{{ header.next_dir }}">{{ header.label }}{{ header.indicator|raw }}</a></th>{% endif %}{% if header.plain %}<th class="{{ header.align }}">{{ header.label }}</th>{% endif %}{% endfor %}<th class="text-end">Actions</th>
|
|
26
26
|
</tr></thead>
|
|
@@ -8,15 +8,16 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
8
8
|
|
|
9
9
|
{% extends "errors/base.twig" %}
|
|
10
10
|
{% block title %}Redirecting…{% endblock %}
|
|
11
|
-
{% block meta %}<meta http-equiv="refresh" content="0;url={{
|
|
11
|
+
{% block meta %}{% set p1 = redirect_url|slice(0,1) %}{% set p2 = redirect_url|slice(0,2) %}{% set p7 = redirect_url|slice(0,7) %}{% set p8 = redirect_url|slice(0,8) %}{% set rel = (p1 == '/') ? ((p2 == '//') ? 'no' : 'yes') : 'no' %}{% set safe_redirect_url = (rel == 'yes' or p7 == 'http://' or p8 == 'https://') ? redirect_url : '/' %}<meta http-equiv="refresh" content="0;url={{ safe_redirect_url }}">{% endblock %}
|
|
12
12
|
{% block extra_styles %}
|
|
13
13
|
.spinner { display: inline-block; width: 1.5rem; height: 1.5rem; border: 3px solid var(--border); border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; margin-bottom: 1rem; }
|
|
14
14
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
15
15
|
{% endblock %}
|
|
16
16
|
{% block content %}
|
|
17
|
+
{% set p1 = redirect_url|slice(0,1) %}{% set p2 = redirect_url|slice(0,2) %}{% set p7 = redirect_url|slice(0,7) %}{% set p8 = redirect_url|slice(0,8) %}{% set rel = (p1 == '/') ? ((p2 == '//') ? 'no' : 'yes') : 'no' %}{% set safe_redirect_url = (rel == 'yes' or p7 == 'http://' or p8 == 'https://') ? redirect_url : '/' %}
|
|
17
18
|
<div class="spinner"></div>
|
|
18
19
|
<div class="error-title">Redirecting…</div>
|
|
19
20
|
<div class="error-msg">You are being redirected to a new location.</div>
|
|
20
|
-
<div class="error-path c-primary">{{
|
|
21
|
-
<a href="{{
|
|
21
|
+
<div class="error-path c-primary">{{ safe_redirect_url }}</div>
|
|
22
|
+
<a href="{{ safe_redirect_url }}" class="error-home">Click here if not redirected</a>
|
|
22
23
|
{% endblock %}
|