@voidbase-cloud/voidbase 0.1.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/.env.example +9 -0
- package/CHANGELOG.md +19 -0
- package/COMPAT.md +43 -0
- package/LICENSE +21 -0
- package/NOTICE +8 -0
- package/README.md +124 -0
- package/bin/voidbase.ts +158 -0
- package/crons/every-minute.ts +13 -0
- package/db/migrations/20260905175935_large_swarm.sql +87 -0
- package/db/migrations/20260905185720_wild_sunspot.sql +16 -0
- package/db/migrations/20260905190723_solid_toro.sql +1 -0
- package/db/migrations/20260905213340_remarkable_union_jack.sql +11 -0
- package/db/migrations/meta/20260905175935_snapshot.json +599 -0
- package/db/migrations/meta/20260905185720_snapshot.json +703 -0
- package/db/migrations/meta/20260905190723_snapshot.json +710 -0
- package/db/migrations/meta/20260905213340_snapshot.json +781 -0
- package/db/migrations/meta/_journal.json +34 -0
- package/db/schema.ts +130 -0
- package/docs/deploy.md +153 -0
- package/docs/differences.md +88 -0
- package/docs/hooks.md +84 -0
- package/docs/migrating.md +29 -0
- package/docs/perf.md +53 -0
- package/docs/platform.md +208 -0
- package/docs/releasing.md +38 -0
- package/env.ts +23 -0
- package/hooks-plugin.ts +237 -0
- package/package.json +134 -0
- package/queues/jobs.ts +13 -0
- package/routes/api/[...path].ts +19 -0
- package/scripts/bench-realtime.ts +46 -0
- package/scripts/bench.ts +39 -0
- package/scripts/ci-suites.sh +27 -0
- package/scripts/dev.sh +29 -0
- package/scripts/export.ts +70 -0
- package/scripts/seed-app-user.sh +14 -0
- package/scripts/seed-d1.ts +17 -0
- package/scripts/seed-reference.sh +29 -0
- package/scripts/starter.sh +22 -0
- package/scripts/sync-app.ts +22 -0
- package/scripts/sync-panel.ts +66 -0
- package/src/cloud/rest.ts +297 -0
- package/src/node/assets.ts +22 -0
- package/src/node/bundle.ts +88 -0
- package/src/node/cloud-init.ts +51 -0
- package/src/node/d1.ts +44 -0
- package/src/node/deploy-cf.ts +179 -0
- package/src/node/index.ts +5 -0
- package/src/node/panel.ts +21 -0
- package/src/node/serve.ts +125 -0
- package/src/node/storage.ts +51 -0
- package/src/platform/node/env.ts +4 -0
- package/src/platform/node/hooks.ts +19 -0
- package/src/platform/node/log.ts +7 -0
- package/src/platform/node/migrations.ts +5 -0
- package/src/platform/node/photon.ts +1 -0
- package/src/platform/node/sockets.ts +22 -0
- package/src/platform/node/sse.ts +23 -0
- package/src/platform/workers/env.ts +3 -0
- package/src/platform/workers/hooks.ts +2 -0
- package/src/platform/workers/log.ts +1 -0
- package/src/platform/workers/migrations.ts +1 -0
- package/src/platform/workers/photon.ts +1 -0
- package/src/platform/workers/sockets.ts +3 -0
- package/src/platform/workers/sse.ts +1 -0
- package/src/server/api.ts +27 -0
- package/src/server/app.ts +582 -0
- package/src/server/auth-extra.ts +113 -0
- package/src/server/auth-flows.ts +186 -0
- package/src/server/auth-response.ts +111 -0
- package/src/server/auth.ts +187 -0
- package/src/server/backups.ts +234 -0
- package/src/server/batch.ts +123 -0
- package/src/server/bootstrap.ts +71 -0
- package/src/server/collections/auth-option-shape.json +71 -0
- package/src/server/collections/ddl.ts +127 -0
- package/src/server/collections/fields.ts +120 -0
- package/src/server/collections/model.ts +185 -0
- package/src/server/collections/oauth2-providers.json +1 -0
- package/src/server/collections/scaffolds.json +210 -0
- package/src/server/collections/service.ts +392 -0
- package/src/server/collections/system.json +605 -0
- package/src/server/collections/system.ts +19 -0
- package/src/server/collections/validate.ts +239 -0
- package/src/server/crc32.ts +13 -0
- package/src/server/crons.ts +100 -0
- package/src/server/crypto.ts +26 -0
- package/src/server/db.ts +37 -0
- package/src/server/errors.ts +53 -0
- package/src/server/files-api.ts +52 -0
- package/src/server/filter/compile.ts +420 -0
- package/src/server/filter/lexer.ts +107 -0
- package/src/server/filter/parser.ts +49 -0
- package/src/server/hardening.ts +136 -0
- package/src/server/hooks/index.ts +147 -0
- package/src/server/hooks/migrations.ts +58 -0
- package/src/server/hooks/node-async-hooks.d.ts +7 -0
- package/src/server/hooks/record.ts +152 -0
- package/src/server/hooks/runtime.ts +344 -0
- package/src/server/hooks/virtual-migrations.d.ts +4 -0
- package/src/server/hooks/virtual.d.ts +7 -0
- package/src/server/hub.ts +91 -0
- package/src/server/ids.ts +22 -0
- package/src/server/jobs.ts +84 -0
- package/src/server/jwt.ts +61 -0
- package/src/server/logs.ts +144 -0
- package/src/server/mail/index.ts +99 -0
- package/src/server/mail/message.ts +43 -0
- package/src/server/mail/smtp.ts +82 -0
- package/src/server/mail/templates.ts +168 -0
- package/src/server/oauth2/index.ts +198 -0
- package/src/server/oauth2/providers.ts +153 -0
- package/src/server/password.ts +17 -0
- package/src/server/realtime/hub-client.ts +50 -0
- package/src/server/realtime/index.ts +239 -0
- package/src/server/records/expand.ts +129 -0
- package/src/server/records/files.ts +69 -0
- package/src/server/records/json.ts +23 -0
- package/src/server/records/picker.ts +80 -0
- package/src/server/records/service.ts +598 -0
- package/src/server/records/thumbs.ts +148 -0
- package/src/server/records/values.ts +295 -0
- package/src/server/settings-api.ts +104 -0
- package/src/server/settings.ts +215 -0
- package/src/server/sql.ts +61 -0
- package/src/server/static.ts +17 -0
- package/src/server/storage/s3.ts +118 -0
- package/src/server/types.ts +25 -0
- package/src/server/webauthn.ts +168 -0
- package/tsconfig.json +36 -0
- package/tsconfig.node.json +27 -0
- package/types/pb_data.d.ts +24438 -0
- package/vite.config.ts +10 -0
- package/void.json +12 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Thumbnails, mirroring PocketBase's apis/file.go + tools/filesystem CreateThumb (disintegration/imaging semantics):
|
|
2
|
+
// WxH fill+crop (center), WxHt (top), WxHb (bottom), WxHf fit without upscaling, 0xH / Wx0 resize by one side.
|
|
3
|
+
// Only sizes declared on the field (plus the default 100x100) are honoured; anything else, a non-image original
|
|
4
|
+
// or a failed generation serves the original, exactly like PocketBase. Generated thumbs are cached in R2 under
|
|
5
|
+
// {collection}/{record}/thumbs_{filename}/{size}_{filename}. Resizing runs in Photon (Rust -> wasm).
|
|
6
|
+
import type { PhotonImage as PhotonImageT } from "#platform/photon";
|
|
7
|
+
// Photon (Rust -> wasm) is loaded on the first thumbnail so every other request keeps a small cold start
|
|
8
|
+
let photonModule: Promise<typeof import("#platform/photon")> | null = null;
|
|
9
|
+
const photon = () => (photonModule ??= import("#platform/photon"));
|
|
10
|
+
let P: Awaited<ReturnType<typeof photon>>; // set by createThumb before the helpers below run
|
|
11
|
+
type PhotonImage = PhotonImageT;
|
|
12
|
+
|
|
13
|
+
export const THUMB_SIZE_RE = /^(\d+)x(\d+)(t|b|f)?$/;
|
|
14
|
+
export const IMAGE_CONTENT_TYPES = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/webp"];
|
|
15
|
+
export const DEFAULT_THUMB_SIZES = ["100x100"];
|
|
16
|
+
|
|
17
|
+
export interface ByteRange { offset: number; length: number }
|
|
18
|
+
export interface ServedFile { body: ReadableStream | Uint8Array; size: number; contentType: string; uploaded: Date; name: string; range?: ByteRange }
|
|
19
|
+
|
|
20
|
+
// Parses a single `Range: bytes=a-b` header against the total size; null when absent, "invalid" when unsatisfiable.
|
|
21
|
+
export function parseRange(header: string | undefined, size: number): ByteRange | null | "invalid" {
|
|
22
|
+
if (!header) return null;
|
|
23
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
|
24
|
+
if (!m || (m[1] === "" && m[2] === "")) return "invalid";
|
|
25
|
+
let start: number, end: number;
|
|
26
|
+
if (m[1] === "") { const suffix = Number(m[2]); if (suffix <= 0) return "invalid"; start = Math.max(0, size - suffix); end = size - 1; }
|
|
27
|
+
else { start = Number(m[1]); end = m[2] === "" ? size - 1 : Math.min(size - 1, Number(m[2])); }
|
|
28
|
+
if (start >= size || start > end) return "invalid";
|
|
29
|
+
return { offset: start, length: end - start + 1 };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const attrsOf = (h: R2Object | null) => ({ size: h?.size ?? 0, type: h?.httpMetadata?.contentType ?? "application/octet-stream" });
|
|
33
|
+
const sliceRange = (bytes: Uint8Array, range: ByteRange | undefined) => range ? bytes.subarray(range.offset, range.offset + range.length) : bytes;
|
|
34
|
+
|
|
35
|
+
async function getObject(storage: R2Bucket, key: string, rangeHeader: string | undefined): Promise<{ obj: R2ObjectBody; range?: ByteRange } | null | "invalid"> {
|
|
36
|
+
const head = rangeHeader ? await storage.head(key) : null;
|
|
37
|
+
if (rangeHeader && !head) return null;
|
|
38
|
+
const range = rangeHeader ? parseRange(rangeHeader, head!.size) : null;
|
|
39
|
+
if (range === "invalid") return "invalid";
|
|
40
|
+
const obj = await storage.get(key, range ? { range } : undefined);
|
|
41
|
+
if (!obj) return null;
|
|
42
|
+
return { obj, range: range ?? undefined };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const served = (obj: R2ObjectBody, name: string, fallbackType: string, range?: ByteRange): ServedFile =>
|
|
46
|
+
({ body: obj.body, size: obj.size, contentType: obj.httpMetadata?.contentType ?? fallbackType, uploaded: obj.uploaded, name, range });
|
|
47
|
+
|
|
48
|
+
export class RangeNotSatisfiable extends Error { constructor(public size: number, public contentType: string, public name: string) { super("range not satisfiable"); } }
|
|
49
|
+
|
|
50
|
+
export async function resolveServedFile(storage: R2Bucket, key: string, filename: string, thumbSize: string, fieldThumbs: string[], rangeHeader?: string): Promise<ServedFile | null> {
|
|
51
|
+
if (thumbSize && (DEFAULT_THUMB_SIZES.includes(thumbSize) || fieldThumbs.includes(thumbSize))) {
|
|
52
|
+
const attrs = await storage.head(key);
|
|
53
|
+
if (!attrs) return null;
|
|
54
|
+
const originalType = attrs.httpMetadata?.contentType ?? "";
|
|
55
|
+
if (IMAGE_CONTENT_TYPES.includes(originalType)) {
|
|
56
|
+
const servedName = `${thumbSize}_${filename}`;
|
|
57
|
+
const thumbKey = `${key.slice(0, key.length - filename.length)}thumbs_${filename}/${servedName}`;
|
|
58
|
+
const cached = await getObject(storage, thumbKey, rangeHeader);
|
|
59
|
+
if (cached === "invalid") { const h = await storage.head(thumbKey); throw new RangeNotSatisfiable(h?.size ?? 0, h?.httpMetadata?.contentType ?? "image/png", servedName); }
|
|
60
|
+
if (cached) return served(cached.obj, servedName, "image/png", cached.range);
|
|
61
|
+
const original = await storage.get(key);
|
|
62
|
+
if (!original) return null;
|
|
63
|
+
try {
|
|
64
|
+
const bytes = new Uint8Array(await original.arrayBuffer());
|
|
65
|
+
const thumb = await createThumb(bytes, originalType, thumbSize);
|
|
66
|
+
await storage.put(thumbKey, thumb.bytes, { httpMetadata: { contentType: thumb.contentType } });
|
|
67
|
+
const range = parseRange(rangeHeader, thumb.bytes.byteLength);
|
|
68
|
+
if (range === "invalid") throw new RangeNotSatisfiable(thumb.bytes.byteLength, thumb.contentType, servedName);
|
|
69
|
+
return { body: sliceRange(thumb.bytes, range ?? undefined), size: thumb.bytes.byteLength, contentType: thumb.contentType, uploaded: new Date(), name: servedName, range: range ?? undefined };
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (err instanceof RangeNotSatisfiable) throw err;
|
|
72
|
+
console.warn(`voidbase: fallback to original - failed to create thumb ${servedName}`, err);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const got = await getObject(storage, key, rangeHeader);
|
|
77
|
+
if (got === "invalid") throw new RangeNotSatisfiable(attrsOf(await storage.head(key)).size, attrsOf(await storage.head(key)).type, filename);
|
|
78
|
+
if (!got) return null;
|
|
79
|
+
return served(got.obj, filename, "application/octet-stream", got.range);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Output format follows PocketBase: JPEG stays JPEG, everything else (png, webp, gif) is encoded as PNG.
|
|
83
|
+
export async function createThumb(bytes: Uint8Array, contentType: string, thumbSize: string): Promise<{ bytes: Uint8Array; contentType: string }> {
|
|
84
|
+
P = await photon();
|
|
85
|
+
const m = THUMB_SIZE_RE.exec(thumbSize);
|
|
86
|
+
if (!m) throw new Error("thumb size must be in WxH, WxHt, WxHb or WxHf format");
|
|
87
|
+
const width = Number(m[1]), height = Number(m[2]), resizeType = m[3] ?? "";
|
|
88
|
+
if (width === 0 && height === 0) throw new Error("thumb width and height cannot be zero at the same time");
|
|
89
|
+
const img = P.PhotonImage.new_from_byteslice(bytes);
|
|
90
|
+
try {
|
|
91
|
+
let out: PhotonImage;
|
|
92
|
+
if (width === 0 || height === 0) out = resizeTo(img, width, height);
|
|
93
|
+
else if (resizeType === "f") out = fit(img, width, height);
|
|
94
|
+
else out = fill(img, width, height, resizeType === "t" ? "top" : resizeType === "b" ? "bottom" : "center");
|
|
95
|
+
try {
|
|
96
|
+
if (contentType === "image/jpeg" || contentType === "image/jpg") return { bytes: out.get_bytes_jpeg(95), contentType: "image/jpeg" };
|
|
97
|
+
return { bytes: out.get_bytes(), contentType: "image/png" };
|
|
98
|
+
} finally { if (out !== img) out.free(); }
|
|
99
|
+
} finally { img.free(); }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const roundHalfUp = (v: number) => Math.max(1, Math.floor(v + 0.5));
|
|
103
|
+
|
|
104
|
+
// imaging.Resize: a zero side is derived from the aspect ratio (round half up, at least 1)
|
|
105
|
+
function resizeTo(img: PhotonImage, w: number, h: number): PhotonImage {
|
|
106
|
+
const sw = img.get_width(), sh = img.get_height();
|
|
107
|
+
if (w === 0) w = roundHalfUp((h * sw) / sh);
|
|
108
|
+
if (h === 0) h = roundHalfUp((w * sh) / sw);
|
|
109
|
+
if (w === sw && h === sh) return img;
|
|
110
|
+
return P.resize(img, w, h, P.SamplingFilter.Triangle);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// imaging.Fit: scale down to fit inside the box, never upscale (int truncation as in Go)
|
|
114
|
+
function fit(img: PhotonImage, maxW: number, maxH: number): PhotonImage {
|
|
115
|
+
const sw = img.get_width(), sh = img.get_height();
|
|
116
|
+
if (sw <= maxW && sh <= maxH) return img;
|
|
117
|
+
const srcAspect = sw / sh, maxAspect = maxW / maxH;
|
|
118
|
+
let nw: number, nh: number;
|
|
119
|
+
if (srcAspect > maxAspect) { nw = maxW; nh = Math.trunc(nw / srcAspect); } else { nh = maxH; nw = Math.trunc(nh * srcAspect); }
|
|
120
|
+
return P.resize(img, Math.max(1, nw), Math.max(1, nh), P.SamplingFilter.Triangle);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// imaging.Fill: sources of at least 100x100 are cropped to the target aspect first and then resized;
|
|
124
|
+
// smaller ones are resized to cover and then cropped. Both end at exactly w x h.
|
|
125
|
+
function fill(img: PhotonImage, w: number, h: number, anchor: "center" | "top" | "bottom"): PhotonImage {
|
|
126
|
+
const sw = img.get_width(), sh = img.get_height();
|
|
127
|
+
if (sw === w && sh === h) return img;
|
|
128
|
+
const srcAspect = sw / sh, dstAspect = w / h;
|
|
129
|
+
if (sw >= 100 && sh >= 100) {
|
|
130
|
+
let cw = sw, ch = sh;
|
|
131
|
+
if (srcAspect < dstAspect) ch = Math.trunc(Math.max(1, (sw * h) / w) + 0.5);
|
|
132
|
+
else cw = Math.trunc(Math.max(1, (sh * w) / h) + 0.5);
|
|
133
|
+
const cropped = cropAnchor(img, cw, ch, anchor);
|
|
134
|
+
try { return P.resize(cropped, w, h, P.SamplingFilter.Triangle); } finally { if (cropped !== img) cropped.free(); }
|
|
135
|
+
}
|
|
136
|
+
const tmp = srcAspect < dstAspect ? resizeTo(img, w, 0) : resizeTo(img, 0, h);
|
|
137
|
+
try { return cropAnchor(tmp, w, h, anchor); } finally { if (tmp !== img) tmp.free(); }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// imaging.CropAnchor with the Center / Top / Bottom anchors, clamped to the image
|
|
141
|
+
function cropAnchor(img: PhotonImage, w: number, h: number, anchor: "center" | "top" | "bottom"): PhotonImage {
|
|
142
|
+
const sw = img.get_width(), sh = img.get_height();
|
|
143
|
+
const x = Math.trunc((sw - w) / 2);
|
|
144
|
+
const y = anchor === "top" ? 0 : anchor === "bottom" ? sh - h : Math.trunc((sh - h) / 2);
|
|
145
|
+
const x1 = Math.max(0, x), y1 = Math.max(0, y), x2 = Math.min(sw, x + w), y2 = Math.min(sh, y + h);
|
|
146
|
+
if (x1 === 0 && y1 === 0 && x2 === sw && y2 === sh) return img;
|
|
147
|
+
return P.crop(img, x1, y1, x2, y2);
|
|
148
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
// Record value normalization and validation per field type, mirroring core/field_*.go ValidateValue.
|
|
2
|
+
import { isMultiple, type Field } from "../collections/fields";
|
|
3
|
+
import type { Collection } from "../collections/model";
|
|
4
|
+
import { all, ident } from "../db";
|
|
5
|
+
import { nowString } from "../ids";
|
|
6
|
+
import type { Row } from "../types";
|
|
7
|
+
|
|
8
|
+
export interface FieldError { code: string; message: string; params?: Record<string, unknown> }
|
|
9
|
+
export type RecordErrors = Record<string, FieldError>;
|
|
10
|
+
const err = (code: string, message: string, params?: Record<string, unknown>): FieldError => (params ? { code, message, params } : { code, message });
|
|
11
|
+
export const REQUIRED = err("validation_required", "Cannot be blank.");
|
|
12
|
+
|
|
13
|
+
export interface Upload { name: string; type: string; size: number; bytes: ArrayBuffer }
|
|
14
|
+
// value of a file field during an upsert: kept names plus new uploads (already renamed)
|
|
15
|
+
export interface FileValue { names: string[]; uploads: Map<string, Upload> }
|
|
16
|
+
|
|
17
|
+
const EMAIL_RE = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
|
|
18
|
+
|
|
19
|
+
// ---- reading rows ---------------------------------------------------------
|
|
20
|
+
export function fromColumn(f: Field, v: unknown): unknown {
|
|
21
|
+
if (f.type === "bool") return !!v;
|
|
22
|
+
if (f.type === "number") return typeof v === "number" ? v : Number(v ?? 0) || 0;
|
|
23
|
+
if (f.type === "json") {
|
|
24
|
+
if (v === null || v === undefined) return null;
|
|
25
|
+
if (typeof v === "string") { try { return JSON.parse(v); } catch { return v; } }
|
|
26
|
+
return v;
|
|
27
|
+
}
|
|
28
|
+
if (f.type === "geoPoint") {
|
|
29
|
+
if (typeof v === "string") { try { const o = JSON.parse(v); return { lon: Number(o?.lon ?? 0), lat: Number(o?.lat ?? 0) }; } catch { /* fallthrough */ } }
|
|
30
|
+
return { lon: 0, lat: 0 };
|
|
31
|
+
}
|
|
32
|
+
if (isMultiple(f)) {
|
|
33
|
+
if (Array.isArray(v)) return v.map(String);
|
|
34
|
+
if (typeof v === "string" && v !== "") { try { const a = JSON.parse(v); return Array.isArray(a) ? a.map(String) : [String(v)]; } catch { return [v]; } }
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
if (v === null || v === undefined) return "";
|
|
38
|
+
return String(v);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function rowToValues(c: Collection, row: Row): Record<string, unknown> {
|
|
42
|
+
const out: Record<string, unknown> = {};
|
|
43
|
+
for (const f of c.fields as Field[]) out[f.name] = fromColumn(f, row[f.name]);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---- writing rows ---------------------------------------------------------
|
|
48
|
+
export function toColumn(f: Field, v: unknown): unknown {
|
|
49
|
+
if (f.type === "bool") return v ? 1 : 0;
|
|
50
|
+
if (f.type === "number") return typeof v === "number" ? v : Number(v) || 0;
|
|
51
|
+
if (f.type === "json") return v === null || v === undefined ? null : JSON.stringify(v);
|
|
52
|
+
if (f.type === "geoPoint") { const o = (v ?? {}) as { lon?: unknown; lat?: unknown }; return JSON.stringify({ lon: Number(o.lon ?? 0) || 0, lat: Number(o.lat ?? 0) || 0 }); }
|
|
53
|
+
if (isMultiple(f)) return JSON.stringify(Array.isArray(v) ? v : v === "" || v == null ? [] : [String(v)]);
|
|
54
|
+
if (f.type === "file" || f.type === "select" || f.type === "relation") return Array.isArray(v) ? String(v[0] ?? "") : v == null ? "" : String(v);
|
|
55
|
+
return v == null ? "" : String(v);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ---- input normalization (Record.Set semantics) ---------------------------
|
|
59
|
+
export function normalizeInput(f: Field, raw: unknown): unknown {
|
|
60
|
+
switch (f.type) {
|
|
61
|
+
case "bool": return raw === true || raw === "true" || raw === 1 || raw === "1";
|
|
62
|
+
case "number": {
|
|
63
|
+
if (raw === "" || raw === null || raw === undefined) return 0;
|
|
64
|
+
// cast.ToFloat64 semantics: anything that does not parse becomes 0 (no validation error)
|
|
65
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
66
|
+
return Number.isFinite(n) ? n : 0;
|
|
67
|
+
}
|
|
68
|
+
case "json": {
|
|
69
|
+
if (raw === undefined) return null;
|
|
70
|
+
if (typeof raw === "string") { const s = raw.trim(); if (s === "") return null; try { return JSON.parse(s); } catch { return raw; } }
|
|
71
|
+
return raw;
|
|
72
|
+
}
|
|
73
|
+
case "geoPoint": {
|
|
74
|
+
let o = raw;
|
|
75
|
+
if (typeof raw === "string") { try { o = JSON.parse(raw); } catch { o = null; } }
|
|
76
|
+
const g = (o ?? {}) as { lon?: unknown; lat?: unknown };
|
|
77
|
+
return { lon: Number(g.lon ?? 0) || 0, lat: Number(g.lat ?? 0) || 0 };
|
|
78
|
+
}
|
|
79
|
+
case "date": return normalizeDate(raw);
|
|
80
|
+
case "select": case "relation": case "file": {
|
|
81
|
+
let list: string[];
|
|
82
|
+
if (Array.isArray(raw)) list = raw.map(String);
|
|
83
|
+
else if (typeof raw === "string") { const s = raw.trim(); if (s.startsWith("[") && isMultiple(f)) { try { list = (JSON.parse(s) as unknown[]).map(String); } catch { list = [s]; } } else list = s === "" ? [] : [s]; }
|
|
84
|
+
else list = raw == null ? [] : [String(raw)];
|
|
85
|
+
list = uniqueStrings(list.map((s) => s.trim()).filter(Boolean));
|
|
86
|
+
return isMultiple(f) ? list : (list[list.length - 1] ?? "");
|
|
87
|
+
}
|
|
88
|
+
default: return raw == null ? "" : typeof raw === "object" ? JSON.stringify(raw) : String(raw);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export const uniqueStrings = (list: string[]) => [...new Set(list)];
|
|
93
|
+
|
|
94
|
+
export function normalizeDate(raw: unknown): string {
|
|
95
|
+
if (raw === null || raw === undefined || raw === "") return "";
|
|
96
|
+
if (raw instanceof Date) return nowString(raw);
|
|
97
|
+
const s = String(raw).trim();
|
|
98
|
+
if (!s) return "";
|
|
99
|
+
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(s)) return s;
|
|
100
|
+
const iso = /^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?(?:Z|[+-]\d{2}:?\d{2})?$/.test(s) ? s.replace(" ", "T") : s;
|
|
101
|
+
const d = new Date(/^\d{4}-\d{2}-\d{2}$/.test(iso) ? iso + "T00:00:00Z" : /Z|[+-]\d{2}:?\d{2}$/.test(iso) || !/T/.test(iso) ? iso : iso + "Z");
|
|
102
|
+
return Number.isNaN(d.getTime()) ? "" : nowString(d);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- validation ----------------------------------------------------------
|
|
106
|
+
export interface ValidateContext {
|
|
107
|
+
db: D1Database;
|
|
108
|
+
collections: Map<string, Collection>;
|
|
109
|
+
isNew: boolean;
|
|
110
|
+
originalId?: string;
|
|
111
|
+
uploads: Record<string, Map<string, Upload>>; // per file field: newly uploaded name -> upload
|
|
112
|
+
existingFiles: Record<string, string[]>; // per file field: names stored before this request
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const len = (s: string) => [...s].length;
|
|
116
|
+
const isEmpty = (v: unknown) => v === null || v === undefined || v === "" || (Array.isArray(v) && v.length === 0) || (typeof v === "number" && v === 0);
|
|
117
|
+
|
|
118
|
+
export async function validateValues(c: Collection, values: Record<string, unknown>, ctx: ValidateContext): Promise<RecordErrors> {
|
|
119
|
+
const errors: RecordErrors = {};
|
|
120
|
+
for (const f of c.fields as Field[]) {
|
|
121
|
+
const e = await validateField(f, values[f.name], ctx, values);
|
|
122
|
+
if (e) errors[f.name] = e;
|
|
123
|
+
}
|
|
124
|
+
return errors;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function validateField(f: Field, v: unknown, ctx: ValidateContext, values: Record<string, unknown>): Promise<FieldError | null> {
|
|
128
|
+
const n = (x: unknown) => (x === null || x === undefined ? null : Number(x));
|
|
129
|
+
switch (f.type) {
|
|
130
|
+
case "text": {
|
|
131
|
+
const s = String(v ?? "");
|
|
132
|
+
if (f.primaryKey) {
|
|
133
|
+
if (!ctx.isNew && ctx.originalId !== undefined && s !== ctx.originalId) return err("validation_pk_change", "The record primary key cannot be changed.");
|
|
134
|
+
if (s === "") return REQUIRED;
|
|
135
|
+
} else if (f.required && s === "") return REQUIRED;
|
|
136
|
+
if (s === "") return null;
|
|
137
|
+
if (n(f.min) && len(s) < (n(f.min) as number)) return err("validation_min_text_constraint", `Must be at least ${f.min} character(s).`, { min: f.min });
|
|
138
|
+
if (n(f.max) && len(s) > (n(f.max) as number)) return err("validation_max_text_constraint", `Must be no more than ${f.max} character(s).`, { max: f.max });
|
|
139
|
+
if (f.pattern && !safeRegex(String(f.pattern)).test(s)) return err("validation_invalid_format", "Invalid value format.");
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
case "editor": {
|
|
143
|
+
const s = String(v ?? "");
|
|
144
|
+
if (f.required && s === "") return REQUIRED;
|
|
145
|
+
if (n(f.maxSize) && new TextEncoder().encode(s).length > (n(f.maxSize) as number)) return err("validation_content_size_limit", `The maximum allowed content size is ${f.maxSize} bytes.`, { maxSize: f.maxSize });
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
case "number": {
|
|
149
|
+
if (typeof v === "number" && Number.isNaN(v)) return err("validation_not_a_number", "The submitted number is not properly formatted");
|
|
150
|
+
const x = Number(v ?? 0);
|
|
151
|
+
if (f.required && x === 0) return REQUIRED;
|
|
152
|
+
if (f.onlyInt && !Number.isInteger(x)) return err("validation_only_int_constraint", "Decimal numbers are not allowed");
|
|
153
|
+
if (n(f.min) !== null && x < (n(f.min) as number)) return err("validation_min_number_constraint", `Must be greater or equal than ${f.min}.`, { min: f.min });
|
|
154
|
+
if (n(f.max) !== null && x > (n(f.max) as number)) return err("validation_max_number_constraint", `Must be less or equal than ${f.max}.`, { max: f.max });
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
case "bool": return f.required && !v ? REQUIRED : null;
|
|
158
|
+
case "email": {
|
|
159
|
+
const s = String(v ?? "");
|
|
160
|
+
if (f.required && s === "") return REQUIRED;
|
|
161
|
+
if (s === "") return null;
|
|
162
|
+
if (!EMAIL_RE.test(s)) return err("validation_is_email", "Must be a valid email address.");
|
|
163
|
+
const domain = s.split("@")[1]!.toLowerCase();
|
|
164
|
+
const only = f.onlyDomains as string[] | null; const except = f.exceptDomains as string[] | null;
|
|
165
|
+
if (only?.length && !only.map((d) => d.toLowerCase()).includes(domain)) return err("validation_email_domain_not_allowed", "Email domain is not allowed");
|
|
166
|
+
if (except?.length && except.map((d) => d.toLowerCase()).includes(domain)) return err("validation_email_domain_not_allowed", "Email domain is not allowed");
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
case "url": {
|
|
170
|
+
const s = String(v ?? "");
|
|
171
|
+
if (f.required && s === "") return REQUIRED;
|
|
172
|
+
if (s === "") return null;
|
|
173
|
+
let host = "";
|
|
174
|
+
try { const u = new URL(s); if (!/^https?:$/.test(u.protocol) || !u.hostname) throw new Error(); host = u.hostname.toLowerCase(); } catch { return err("validation_invalid_url", "Must be a valid url."); }
|
|
175
|
+
const only = f.onlyDomains as string[] | null; const except = f.exceptDomains as string[] | null;
|
|
176
|
+
if (only?.length && !only.map((d) => d.toLowerCase()).includes(host)) return err("validation_url_domain_not_allowed", "Url domain is not allowed");
|
|
177
|
+
if (except?.length && except.map((d) => d.toLowerCase()).includes(host)) return err("validation_url_domain_not_allowed", "Url domain is not allowed");
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
case "date": {
|
|
181
|
+
const s = String(v ?? "");
|
|
182
|
+
if (f.required && s === "") return REQUIRED;
|
|
183
|
+
if (s === "") return null;
|
|
184
|
+
if (f.min && s < String(f.min)) return err("validation_min_date_constraint", `Must be later than ${f.min}`, { min: f.min });
|
|
185
|
+
if (f.max && s > String(f.max)) return err("validation_max_date_constraint", `Must be earlier than ${f.max}`, { max: f.max });
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
case "autodate": return null;
|
|
189
|
+
case "select": {
|
|
190
|
+
const list = Array.isArray(v) ? (v as string[]) : v ? [String(v)] : [];
|
|
191
|
+
if (f.required && list.length === 0) return REQUIRED;
|
|
192
|
+
const max = isMultiple(f) ? Number(f.maxSelect) : 1;
|
|
193
|
+
if (list.length > max) return err("validation_too_many_values", `Select no more than ${max}`, { maxSelect: max });
|
|
194
|
+
const allowed = (f.values as string[] | null) ?? [];
|
|
195
|
+
for (const s of list) if (!allowed.includes(s)) return err("validation_invalid_value", `Invalid value ${s}.`, { value: s });
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
case "relation": {
|
|
199
|
+
const list = Array.isArray(v) ? (v as string[]) : v ? [String(v)] : [];
|
|
200
|
+
if (f.required && list.length === 0) return REQUIRED;
|
|
201
|
+
const max = isMultiple(f) ? Number(f.maxSelect) : 1;
|
|
202
|
+
const min = Number(f.minSelect ?? 0);
|
|
203
|
+
if (min > 0 && list.length < min) return err("validation_not_enough_values", `Select at least ${min}`, { minSelect: min });
|
|
204
|
+
if (max > 0 && list.length > max) return err("validation_too_many_values", `Select no more than ${max}`, { maxSelect: max });
|
|
205
|
+
if (list.length === 0) return null;
|
|
206
|
+
const rel = ctx.collections.get(String(f.collectionId));
|
|
207
|
+
if (!rel) return err("validation_missing_rel_collection", "Relation connection is missing or cannot be accessed");
|
|
208
|
+
const found = new Set<string>();
|
|
209
|
+
for (let i = 0; i < list.length; i += 90) {
|
|
210
|
+
const chunk = list.slice(i, i + 90);
|
|
211
|
+
const rows = await all<{ id: string }>(ctx.db, `SELECT id FROM ${ident(rel.name)} WHERE id IN (${chunk.map(() => "?").join(",")})`, chunk);
|
|
212
|
+
for (const r of rows) found.add(r.id);
|
|
213
|
+
}
|
|
214
|
+
if (list.some((id) => !found.has(id))) return err("validation_missing_rel_records", "Failed to find all relation records with the provided ids.");
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
case "file": {
|
|
218
|
+
const list = Array.isArray(v) ? (v as string[]) : v ? [String(v)] : [];
|
|
219
|
+
if (f.required && list.length === 0) return REQUIRED;
|
|
220
|
+
const max = isMultiple(f) ? Number(f.maxSelect) : 1;
|
|
221
|
+
if (max > 0 && list.length > max) return err("validation_too_many_files", `The maximum allowed files is ${max}`, { maxSelect: max });
|
|
222
|
+
const uploads = ctx.uploads[f.name] ?? new Map<string, Upload>();
|
|
223
|
+
const existing = new Set(ctx.existingFiles[f.name] ?? []);
|
|
224
|
+
const invalid = list.filter((name) => !uploads.has(name) && !existing.has(name));
|
|
225
|
+
if (invalid.length) return err("validation_invalid_file", `Invalid new files: ${invalid.join(", ")}.`, { invalidFiles: invalid.join(", ") });
|
|
226
|
+
const mimeTypes = (f.mimeTypes as string[] | null) ?? [];
|
|
227
|
+
const maxSize = Number(f.maxSize ?? 0);
|
|
228
|
+
for (const name of list) {
|
|
229
|
+
const up = uploads.get(name);
|
|
230
|
+
if (!up) continue;
|
|
231
|
+
if (maxSize > 0 && up.size > maxSize) return err("validation_file_size_limit", `Failed to upload "${name}" - the maximum allowed file size is ${maxSize} bytes.`, { maxSize });
|
|
232
|
+
if (mimeTypes.length && !mimeTypes.includes(up.type)) return err("validation_invalid_mime_type", `"${name}" mime type must be one of: ${mimeTypes.join(", ")}.`);
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
case "json": {
|
|
237
|
+
if (f.required && (v === null || v === undefined || v === "" || (Array.isArray(v) && v.length === 0) || (typeof v === "object" && v && Object.keys(v as object).length === 0))) return REQUIRED;
|
|
238
|
+
const bytes = new TextEncoder().encode(JSON.stringify(v ?? null)).length;
|
|
239
|
+
if (n(f.maxSize) && bytes > (n(f.maxSize) as number)) return err("validation_json_size_limit", `The maximum allowed JSON size is ${f.maxSize} bytes.`, { maxSize: f.maxSize });
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
case "password": {
|
|
243
|
+
// v here is the plain text password when it is being set; empty means untouched
|
|
244
|
+
const s = String(v ?? "");
|
|
245
|
+
if (s === "") return null;
|
|
246
|
+
if (n(f.min) && len(s) < (n(f.min) as number)) return err("validation_min_text_constraint", `Must be at least ${f.min} character(s).`);
|
|
247
|
+
if (n(f.max) && len(s) > (n(f.max) as number)) return err("validation_max_text_constraint", `Must be no more than ${f.max} character(s).`);
|
|
248
|
+
if (f.pattern && !safeRegex(String(f.pattern)).test(s)) return err("validation_invalid_format", "Invalid value format.");
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
case "geoPoint": {
|
|
252
|
+
const g = (v ?? {}) as { lon?: number; lat?: number };
|
|
253
|
+
const lon = Number(g.lon ?? 0), lat = Number(g.lat ?? 0);
|
|
254
|
+
if (f.required && lon === 0 && lat === 0) return REQUIRED;
|
|
255
|
+
if (lon < -180 || lon > 180) return err("validation_invalid_longitude", "Longitude must be between -180 and 180 degrees.");
|
|
256
|
+
if (lat < -90 || lat > 90) return err("validation_invalid_latitude", "Latitude must be between -90 and 90 degrees.");
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
void values; void isEmpty;
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const regexCache = new Map<string, RegExp>();
|
|
265
|
+
function safeRegex(p: string): RegExp {
|
|
266
|
+
let r = regexCache.get(p);
|
|
267
|
+
if (!r) { try { r = new RegExp(p); } catch { r = /$^/; } regexCache.set(p, r); }
|
|
268
|
+
return r;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Generates a value for autogeneratePattern: literal chars and [class]{n} groups (the forms PocketBase ships).
|
|
272
|
+
export function autogenerate(pattern: string): string {
|
|
273
|
+
let out = "";
|
|
274
|
+
const re = /\[([^\]]+)\]\{(\d+)(?:,(\d+))?\}|\[([^\]]+)\]|(\\?.)/g;
|
|
275
|
+
let m: RegExpExecArray | null;
|
|
276
|
+
while ((m = re.exec(pattern))) {
|
|
277
|
+
if (m[1] !== undefined) out += randomFromClass(m[1], Number(m[2]) + (m[3] ? Math.floor(Math.random() * (Number(m[3]) - Number(m[2]) + 1)) : 0));
|
|
278
|
+
else if (m[4] !== undefined) out += randomFromClass(m[4], 1);
|
|
279
|
+
else out += m[5]!.replace(/^\\/, "");
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
function randomFromClass(cls: string, count: number): string {
|
|
284
|
+
const chars: string[] = [];
|
|
285
|
+
for (let i = 0; i < cls.length; i++) {
|
|
286
|
+
if (cls[i + 1] === "-" && i + 2 < cls.length) {
|
|
287
|
+
for (let c = cls.charCodeAt(i); c <= cls.charCodeAt(i + 2); c++) chars.push(String.fromCharCode(c));
|
|
288
|
+
i += 2;
|
|
289
|
+
} else chars.push(cls[i]!);
|
|
290
|
+
}
|
|
291
|
+
const bytes = crypto.getRandomValues(new Uint8Array(count));
|
|
292
|
+
let s = "";
|
|
293
|
+
for (let i = 0; i < count; i++) s += chars[bytes[i]! % chars.length];
|
|
294
|
+
return s;
|
|
295
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// /api/settings (superuser): update, test email, test S3, Apple client secret (apis/settings.go).
|
|
2
|
+
import type { Context, Hono } from "hono";
|
|
3
|
+
import { ApiError, badRequest } from "./errors";
|
|
4
|
+
import { loadCollections } from "./collections/model";
|
|
5
|
+
import { sendRecordAuthAlert, sendRecordChangeEmail, sendRecordOTP, sendRecordPasswordReset, sendRecordVerification } from "./mail";
|
|
6
|
+
import { lengthErr, loadSettings, maxErr, mergeSettings, minErr, publicSettings, saveSettings, sortNested, validateSettings, type NestedErrors } from "./settings";
|
|
7
|
+
import { randomString } from "./ids";
|
|
8
|
+
import type { AppEnv, Row } from "./types";
|
|
9
|
+
import type { Field } from "./collections/fields";
|
|
10
|
+
import { requireSuperuser } from "./auth";
|
|
11
|
+
import { S3Bucket } from "./storage/s3";
|
|
12
|
+
import { requestHook, trigger } from "./hooks/runtime";
|
|
13
|
+
|
|
14
|
+
const readBody = async (c: Context<AppEnv>): Promise<Record<string, unknown>> => {
|
|
15
|
+
try { const v = await c.req.json(); if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error(); return v as Record<string, unknown>; }
|
|
16
|
+
catch { throw badRequest("An error occurred while loading the submitted data."); }
|
|
17
|
+
};
|
|
18
|
+
const req = { code: "validation_required", message: "Cannot be blank." };
|
|
19
|
+
|
|
20
|
+
export function mountSettingsApi(app: Hono<AppEnv>) {
|
|
21
|
+
app.patch("/api/settings", async (c) => {
|
|
22
|
+
requireSuperuser(c);
|
|
23
|
+
const body = await readBody(c);
|
|
24
|
+
const current = await loadSettings(c.env.DB);
|
|
25
|
+
const merged = mergeSettings(current, body);
|
|
26
|
+
return requestHook("onSettingsUpdateRequest", c, null, { oldSettings: structuredClone(current), newSettings: merged }, async (ev) => {
|
|
27
|
+
const next = ev.newSettings as typeof merged;
|
|
28
|
+
const errs = validateSettings(next);
|
|
29
|
+
if (Object.keys(errs).length) throw new ApiError(400, "An error occurred while saving the new settings.", errs as never);
|
|
30
|
+
// PocketBase keeps the stored secrets when the submitted ones are blank (validation already saw the blank value)
|
|
31
|
+
if (!next.smtp.password) next.smtp.password = current.smtp.password;
|
|
32
|
+
if (!next.s3.secret) next.s3.secret = current.s3.secret;
|
|
33
|
+
if (!next.backups.s3.secret) next.backups.s3.secret = current.backups.s3.secret;
|
|
34
|
+
await saveSettings(c.env.DB, next);
|
|
35
|
+
await trigger("onSettingsReload", { app: undefined as unknown, next: async () => undefined as unknown }, null, async () => undefined);
|
|
36
|
+
return c.json(publicSettings(next));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
app.post("/api/settings/test/email", async (c) => {
|
|
41
|
+
requireSuperuser(c);
|
|
42
|
+
const body = await readBody(c);
|
|
43
|
+
const email = String(body.email ?? ""), template = String(body.template ?? ""), collName = String(body.collection ?? "");
|
|
44
|
+
const errs: NestedErrors = {};
|
|
45
|
+
const collections = await loadCollections(c.env.DB);
|
|
46
|
+
if (collName) {
|
|
47
|
+
if (collName.length > 255) errs.collection = lengthErr(1, 255);
|
|
48
|
+
else { const cc = collections.get(collName); if (!cc || cc.type !== "auth") errs.collection = { code: "validation_invalid_auth_collection", message: "Must be a valid auth collection id or name." }; }
|
|
49
|
+
}
|
|
50
|
+
if (!email) errs.email = req; else if (email.length > 255) errs.email = lengthErr(1, 255); else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errs.email = { code: "validation_is_email", message: "Must be a valid email address." };
|
|
51
|
+
if (!template) errs.template = req; else if (!["verification", "password-reset", "email-change", "otp", "login-alert"].includes(template)) errs.template = { code: "validation_in_invalid", message: "Must be a valid value." };
|
|
52
|
+
if (Object.keys(errs).length) throw new ApiError(400, "Failed to send the test email.", sortNested(errs) as never);
|
|
53
|
+
const collection = collections.get(collName || "_superusers")!;
|
|
54
|
+
const row: Row = {};
|
|
55
|
+
for (const f of collection.fields as Field[]) if (!f.hidden) row[f.name] = `__pb_test_${f.name}__`;
|
|
56
|
+
row.tokenKey = randomString(50); row.email = email;
|
|
57
|
+
try {
|
|
58
|
+
const inline = { inline: true }; // PocketBase reports the SMTP error to the panel, so deliver now, not from the queue
|
|
59
|
+
if (template === "verification") await sendRecordVerification(c.env.DB, collection, row, inline);
|
|
60
|
+
else if (template === "password-reset") await sendRecordPasswordReset(c.env.DB, collection, row, inline);
|
|
61
|
+
else if (template === "email-change") await sendRecordChangeEmail(c.env.DB, collection, row, email, inline);
|
|
62
|
+
else if (template === "otp") await sendRecordOTP(c.env.DB, collection, row, "_PB_TEST_OTP_ID_", "123456", inline);
|
|
63
|
+
else await sendRecordAuthAlert(c.env.DB, collection, row, `${new Date().toISOString().replace("T", " ").replace(/\.\d{3}Z$/, ".000Z")} - TEST_IP TEST_USER_AGENT`, inline);
|
|
64
|
+
} catch (err) { throw badRequest("Failed to send the test email. Raw error: \n" + (err instanceof Error ? err.message : String(err))); }
|
|
65
|
+
return c.body(null, 204);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
app.post("/api/settings/test/s3", async (c) => {
|
|
69
|
+
requireSuperuser(c);
|
|
70
|
+
const body = await readBody(c);
|
|
71
|
+
const fs = String(body.filesystem ?? "");
|
|
72
|
+
if (!fs) throw new ApiError(400, "Failed to test the S3 filesystem.", { filesystem: req } as never);
|
|
73
|
+
if (!["storage", "backups"].includes(fs)) throw new ApiError(400, "Failed to test the S3 filesystem.", { filesystem: { code: "validation_in_invalid", message: "Must be a valid value." } } as never);
|
|
74
|
+
const settings = await loadSettings(c.env.DB);
|
|
75
|
+
const cfg = fs === "storage" ? settings.s3 : settings.backups.s3;
|
|
76
|
+
if (!cfg.enabled) throw badRequest(`Failed to test the S3 filesystem. Raw error: \nS3 storage filesystem is not enabled`);
|
|
77
|
+
try { await new S3Bucket(cfg).test(); } catch (err) { throw badRequest(`Failed to test the S3 filesystem. Raw error: \n${err instanceof Error ? err.message : String(err)}`); }
|
|
78
|
+
return c.body(null, 204);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
app.post("/api/settings/apple/generate-client-secret", async (c) => {
|
|
82
|
+
requireSuperuser(c);
|
|
83
|
+
const body = await readBody(c);
|
|
84
|
+
const clientId = String(body.clientId ?? ""), teamId = String(body.teamId ?? ""), keyId = String(body.keyId ?? ""), privateKey = String(body.privateKey ?? ""), duration = Number(body.duration ?? 0);
|
|
85
|
+
const errs: NestedErrors = {};
|
|
86
|
+
if (!clientId) errs.clientId = req;
|
|
87
|
+
if (!teamId) errs.teamId = req; else if (teamId.length !== 10) errs.teamId = lengthErr(10, 10);
|
|
88
|
+
if (!keyId) errs.keyId = req; else if (keyId.length !== 10) errs.keyId = lengthErr(10, 10);
|
|
89
|
+
if (!privateKey) errs.privateKey = req; else if (!/^-----BEGIN PRIVATE KEY-----[\s\S]+-----END PRIVATE KEY-----\s*$/.test(privateKey.trim())) errs.privateKey = { code: "validation_match_invalid", message: "Must be in a valid format." };
|
|
90
|
+
if (!duration) errs.duration = req; else if (duration < 1) errs.duration = minErr(1); else if (duration > 15777000) errs.duration = maxErr(15777000);
|
|
91
|
+
if (Object.keys(errs).length) throw new ApiError(400, "Invalid client secret data.", sortNested(errs) as never);
|
|
92
|
+
try {
|
|
93
|
+
const pem = privateKey.trim().replace(/-----BEGIN PRIVATE KEY-----|-----END PRIVATE KEY-----/g, "").replace(/\s+/g, "");
|
|
94
|
+
const der = Uint8Array.from(atob(pem), (ch) => ch.charCodeAt(0));
|
|
95
|
+
const key = await crypto.subtle.importKey("pkcs8", der, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
|
|
96
|
+
const b64 = (v: ArrayBuffer | Uint8Array | string) => { const bytes = typeof v === "string" ? new TextEncoder().encode(v) : new Uint8Array(v as ArrayBuffer); return btoa(String.fromCharCode(...bytes)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); };
|
|
97
|
+
const now = Math.floor(Date.now() / 1000);
|
|
98
|
+
const header = b64(JSON.stringify({ alg: "ES256", kid: keyId, typ: "JWT" }));
|
|
99
|
+
const claims = b64(JSON.stringify({ aud: ["https://appleid.apple.com"], exp: now + duration, iat: now, iss: teamId, sub: clientId }));
|
|
100
|
+
const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, new TextEncoder().encode(`${header}.${claims}`));
|
|
101
|
+
return c.json({ secret: `${header}.${claims}.${b64(sig)}` });
|
|
102
|
+
} catch (err) { throw badRequest("Failed to generate client secret. Raw error: \n" + (err instanceof Error ? err.message : String(err))); }
|
|
103
|
+
});
|
|
104
|
+
}
|