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
|
@@ -73,13 +73,13 @@ function renderModalForm(mode: string, columns: string[], pk: string, edit: bool
|
|
|
73
73
|
value: "",
|
|
74
74
|
placeholder: h(`Enter ${label.toLowerCase()}`),
|
|
75
75
|
type: "text",
|
|
76
|
-
|
|
76
|
+
required: false,
|
|
77
77
|
input: true,
|
|
78
78
|
};
|
|
79
79
|
});
|
|
80
80
|
return renderCrudTemplate("form", {
|
|
81
81
|
wrap: true,
|
|
82
|
-
|
|
82
|
+
form_id: `form-${mode}`,
|
|
83
83
|
action: "",
|
|
84
84
|
form_method: "POST",
|
|
85
85
|
method_override: null,
|
|
@@ -150,7 +150,7 @@ function fieldInputShape(field: FormFieldDef, base: Record<string, unknown>): Re
|
|
|
150
150
|
case "text":
|
|
151
151
|
return { ...base, textarea: true };
|
|
152
152
|
case "boolean":
|
|
153
|
-
return { ...base, checkbox: true,
|
|
153
|
+
return { ...base, checkbox: true, checked: !!value };
|
|
154
154
|
case "select":
|
|
155
155
|
return { ...base, select: true, options_html: buildOptions(field.options, value) };
|
|
156
156
|
case "date":
|
|
@@ -174,7 +174,7 @@ function buildCustomField(field: FormFieldDef): Record<string, unknown> {
|
|
|
174
174
|
label: h(String(label)),
|
|
175
175
|
value: h(field.value ?? ""),
|
|
176
176
|
placeholder: "",
|
|
177
|
-
|
|
177
|
+
required: !!field.required,
|
|
178
178
|
};
|
|
179
179
|
return fieldInputShape(field, base);
|
|
180
180
|
}
|
|
@@ -190,7 +190,7 @@ export function generateForm(
|
|
|
190
190
|
const verb = String(options.method ?? "POST").toUpperCase();
|
|
191
191
|
return renderCrudTemplate("form", {
|
|
192
192
|
wrap: true,
|
|
193
|
-
|
|
193
|
+
form_id: "",
|
|
194
194
|
action: h(options.action ?? "/"),
|
|
195
195
|
form_method: h(verb),
|
|
196
196
|
method_override: ["PUT", "PATCH", "DELETE"].includes(verb) ? verb : null,
|
|
@@ -165,7 +165,7 @@ export function tableData(opts: TableDataOpts): Record<string, unknown> {
|
|
|
165
165
|
readonly: !opts.editable,
|
|
166
166
|
inline_script: opts.inlineScript,
|
|
167
167
|
table_name: h(opts.tableName),
|
|
168
|
-
|
|
168
|
+
table_id: opts.tableId ? String(opts.tableId) : "",
|
|
169
169
|
};
|
|
170
170
|
}
|
|
171
171
|
|
|
@@ -27,7 +27,7 @@ import { quoteIdentifierAnsi, assertColumnNames } from "./adapters/sqlDialect.js
|
|
|
27
27
|
*/
|
|
28
28
|
export function stripTrailingSemicolons(sql: string): string {
|
|
29
29
|
if (!sql) return sql;
|
|
30
|
-
let stripped = sql.
|
|
30
|
+
let stripped = sql.trimEnd();
|
|
31
31
|
while (stripped.endsWith(";")) {
|
|
32
32
|
stripped = stripped.slice(0, -1).replace(/\s+$/, "");
|
|
33
33
|
}
|
|
@@ -1800,6 +1800,16 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
|
|
|
1800
1800
|
async function buildAdapterFromUrl(url: string, username?: string, password?: string): Promise<DatabaseAdapter> {
|
|
1801
1801
|
const parsed = parseDatabaseUrl(url, username, password);
|
|
1802
1802
|
|
|
1803
|
+
// The four TCP engines (postgres/mysql/mssql/firebird) take the identical
|
|
1804
|
+
// connection shape from the parsed URL; build it once instead of per case.
|
|
1805
|
+
const serverConfig = {
|
|
1806
|
+
host: parsed.host ?? undefined,
|
|
1807
|
+
port: parsed.port ?? undefined,
|
|
1808
|
+
user: parsed.username ?? undefined,
|
|
1809
|
+
password: parsed.password ?? undefined,
|
|
1810
|
+
database: parsed.database || undefined,
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1803
1813
|
switch (parsed.engine) {
|
|
1804
1814
|
case "sqlite": {
|
|
1805
1815
|
const { SQLiteAdapter } = await import("./adapters/sqlite.js");
|
|
@@ -1807,49 +1817,25 @@ async function buildAdapterFromUrl(url: string, username?: string, password?: st
|
|
|
1807
1817
|
}
|
|
1808
1818
|
case "postgres": {
|
|
1809
1819
|
const { PostgresAdapter } = await import("./adapters/postgres.js");
|
|
1810
|
-
const adapter = new PostgresAdapter(
|
|
1811
|
-
host: parsed.host ?? undefined,
|
|
1812
|
-
port: parsed.port ?? undefined,
|
|
1813
|
-
user: parsed.username ?? undefined,
|
|
1814
|
-
password: parsed.password ?? undefined,
|
|
1815
|
-
database: parsed.database || undefined,
|
|
1816
|
-
});
|
|
1820
|
+
const adapter = new PostgresAdapter(serverConfig);
|
|
1817
1821
|
await adapter.connect();
|
|
1818
1822
|
return adapter;
|
|
1819
1823
|
}
|
|
1820
1824
|
case "mysql": {
|
|
1821
1825
|
const { MysqlAdapter } = await import("./adapters/mysql.js");
|
|
1822
|
-
const adapter = new MysqlAdapter(
|
|
1823
|
-
host: parsed.host ?? undefined,
|
|
1824
|
-
port: parsed.port ?? undefined,
|
|
1825
|
-
user: parsed.username ?? undefined,
|
|
1826
|
-
password: parsed.password ?? undefined,
|
|
1827
|
-
database: parsed.database || undefined,
|
|
1828
|
-
});
|
|
1826
|
+
const adapter = new MysqlAdapter(serverConfig);
|
|
1829
1827
|
await adapter.connect();
|
|
1830
1828
|
return adapter;
|
|
1831
1829
|
}
|
|
1832
1830
|
case "mssql": {
|
|
1833
1831
|
const { MssqlAdapter } = await import("./adapters/mssql.js");
|
|
1834
|
-
const adapter = new MssqlAdapter(
|
|
1835
|
-
host: parsed.host ?? undefined,
|
|
1836
|
-
port: parsed.port ?? undefined,
|
|
1837
|
-
user: parsed.username ?? undefined,
|
|
1838
|
-
password: parsed.password ?? undefined,
|
|
1839
|
-
database: parsed.database || undefined,
|
|
1840
|
-
});
|
|
1832
|
+
const adapter = new MssqlAdapter(serverConfig);
|
|
1841
1833
|
await adapter.connect();
|
|
1842
1834
|
return adapter;
|
|
1843
1835
|
}
|
|
1844
1836
|
case "firebird": {
|
|
1845
1837
|
const { FirebirdAdapter } = await import("./adapters/firebird.js");
|
|
1846
|
-
const adapter = new FirebirdAdapter(
|
|
1847
|
-
host: parsed.host ?? undefined,
|
|
1848
|
-
port: parsed.port ?? undefined,
|
|
1849
|
-
user: parsed.username ?? undefined,
|
|
1850
|
-
password: parsed.password ?? undefined,
|
|
1851
|
-
database: parsed.database || undefined,
|
|
1852
|
-
});
|
|
1838
|
+
const adapter = new FirebirdAdapter(serverConfig);
|
|
1853
1839
|
await adapter.connect();
|
|
1854
1840
|
return adapter;
|
|
1855
1841
|
}
|
|
@@ -8,6 +8,16 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
8
8
|
|
|
9
9
|
import type { DatabaseAdapter, ColumnInfo } from "./types.js";
|
|
10
10
|
|
|
11
|
+
// Strip the "(args)" from a column type ("VARCHAR(255)" -> "VARCHAR",
|
|
12
|
+
// "INT(11) UNSIGNED" -> "INT UNSIGNED"). ReDoS-safe: indexOf/slice, not the old
|
|
13
|
+
// /\([^)]*\)/ which backtracks O(n^2) across many '(' (js/polynomial-redos).
|
|
14
|
+
function stripTypeArgs(colType: string): string {
|
|
15
|
+
const open = colType.indexOf("(");
|
|
16
|
+
if (open < 0) return colType;
|
|
17
|
+
const close = colType.indexOf(")", open);
|
|
18
|
+
return close < 0 ? colType : colType.slice(0, open) + colType.slice(close + 1);
|
|
19
|
+
}
|
|
20
|
+
|
|
11
21
|
/** Column metadata returned by columnInfo(). */
|
|
12
22
|
export interface ColumnInfoResult {
|
|
13
23
|
name: string;
|
|
@@ -261,7 +271,7 @@ export class DatabaseResult implements Iterable<Record<string, unknown>> {
|
|
|
261
271
|
const [size, decimals] = this._parseTypeSize(colType);
|
|
262
272
|
return {
|
|
263
273
|
name: col.name,
|
|
264
|
-
type: colType
|
|
274
|
+
type: stripTypeArgs(colType),
|
|
265
275
|
size,
|
|
266
276
|
decimals,
|
|
267
277
|
nullable: col.nullable ?? true,
|
|
@@ -14,6 +14,7 @@ export {
|
|
|
14
14
|
storageKey,
|
|
15
15
|
type StorageBackend,
|
|
16
16
|
} from "./storage.js";
|
|
17
|
+
export { S3Client, S3Error, S3ConfigError, uriEncode } from "./s3Client.js";
|
|
17
18
|
export { default as Workspace } from "./models/workspace.js";
|
|
18
19
|
export { default as Channel } from "./models/channel.js";
|
|
19
20
|
export { default as ChannelMember } from "./models/channelMember.js";
|
|
@@ -7,6 +7,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { createHmac } from "node:crypto";
|
|
10
|
+
import { trimSlashes } from "../../../core/src/slashTrim.js";
|
|
10
11
|
import {
|
|
11
12
|
Router,
|
|
12
13
|
HTTP_OK,
|
|
@@ -87,7 +88,7 @@ let _authorize: RealtimeOptions["authorize"] | undefined;
|
|
|
87
88
|
*/
|
|
88
89
|
export async function realtime(options: RealtimeOptions = {}): Promise<Record<string, string>> {
|
|
89
90
|
const features = options.features ?? ["calls"];
|
|
90
|
-
const raw = (options.prefix ?? "")
|
|
91
|
+
const raw = trimSlashes(options.prefix ?? "");
|
|
91
92
|
const p = raw ? `/${raw}` : "";
|
|
92
93
|
_authorize = options.authorize;
|
|
93
94
|
|
|
@@ -0,0 +1,276 @@
|
|
|
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
|
+
import { createHash, createHmac } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Zero-dependency S3 client (AWS Signature Version 4) on the Node standard
|
|
13
|
+
* library — node:crypto for signing, global fetch for transport. Covers exactly
|
|
14
|
+
* what the realtime S3Storage backend needs (PUT, GET, DELETE, HEAD, a presigned
|
|
15
|
+
* GET URL, and create-bucket for provisioning) so the `s3` backend no longer
|
|
16
|
+
* needs @aws-sdk/client-s3 or @aws-sdk/s3-request-presigner.
|
|
17
|
+
*
|
|
18
|
+
* Path-style addressing (<endpoint>/<bucket>/<key>), which S3-compatible servers
|
|
19
|
+
* (MinIO, Garage) require and AWS accepts; with no endpoint it targets
|
|
20
|
+
* https://s3.<region>.amazonaws.com. SigV4 is HMAC-SHA256 canonical signing —
|
|
21
|
+
* the same primitive Tina4's JWT already uses (node:crypto); nothing here is
|
|
22
|
+
* reinvented crypto. Mirrors the Python master, proven against AWS's published
|
|
23
|
+
* SigV4 test vector.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const ALGORITHM = "AWS4-HMAC-SHA256";
|
|
27
|
+
const SERVICE = "s3";
|
|
28
|
+
const UNRESERVED = /[A-Za-z0-9\-_.~]/;
|
|
29
|
+
|
|
30
|
+
// Retry policy, as for the AI client (ADR-0053): HTTP 429, any 5xx (S3's
|
|
31
|
+
// SlowDown is a 503) and a dropped connection are sent again, a bounded number
|
|
32
|
+
// of times, honouring Retry-After. Every operation here is idempotent.
|
|
33
|
+
const MAX_RETRIES = 2;
|
|
34
|
+
const BACKOFF_BASE = 0.5; // seconds; retry n waits up to base * 2**n, jittered
|
|
35
|
+
const BACKOFF_CAP = 20.0;
|
|
36
|
+
|
|
37
|
+
/** An S3 request returned a non-2xx status; `status` holds the HTTP code. */
|
|
38
|
+
export class S3Error extends Error {
|
|
39
|
+
/** Seconds from a Retry-After header in its delta-seconds form, else null. */
|
|
40
|
+
retryAfter: number | null = null;
|
|
41
|
+
constructor(public readonly status: number, message: string) {
|
|
42
|
+
super(message);
|
|
43
|
+
this.name = "S3Error";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The client cannot sign a request: no access key or secret was given. */
|
|
48
|
+
export class S3ConfigError extends Error {
|
|
49
|
+
constructor(message: string) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "S3ConfigError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Seconds from a Retry-After header in its delta-seconds form, else null. */
|
|
56
|
+
function retryAfterSeconds(raw: string | null): number | null {
|
|
57
|
+
if (raw == null) return null;
|
|
58
|
+
const n = Number(raw);
|
|
59
|
+
return Number.isFinite(n) ? Math.max(0, n) : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* SigV4 canonical encoding: keep the RFC 3986 unreserved set literal; keep "/"
|
|
66
|
+
* in a path and encode it in a query; every other byte becomes %XX (upper-case).
|
|
67
|
+
*/
|
|
68
|
+
export function uriEncode(value: string, isPath: boolean): string {
|
|
69
|
+
let out = "";
|
|
70
|
+
for (const byte of Buffer.from(value, "utf8")) {
|
|
71
|
+
const ch = String.fromCharCode(byte);
|
|
72
|
+
if (UNRESERVED.test(ch) || (isPath && ch === "/")) out += ch;
|
|
73
|
+
else out += "%" + byte.toString(16).toUpperCase().padStart(2, "0");
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sha256Hex(data: Buffer | string): string {
|
|
79
|
+
return createHash("sha256").update(data).digest("hex");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hmac(key: Buffer | string, msg: string): Buffer {
|
|
83
|
+
return createHmac("sha256", key).update(msg, "utf8").digest();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class S3Client {
|
|
87
|
+
private readonly scheme: string;
|
|
88
|
+
private readonly host: string; // authority (host[:port]) — matches the Host header
|
|
89
|
+
private readonly prefix: string; // path an endpoint carries (https://host/s3 -> "/s3"), "" if none
|
|
90
|
+
private readonly region: string;
|
|
91
|
+
private readonly accessKey: string;
|
|
92
|
+
private readonly secretKey: string;
|
|
93
|
+
private readonly timeout: number;
|
|
94
|
+
|
|
95
|
+
constructor(opts: {
|
|
96
|
+
endpoint?: string;
|
|
97
|
+
accessKey?: string;
|
|
98
|
+
secretKey?: string;
|
|
99
|
+
region?: string;
|
|
100
|
+
timeout?: number;
|
|
101
|
+
} = {}) {
|
|
102
|
+
this.region = opts.region || "us-east-1";
|
|
103
|
+
this.accessKey = opts.accessKey || "";
|
|
104
|
+
this.secretKey = opts.secretKey || "";
|
|
105
|
+
this.timeout = opts.timeout ?? 30000;
|
|
106
|
+
let base = opts.endpoint || `https://s3.${this.region}.amazonaws.com`;
|
|
107
|
+
while (base.endsWith("/")) base = base.slice(0, -1); // strip trailing slashes without a regex
|
|
108
|
+
// Split scheme :// authority [ / path ]. The authority alone is the Host
|
|
109
|
+
// header and the signed `host:`; an endpoint behind a path (https://host/s3)
|
|
110
|
+
// keeps that path in front of every request path, and in what is signed.
|
|
111
|
+
const m = /^([a-z][a-z0-9+.-]*):\/\/([^/]+)(\/.*)?$/i.exec(base);
|
|
112
|
+
this.scheme = m ? m[1] : "https";
|
|
113
|
+
this.host = m ? m[2] : base;
|
|
114
|
+
let p = m && m[3] ? m[3] : "";
|
|
115
|
+
while (p.endsWith("/")) p = p.slice(0, -1);
|
|
116
|
+
this.prefix = p;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// -- public API (the subset S3Storage uses) --------------------------------
|
|
120
|
+
|
|
121
|
+
async putObject(bucket: string, key: string, data: Buffer | string, contentType = "application/octet-stream"): Promise<void> {
|
|
122
|
+
const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
123
|
+
await this.request("PUT", this.canonicalUri(bucket, key), body, { "content-type": contentType });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async getObject(bucket: string, key: string): Promise<Buffer> {
|
|
127
|
+
return (await this.request("GET", this.canonicalUri(bucket, key), Buffer.alloc(0))).body;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async deleteObject(bucket: string, key: string): Promise<void> {
|
|
131
|
+
await this.request("DELETE", this.canonicalUri(bucket, key), Buffer.alloc(0));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async headObject(bucket: string, key: string): Promise<void> {
|
|
135
|
+
await this.request("HEAD", this.canonicalUri(bucket, key), Buffer.alloc(0));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Create the bucket (path-style `PUT /<bucket>`); an already-owned bucket (409) is success. */
|
|
139
|
+
async createBucket(bucket: string): Promise<void> {
|
|
140
|
+
try {
|
|
141
|
+
await this.request("PUT", `${this.prefix}/${uriEncode(bucket, true)}`, Buffer.alloc(0));
|
|
142
|
+
} catch (e) {
|
|
143
|
+
if (!(e instanceof S3Error) || e.status !== 409) throw e;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A URL that fetches the object directly, signed via SigV4 query params. */
|
|
148
|
+
presignedGetUrl(bucket: string, key: string, expires = 3600): string {
|
|
149
|
+
this.requireCredentials();
|
|
150
|
+
const { amzDate, dateStamp } = this.now();
|
|
151
|
+
const uri = this.canonicalUri(bucket, key);
|
|
152
|
+
const scope = `${dateStamp}/${this.region}/${SERVICE}/aws4_request`;
|
|
153
|
+
const query: Record<string, string> = {
|
|
154
|
+
"X-Amz-Algorithm": ALGORITHM,
|
|
155
|
+
"X-Amz-Credential": `${this.accessKey}/${scope}`,
|
|
156
|
+
"X-Amz-Date": amzDate,
|
|
157
|
+
"X-Amz-Expires": String(expires),
|
|
158
|
+
"X-Amz-SignedHeaders": "host",
|
|
159
|
+
};
|
|
160
|
+
const canonicalQs = this.canonicalQuery(query);
|
|
161
|
+
const canonicalRequest = ["GET", uri, canonicalQs, `host:${this.host}\n`, "host", "UNSIGNED-PAYLOAD"].join("\n");
|
|
162
|
+
const signature = this.sign(canonicalRequest, amzDate, dateStamp, scope);
|
|
163
|
+
return `${this.scheme}://${this.host}${uri}?${canonicalQs}&X-Amz-Signature=${signature}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// -- signing + transport ----------------------------------------------------
|
|
167
|
+
|
|
168
|
+
private requireCredentials(): void {
|
|
169
|
+
// Signing with an empty key only earns a 403 from the server, which says
|
|
170
|
+
// nothing about the cause. Name the settings instead, before sending.
|
|
171
|
+
if (!this.accessKey || !this.secretKey) {
|
|
172
|
+
throw new S3ConfigError(
|
|
173
|
+
"S3 credentials are not set: set TINA4_STORAGE_KEY and TINA4_STORAGE_SECRET",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private now(): { amzDate: string; dateStamp: string } {
|
|
179
|
+
const iso = new Date().toISOString().replace(/[:-]|\.\d{3}/g, "");
|
|
180
|
+
return { amzDate: iso, dateStamp: iso.slice(0, 8) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private canonicalUri(bucket: string, key: string): string {
|
|
184
|
+
return `${this.prefix}/${uriEncode(bucket, true)}/${uriEncode(key, true)}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private canonicalQuery(query: Record<string, string>): string {
|
|
188
|
+
return Object.keys(query)
|
|
189
|
+
.sort()
|
|
190
|
+
.map((k) => `${uriEncode(k, false)}=${uriEncode(query[k], false)}`)
|
|
191
|
+
.join("&");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private signingKey(dateStamp: string): Buffer {
|
|
195
|
+
const kDate = hmac(`AWS4${this.secretKey}`, dateStamp);
|
|
196
|
+
const kRegion = hmac(kDate, this.region);
|
|
197
|
+
const kService = hmac(kRegion, SERVICE);
|
|
198
|
+
return hmac(kService, "aws4_request");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private sign(canonicalRequest: string, amzDate: string, dateStamp: string, scope: string): string {
|
|
202
|
+
const stringToSign = [ALGORITHM, amzDate, scope, sha256Hex(canonicalRequest)].join("\n");
|
|
203
|
+
return createHmac("sha256", this.signingKey(dateStamp)).update(stringToSign, "utf8").digest("hex");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async request(
|
|
207
|
+
method: string,
|
|
208
|
+
uri: string,
|
|
209
|
+
body: Buffer,
|
|
210
|
+
extraHeaders: Record<string, string> = {},
|
|
211
|
+
): Promise<{ status: number; body: Buffer }> {
|
|
212
|
+
this.requireCredentials();
|
|
213
|
+
for (let attempt = 0; ; attempt++) {
|
|
214
|
+
const last = attempt === MAX_RETRIES;
|
|
215
|
+
let delay: number | null = null;
|
|
216
|
+
try {
|
|
217
|
+
// Signed afresh each attempt: x-amz-date moves on.
|
|
218
|
+
return await this.send(method, uri, body, extraHeaders);
|
|
219
|
+
} catch (e) {
|
|
220
|
+
if (e instanceof S3Error) {
|
|
221
|
+
if (last || !(e.status === 429 || e.status >= 500)) throw e;
|
|
222
|
+
delay = e.retryAfter;
|
|
223
|
+
} else {
|
|
224
|
+
// Connection refused or dropped, timeout: nothing was answered.
|
|
225
|
+
if (last) throw e;
|
|
226
|
+
delay = null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (delay == null) delay = Math.random() * (BACKOFF_BASE * 2 ** attempt);
|
|
230
|
+
await sleep(Math.min(delay, BACKOFF_CAP) * 1000);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private async send(
|
|
235
|
+
method: string,
|
|
236
|
+
uri: string,
|
|
237
|
+
body: Buffer,
|
|
238
|
+
extraHeaders: Record<string, string> = {},
|
|
239
|
+
): Promise<{ status: number; body: Buffer }> {
|
|
240
|
+
const { amzDate, dateStamp } = this.now();
|
|
241
|
+
const payloadHash = sha256Hex(body);
|
|
242
|
+
|
|
243
|
+
const signed: Record<string, string> = {
|
|
244
|
+
host: this.host,
|
|
245
|
+
"x-amz-content-sha256": payloadHash,
|
|
246
|
+
"x-amz-date": amzDate,
|
|
247
|
+
};
|
|
248
|
+
const signedKeys = Object.keys(signed).sort();
|
|
249
|
+
const canonicalHeaders = signedKeys.map((k) => `${k}:${signed[k]}\n`).join("");
|
|
250
|
+
const signedHeaders = signedKeys.join(";");
|
|
251
|
+
const canonicalRequest = [method, uri, "", canonicalHeaders, signedHeaders, payloadHash].join("\n");
|
|
252
|
+
const scope = `${dateStamp}/${this.region}/${SERVICE}/aws4_request`;
|
|
253
|
+
const signature = this.sign(canonicalRequest, amzDate, dateStamp, scope);
|
|
254
|
+
const authorization = `${ALGORITHM} Credential=${this.accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
255
|
+
|
|
256
|
+
const headers: Record<string, string> = {
|
|
257
|
+
Authorization: authorization,
|
|
258
|
+
"x-amz-content-sha256": payloadHash,
|
|
259
|
+
"x-amz-date": amzDate,
|
|
260
|
+
...extraHeaders,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const url = `${this.scheme}://${this.host}${uri}`;
|
|
264
|
+
const init: RequestInit = { method, headers, redirect: "manual" };
|
|
265
|
+
if (method === "PUT" || method === "POST") init.body = body;
|
|
266
|
+
if (AbortSignal.timeout) init.signal = AbortSignal.timeout(this.timeout);
|
|
267
|
+
|
|
268
|
+
const response = await fetch(url, init);
|
|
269
|
+
if (response.status < 200 || response.status >= 300) {
|
|
270
|
+
const err = new S3Error(response.status, `${method} ${uri}: HTTP ${response.status}`);
|
|
271
|
+
err.retryAfter = retryAfterSeconds(response.headers.get("retry-after"));
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
return { status: response.status, body: Buffer.from(await response.arrayBuffer()) };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -9,38 +9,15 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
|
9
9
|
import { randomBytes } from "node:crypto";
|
|
10
10
|
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from "node:fs";
|
|
11
11
|
import { resolve, sep } from "node:path";
|
|
12
|
-
import { createRequire } from "node:module";
|
|
13
12
|
import { Log } from "../../../core/src/index.js";
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
// ESM has no global `require`; create one so the OPTIONAL aws-sdk can be loaded
|
|
17
|
-
// synchronously in S3Storage's constructor.
|
|
18
|
-
const require = createRequire(import.meta.url);
|
|
19
|
-
|
|
20
|
-
/** S3 needs both SDK packages, so a missing one names the command for both. */
|
|
21
|
-
const S3_PACKAGES = ["@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner"];
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Load an @aws-sdk package (an optional peer, ADR-0067). A missing one throws
|
|
25
|
-
* the actionable install error; selectStorage() catches it and logs it while
|
|
26
|
-
* degrading to local storage. Any OTHER load failure is re-thrown untouched,
|
|
27
|
-
* so an installed-but-broken SDK is never mislabelled "not installed".
|
|
28
|
-
*/
|
|
29
|
-
function loadAwsSdk(packageName: string): any {
|
|
30
|
-
try {
|
|
31
|
-
return require(packageName);
|
|
32
|
-
} catch (error: any) {
|
|
33
|
-
const missing = error?.code === "MODULE_NOT_FOUND" && String(error?.message ?? "").includes(`'${packageName}'`);
|
|
34
|
-
if (!missing) throw error;
|
|
35
|
-
throw optionalPackageMissing("S3Storage", S3_PACKAGES);
|
|
36
|
-
}
|
|
37
|
-
}
|
|
13
|
+
import { S3Client } from "./s3Client.js";
|
|
38
14
|
|
|
39
15
|
/**
|
|
40
16
|
* Pluggable file storage for the realtime "files" feature. StorageBackend is
|
|
41
17
|
* the interface; LocalStorage (default, zero-dependency, filesystem) and
|
|
42
|
-
* S3Storage (opt-in, S3-compatible
|
|
43
|
-
*
|
|
18
|
+
* S3Storage (opt-in, S3-compatible, also zero-dependency — it uses Tina4's own
|
|
19
|
+
* stdlib SigV4 client, not @aws-sdk) are the shipped implementations.
|
|
20
|
+
* Selection mirrors the cache/session/queue backend pattern:
|
|
44
21
|
* TINA4_STORAGE_BACKEND (local default | s3) plus per-backend env vars, with a
|
|
45
22
|
* graceful fallback to local if an s3 backend cannot be constructed - a real
|
|
46
23
|
* persistent store, never a silent no-op. Parity with Python's storage.py.
|
|
@@ -124,63 +101,46 @@ export class LocalStorage implements StorageBackend {
|
|
|
124
101
|
}
|
|
125
102
|
|
|
126
103
|
/**
|
|
127
|
-
* S3-compatible store (AWS S3, MinIO, ...). Opt-in
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
104
|
+
* S3-compatible store (AWS S3, MinIO, Garage, ...). Opt-in, zero dependencies.
|
|
105
|
+
*
|
|
106
|
+
* Talks to S3 through Tina4's own stdlib SigV4 client (S3Client, node:crypto +
|
|
107
|
+
* global fetch) — no @aws-sdk packages. Presigned GET URLs let clients fetch
|
|
108
|
+
* large blobs straight from object storage instead of streaming through the app.
|
|
131
109
|
*/
|
|
132
110
|
export class S3Storage implements StorageBackend {
|
|
133
|
-
private client:
|
|
111
|
+
private client: S3Client;
|
|
134
112
|
private bucket: string;
|
|
135
113
|
|
|
136
114
|
constructor(opts: { endpoint?: string; key?: string; secret?: string; bucket?: string; region?: string } = {}) {
|
|
137
115
|
this.bucket = opts.bucket || process.env.TINA4_STORAGE_BUCKET || "";
|
|
138
116
|
if (!this.bucket) throw new Error("S3Storage requires TINA4_STORAGE_BUCKET");
|
|
139
|
-
// Loaded lazily; a missing SDK throws the install command here, and
|
|
140
|
-
// selectStorage() logs it and falls back to local storage.
|
|
141
|
-
const { S3Client } = loadAwsSdk("@aws-sdk/client-s3");
|
|
142
|
-
// url() needs the presigner; check it now so a half-installed SDK refuses
|
|
143
|
-
// at construction instead of failing on the first download link.
|
|
144
|
-
loadAwsSdk("@aws-sdk/s3-request-presigner");
|
|
145
|
-
const endpoint = opts.endpoint || process.env.TINA4_STORAGE_URL;
|
|
146
117
|
this.client = new S3Client({
|
|
147
|
-
endpoint: endpoint || undefined,
|
|
148
|
-
forcePathStyle: true,
|
|
118
|
+
endpoint: opts.endpoint || process.env.TINA4_STORAGE_URL || undefined,
|
|
149
119
|
region: opts.region || process.env.TINA4_STORAGE_REGION || "us-east-1",
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
secretAccessKey: opts.secret || process.env.TINA4_STORAGE_SECRET || "",
|
|
153
|
-
},
|
|
120
|
+
accessKey: opts.key || process.env.TINA4_STORAGE_KEY || "",
|
|
121
|
+
secretKey: opts.secret || process.env.TINA4_STORAGE_SECRET || "",
|
|
154
122
|
});
|
|
155
123
|
}
|
|
156
124
|
|
|
157
125
|
async put(key: string, data: Buffer | string, mime = "application/octet-stream"): Promise<void> {
|
|
158
|
-
|
|
159
|
-
await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: data, ContentType: mime }));
|
|
126
|
+
await this.client.putObject(this.bucket, key, data, mime);
|
|
160
127
|
}
|
|
161
128
|
|
|
162
129
|
async get(key: string): Promise<Buffer | null> {
|
|
163
130
|
try {
|
|
164
|
-
|
|
165
|
-
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
166
|
-
const chunks: Buffer[] = [];
|
|
167
|
-
for await (const chunk of res.Body as AsyncIterable<Buffer>) chunks.push(Buffer.from(chunk));
|
|
168
|
-
return Buffer.concat(chunks);
|
|
131
|
+
return await this.client.getObject(this.bucket, key);
|
|
169
132
|
} catch {
|
|
170
133
|
return null;
|
|
171
134
|
}
|
|
172
135
|
}
|
|
173
136
|
|
|
174
137
|
async url(key: string, ttl = 3600): Promise<string | null> {
|
|
175
|
-
|
|
176
|
-
const { getSignedUrl } = loadAwsSdk("@aws-sdk/s3-request-presigner");
|
|
177
|
-
return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn: ttl });
|
|
138
|
+
return this.client.presignedGetUrl(this.bucket, key, ttl);
|
|
178
139
|
}
|
|
179
140
|
|
|
180
141
|
async delete(key: string): Promise<void> {
|
|
181
142
|
try {
|
|
182
|
-
|
|
183
|
-
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
143
|
+
await this.client.deleteObject(this.bucket, key);
|
|
184
144
|
} catch (e) {
|
|
185
145
|
Log.error(`S3Storage delete failed for ${key}: ${(e as Error).message}`);
|
|
186
146
|
}
|
|
@@ -188,8 +148,7 @@ export class S3Storage implements StorageBackend {
|
|
|
188
148
|
|
|
189
149
|
async exists(key: string): Promise<boolean> {
|
|
190
150
|
try {
|
|
191
|
-
|
|
192
|
-
await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
151
|
+
await this.client.headObject(this.bucket, key);
|
|
193
152
|
return true;
|
|
194
153
|
} catch {
|
|
195
154
|
return false;
|
|
@@ -200,7 +159,7 @@ export class S3Storage implements StorageBackend {
|
|
|
200
159
|
/**
|
|
201
160
|
* Resolve the storage backend from an explicit instance or the environment.
|
|
202
161
|
* Falls back to LocalStorage (logging why) when an s3 backend cannot be built
|
|
203
|
-
* (
|
|
162
|
+
* (config incomplete, e.g. no bucket) - a real store, never a silent no-op.
|
|
204
163
|
*/
|
|
205
164
|
export function selectStorage(storage?: StorageBackend): StorageBackend {
|
|
206
165
|
if (storage) return storage;
|