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.
Files changed (60) hide show
  1. package/CLAUDE.md +2 -2
  2. package/package.json +5 -9
  3. package/packages/cli/dist/bin.js +1074 -521
  4. package/packages/core/dist/index.js +1071 -516
  5. package/packages/core/public/bundled-component-provenance.json +2 -2
  6. package/packages/core/public/js/tina4-dev-admin-split.html +202 -0
  7. package/packages/core/public/js/tina4-dev-admin.min.js +147 -138
  8. package/packages/core/src/aiClient.ts +2 -1
  9. package/packages/core/src/api.ts +2 -1
  10. package/packages/core/src/auth.ts +1 -1
  11. package/packages/core/src/cache.ts +6 -1
  12. package/packages/core/src/devAdmin.ts +209 -123
  13. package/packages/core/src/dispatchPipeline.ts +4 -0
  14. package/packages/core/src/docs.ts +5 -3
  15. package/packages/core/src/errorOverlay.ts +18 -9
  16. package/packages/core/src/index.ts +2 -1
  17. package/packages/core/src/mcp.ts +53 -16
  18. package/packages/core/src/messenger.ts +22 -30
  19. package/packages/core/src/metrics.ts +5 -1
  20. package/packages/core/src/middleware.ts +8 -1
  21. package/packages/core/src/optionalPackage.ts +2 -2
  22. package/packages/core/src/plan.ts +4 -4
  23. package/packages/core/src/push.ts +1 -1
  24. package/packages/core/src/request.ts +185 -25
  25. package/packages/core/src/router.ts +3 -2
  26. package/packages/core/src/server.ts +32 -10
  27. package/packages/core/src/session.ts +44 -0
  28. package/packages/core/src/slashTrim.ts +39 -0
  29. package/packages/core/src/wsdl.ts +66 -48
  30. package/packages/core/templates/crud/form.twig +5 -5
  31. package/packages/core/templates/crud/table.twig +2 -2
  32. package/packages/core/templates/errors/302.twig +4 -3
  33. package/packages/frond/dist/index.js +206 -64
  34. package/packages/frond/src/engine.ts +278 -69
  35. package/packages/frond/src/index.ts +1 -1
  36. package/packages/orm/dist/index.js +1055 -502
  37. package/packages/orm/src/crudRender.ts +5 -5
  38. package/packages/orm/src/crudTable.ts +1 -1
  39. package/packages/orm/src/database.ts +15 -29
  40. package/packages/orm/src/databaseResult.ts +11 -1
  41. package/packages/orm/src/realtime/index.ts +1 -0
  42. package/packages/orm/src/realtime/realtime.ts +2 -1
  43. package/packages/orm/src/realtime/s3Client.ts +276 -0
  44. package/packages/orm/src/realtime/storage.ts +19 -60
  45. package/packages/orm/src/sqlTranslator.ts +58 -42
  46. package/packages/swagger/dist/index.js +25 -4
  47. package/packages/swagger/src/generator.ts +7 -4
  48. package/packages/swagger/src/ui.ts +2 -1
  49. package/types/core/src/devAdmin.d.ts +40 -0
  50. package/types/core/src/index.d.ts +2 -1
  51. package/types/core/src/middleware.d.ts +1 -0
  52. package/types/core/src/optionalPackage.d.ts +2 -2
  53. package/types/core/src/request.d.ts +5 -1
  54. package/types/core/src/session.d.ts +25 -0
  55. package/types/core/src/slashTrim.d.ts +6 -0
  56. package/types/frond/src/engine.d.ts +11 -0
  57. package/types/frond/src/index.d.ts +1 -1
  58. package/types/orm/src/realtime/index.d.ts +1 -0
  59. package/types/orm/src/realtime/s3Client.d.ts +48 -0
  60. 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, exec } from "node:child_process";
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
- // exec imported at top of file (ESM)
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") exec(`open ${url}`);
404
- else if (process.platform === "win32") exec(`start "" "${url}"`);
405
- else exec(`xdg-open ${url}`);
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.replace(/^\/+/, "").replace(/\/+$/, "") || "index";
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
- const argMatch = fnStr.match(/^(?:async\s*)?(?:function\s*\w*)?\s*\(([^)]*)\)/);
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, () => dispatchInner(ctx, rawReq, rawRes, requestId)),
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
- while (position < xml.length) {
154
- const nextTag = xml.indexOf("<", position);
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 > position) {
157
- const raw = xml.slice(position, textEnd);
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
- if (xml.startsWith("<!--", position)) {
165
- const close = xml.indexOf("-->", position + 4);
166
- if (close === -1) malformed();
167
- position = close + 3;
168
- } else if (xml.startsWith("<![CDATA[", position)) {
169
- const close = xml.indexOf("]]>", position + 9);
170
- if (close === -1 || stack.length === 0) malformed();
171
- addText(xml.slice(position + 9, close));
172
- position = close + 3;
173
- } else if (xml.startsWith("<?", position)) {
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, required_attr,
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
- (checked_attr) | select (options_html) }.
9
- wrap - render the <form> wrapper; form_id_attr/action/form_method set it
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{{ form_id_attr|raw }} action="{{ action }}" method="{{ form_method }}" class="needs-validation" data-crud-form="1">{% endif %}
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 }}"{{ field.required_attr|raw }}>{{ field.value }}</textarea>{% endif %}{% if field.checkbox %}<div class="form-check"><input class="form-check-input" type="checkbox" id="{{ field.id }}" name="{{ field.name }}"{{ field.checked_attr|raw }}></div>{% endif %}{% if field.select %}<select class="form-select" id="{{ field.id }}" name="{{ field.name }}"{{ field.required_attr|raw }}>{{ 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 }}"{{ field.required_attr|raw }}>{% endif %}
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, table_id_attr, inline_script
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"{{ table_id_attr|raw }}>
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={{ redirect_url }}">{% endblock %}
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">{{ redirect_url }}</div>
21
- <a href="{{ redirect_url }}" class="error-home">Click here if not redirected</a>
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 %}