@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,582 @@
|
|
|
1
|
+
import { Hono, type Context } from "hono";
|
|
2
|
+
import { cors } from "hono/cors";
|
|
3
|
+
import { authMethods, authRefresh, authWithPassword, findAuthRecordByToken, isSuperuser, loadAuth, requireSuperuser } from "./auth";
|
|
4
|
+
import { ensureBootstrapped } from "./bootstrap";
|
|
5
|
+
import { collectionToJSON, findCollection, invalidateCollections, listCollections, loadCollections, type Collection } from "./collections/model";
|
|
6
|
+
import type { Field } from "./collections/fields";
|
|
7
|
+
import { createRecord, deleteRecord, listRecords, updateRecord, viewRecord, type ListQuery, type RecordContext } from "./records/service";
|
|
8
|
+
import { fromColumn, toColumn } from "./records/values";
|
|
9
|
+
import { expandRecords } from "./records/expand";
|
|
10
|
+
import { hookGlobals, hookMiddleware, loadHooks, mountHookRoutes } from "./hooks";
|
|
11
|
+
import { requestHook, requestHookResult, trigger } from "./hooks/runtime";
|
|
12
|
+
import { logger } from "#platform/log";
|
|
13
|
+
import { env as voidEnv } from "#platform/env";
|
|
14
|
+
import type { Settings } from "./settings";
|
|
15
|
+
import { applyPendingMigrations, withHookStore } from "./hooks/migrations";
|
|
16
|
+
import { RangeNotSatisfiable, resolveServedFile } from "./records/thumbs";
|
|
17
|
+
import { deletePrefix } from "./records/files";
|
|
18
|
+
import { authWithOAuth2, mountOAuth2Redirect } from "./oauth2";
|
|
19
|
+
import { mountSettingsApi } from "./settings-api";
|
|
20
|
+
import { mountAuthFlows } from "./auth-flows";
|
|
21
|
+
import { mountAuthExtra } from "./auth-extra";
|
|
22
|
+
import { mountFilesApi, protectedAccess } from "./files-api";
|
|
23
|
+
import { BATCH_CONTEXT_HEADER, batchContextToken, mountBatch } from "./batch";
|
|
24
|
+
import { mountLogsApi, requestLogger } from "./logs";
|
|
25
|
+
import { mountCronsApi } from "./crons";
|
|
26
|
+
import { mountBackupsApi } from "./backups";
|
|
27
|
+
import { mountSqlApi } from "./sql";
|
|
28
|
+
import { bodyLimitMiddleware, rateLimitMiddleware, realIPWith } from "./hardening";
|
|
29
|
+
import { backupActive } from "./backups";
|
|
30
|
+
import { maintenanceIfDue } from "./crons";
|
|
31
|
+
import { attachJobs } from "./jobs";
|
|
32
|
+
import { attachHub } from "./realtime/hub-client";
|
|
33
|
+
import { sendMail } from "./mail";
|
|
34
|
+
import { s3Bucket } from "./storage/s3";
|
|
35
|
+
import { installServices, RequestEvent, authToHookRecord, hookStore } from "./hooks/runtime";
|
|
36
|
+
import { CollectionRef, HookRecord } from "./hooks/record";
|
|
37
|
+
import { saveHookRecord } from "./records/service";
|
|
38
|
+
import { compileFilter, FilterError, renderJoin } from "./filter/compile";
|
|
39
|
+
import { FilterSyntaxError } from "./filter/lexer";
|
|
40
|
+
import { recordToJSON } from "./records/json";
|
|
41
|
+
import { connect as realtimeConnect, setSubscriptions as realtimeSetSubscriptions } from "./realtime";
|
|
42
|
+
import oauth2Providers from "./collections/oauth2-providers.json";
|
|
43
|
+
import scaffolds from "./collections/scaffolds.json";
|
|
44
|
+
import { all, ident, one } from "./db";
|
|
45
|
+
import { ApiError, badRequest, forbidden, notFound } from "./errors";
|
|
46
|
+
import { randomIdSuffix, randomString } from "./ids";
|
|
47
|
+
import { createCollection, deleteCollection, importCollections, inferViewFields, truncateCollection, updateCollection } from "./collections/service";
|
|
48
|
+
import { loadSettings, publicSettings } from "./settings";
|
|
49
|
+
import type { AppEnv, Row } from "./types";
|
|
50
|
+
|
|
51
|
+
export const app = new Hono<AppEnv>();
|
|
52
|
+
let served = false; // onBootstrap / onServe fire once per isolate, on the first request
|
|
53
|
+
|
|
54
|
+
app.use("*", cors({ origin: "*", allowHeaders: ["Authorization", "Content-Type"], allowMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS", "HEAD"] }));
|
|
55
|
+
|
|
56
|
+
// PocketBase's default security headers.
|
|
57
|
+
app.use("*", async (c, next) => {
|
|
58
|
+
await next();
|
|
59
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
60
|
+
c.header("X-Frame-Options", "SAMEORIGIN");
|
|
61
|
+
c.header("X-Xss-Protection", "1; mode=block");
|
|
62
|
+
c.header("Cross-Origin-Opener-Policy", "same-origin");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
app.use("*", async (c, next) => {
|
|
66
|
+
await ensureBootstrapped(c.env.DB, (db) => applyPendingMigrations(db, hookGlobals(), c.env));
|
|
67
|
+
// settings.s3 swaps the file storage for an S3 bucket; everything downstream keeps using c.env.STORAGE
|
|
68
|
+
const s3 = (await loadSettings(c.env.DB)).s3;
|
|
69
|
+
if (s3.enabled) c.env = { ...c.env, STORAGE: s3Bucket(s3) };
|
|
70
|
+
attachJobs(c.env);
|
|
71
|
+
attachHub(c.env);
|
|
72
|
+
// onBootstrap/onServe handlers may use $app (find/save records and collections) like PocketBase's, so they run inside a hook store
|
|
73
|
+
if (!served) { served = true; await withHookStore(c.env.DB, c.env, async () => { await trigger("onBootstrap", { app: undefined as unknown, next: async () => undefined as unknown }, null, async () => undefined); await trigger("onServe", { app: undefined as unknown, router: app, next: async () => undefined as unknown }, null, async () => undefined); }); }
|
|
74
|
+
c.set("auth", await loadAuth(c));
|
|
75
|
+
try { maintenanceIfDue(c.env, (p) => c.executionCtx.waitUntil(p)); } catch { /* no execution context */ }
|
|
76
|
+
await next();
|
|
77
|
+
});
|
|
78
|
+
app.use("*", requestLogger());
|
|
79
|
+
app.use("*", bodyLimitMiddleware());
|
|
80
|
+
app.use("*", rateLimitMiddleware());
|
|
81
|
+
app.use("*", hookMiddleware() as never);
|
|
82
|
+
|
|
83
|
+
app.onError((err, c) => {
|
|
84
|
+
if (err instanceof ApiError) return err.response();
|
|
85
|
+
const details = { method: c.req.method, path: c.req.path, error: err instanceof Error ? `${err.name}: ${err.message}` : String(err), stack: err instanceof Error ? err.stack : undefined };
|
|
86
|
+
logger.error("voidbase: unhandled error", details);
|
|
87
|
+
const webhook = String((voidEnv as Record<string, unknown>).VOIDBASE_ALERT_WEBHOOK_URL ?? "").trim();
|
|
88
|
+
if (webhook) {
|
|
89
|
+
const alert = fetch(webhook, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ source: "voidbase", level: "error", message: "unhandled request error", status: 500, time: new Date().toISOString(), ...details }) }).catch((e) => console.error("voidbase: alert webhook failed", e));
|
|
90
|
+
try { c.executionCtx.waitUntil(alert); } catch { /* no execution context (tests) */ }
|
|
91
|
+
}
|
|
92
|
+
return c.json({ data: {}, message: "Something went wrong while processing your request.", status: 500 }, 500);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
app.notFound((c) => c.json(notFound().toJSON(), 404));
|
|
96
|
+
|
|
97
|
+
// --- health ---------------------------------------------------------------
|
|
98
|
+
app.get("/api/health", async (c) => {
|
|
99
|
+
const auth = c.get("auth");
|
|
100
|
+
let data: Record<string, unknown> = {};
|
|
101
|
+
if (isSuperuser(auth)) {
|
|
102
|
+
const settings = await loadSettings(c.env.DB);
|
|
103
|
+
// apis/health.go: remind superusers behind an unconfigured reverse proxy. On Workers CF-Connecting-IP is set
|
|
104
|
+
// by the platform itself and already used as the real IP, so it is not a "possible" proxy header here.
|
|
105
|
+
const headers = [...settings.trustedProxy.headers, "Fly-Client-IP", "X-Forwarded-For"];
|
|
106
|
+
data = {
|
|
107
|
+
canBackup: !(await backupActive(c.env.DB)),
|
|
108
|
+
possibleProxyHeader: headers.find((h) => !!c.req.header(h)) ?? "",
|
|
109
|
+
realIP: realIPWith(settings, c),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return c.json({ message: "API is healthy.", code: 200, data });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// --- settings -------------------------------------------------------------
|
|
116
|
+
app.get("/api/settings", async (c) => {
|
|
117
|
+
requireSuperuser(c);
|
|
118
|
+
return requestHookResult("onSettingsListRequest", c, null, { settings: structuredClone(await loadSettings(c.env.DB)) }, async (ev) => publicSettings(ev.settings as Settings));
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// --- collections ----------------------------------------------------------
|
|
122
|
+
app.get("/api/collections/meta/oauth2-providers", (c) => {
|
|
123
|
+
requireSuperuser(c);
|
|
124
|
+
return c.json(oauth2Providers);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Scaffolds are returned as PocketBase returns them: default definitions without token secrets,
|
|
128
|
+
// and with a fresh random suffix on the default index names (PocketBase regenerates them per request).
|
|
129
|
+
app.get("/api/collections/meta/scaffolds", (c) => {
|
|
130
|
+
requireSuperuser(c);
|
|
131
|
+
const out = structuredClone(scaffolds) as Record<string, { indexes?: string[] }>;
|
|
132
|
+
const suffix = randomIdSuffix();
|
|
133
|
+
for (const scaffold of Object.values(out)) {
|
|
134
|
+
scaffold.indexes = (scaffold.indexes ?? []).map((idx) => idx.replace(/_[a-z0-9]{10}`/, `_${suffix}\``));
|
|
135
|
+
}
|
|
136
|
+
return c.json(out);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const COLLECTIONS_META: Collection = {
|
|
140
|
+
id: "_collections", name: "_collections", type: "base", system: true, listRule: null, viewRule: null, createRule: null, updateRule: null, deleteRule: null, indexes: [], options: {}, created: "", updated: "",
|
|
141
|
+
fields: [
|
|
142
|
+
{ id: "text_id", name: "id", type: "text", system: true, required: true, hidden: false, presentable: false },
|
|
143
|
+
{ id: "text_name", name: "name", type: "text", system: true, required: false, hidden: false, presentable: false },
|
|
144
|
+
{ id: "text_type", name: "type", type: "text", system: true, required: false, hidden: false, presentable: false },
|
|
145
|
+
{ id: "bool_system", name: "system", type: "bool", system: true, required: false, hidden: false, presentable: false },
|
|
146
|
+
{ id: "date_created", name: "created", type: "date", system: true, required: false, hidden: false, presentable: false },
|
|
147
|
+
{ id: "date_updated", name: "updated", type: "date", system: true, required: false, hidden: false, presentable: false },
|
|
148
|
+
] as unknown as Collection["fields"],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// POST /api/collections/meta/dry-run-view (apis/collection.go collectionDryRunView): inferred fields + up to 10 sample rows
|
|
152
|
+
app.post("/api/collections/meta/dry-run-view", async (c) => {
|
|
153
|
+
requireSuperuser(c);
|
|
154
|
+
const body = await readJSON(c, "An error occurred while loading the submitted data.");
|
|
155
|
+
const query = String(body.query ?? "");
|
|
156
|
+
if (!query) throw new ApiError(400, "An error occurred while validating the submitted data.", { query: { code: "validation_required", message: "Cannot be blank." } } as never);
|
|
157
|
+
if (query.length > 5000) throw new ApiError(400, "An error occurred while validating the submitted data.", { query: { code: "validation_length_too_long", message: "The length must be no more than 5000.", params: { max: 5000, min: 0 } } } as never);
|
|
158
|
+
const collections = await listCollections(c.env.DB);
|
|
159
|
+
let fields: Field[]; let rows: Row[];
|
|
160
|
+
try {
|
|
161
|
+
fields = await inferViewFields(c.env.DB, query, new Map(collections.flatMap((x) => [[x.id, x], [x.name, x]] as [string, Collection][])));
|
|
162
|
+
rows = (await c.env.DB.prepare(`SELECT * FROM (${query.trim().replace(/;\s*$/, "")}) LIMIT 10`).all<Row>()).results;
|
|
163
|
+
} catch (err) { throw badRequest("Invalid view query. Raw error: \n" + (err instanceof Error ? err.message : String(err))); }
|
|
164
|
+
const tmpName = `temp_view_${randomString(5)}`;
|
|
165
|
+
const tmp = { ...COLLECTIONS_META, id: tmpName, name: tmpName, type: "view", fields } as Collection;
|
|
166
|
+
return c.json({ fields, sample: rows.map((r) => recordToJSON(tmp, r)) });
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
app.get("/api/collections", async (c) => {
|
|
170
|
+
requireSuperuser(c);
|
|
171
|
+
const { page, perPage, skipTotal } = paging(c);
|
|
172
|
+
let items = await listCollections(c.env.DB);
|
|
173
|
+
const filter = (c.req.query("filter") ?? "").trim();
|
|
174
|
+
if (filter) {
|
|
175
|
+
// search.NewSimpleFieldResolver("id", "created", "updated", "name", "system", "type") over the _collections table
|
|
176
|
+
let compiled: { where: string; params: unknown[] };
|
|
177
|
+
try { compiled = compileFilter(filter, { base: COLLECTIONS_META, baseTable: "_collections", collections: new Map(), request: { auth: null, method: "GET", query: {}, headers: {}, body: {}, context: "default" }, allowHiddenFields: true }); }
|
|
178
|
+
catch (err) { if (err instanceof FilterError || err instanceof FilterSyntaxError) throw badRequest(); throw err; }
|
|
179
|
+
const ids = new Set((await c.env.DB.prepare(`SELECT id FROM \`_collections\` WHERE ${compiled.where}`).bind(...compiled.params).all<{ id: string }>()).results.map((r) => r.id));
|
|
180
|
+
items = items.filter((i) => ids.has(i.id));
|
|
181
|
+
}
|
|
182
|
+
const sort = c.req.query("sort") ?? "";
|
|
183
|
+
if (sort) items = sortBy(items, sort, ["name", "type", "system", "created", "updated", "id"]);
|
|
184
|
+
return requestHookResult("onCollectionsListRequest", c, null, { collections: items.map((i) => new CollectionRef(i)) }, async (ev) => {
|
|
185
|
+
const list = (ev.collections as CollectionRef[]).map((r) => r.data);
|
|
186
|
+
const total = list.length;
|
|
187
|
+
return { items: list.slice((page - 1) * perPage, page * perPage).map(collectionToJSON), page, perPage, totalItems: skipTotal ? -1 : total, totalPages: skipTotal ? -1 : Math.ceil(total / perPage) };
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
app.get("/api/collections/:collection", async (c) => {
|
|
192
|
+
requireSuperuser(c);
|
|
193
|
+
const collection = await mustFindCollection(c, c.req.param("collection"), true);
|
|
194
|
+
return requestHookResult("onCollectionViewRequest", c, collection.name, { collection: new CollectionRef(collection) }, async (ev) => collectionToJSON((ev.collection as CollectionRef).data));
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
app.post("/api/collections", async (c) => {
|
|
198
|
+
requireSuperuser(c);
|
|
199
|
+
const body = await readJSON(c, "Failed to load the collection type data due to invalid formatting.");
|
|
200
|
+
return requestHookResult("onCollectionCreateRequest", c, String(body.name ?? ""), { collection: new CollectionRef(body) }, async (ev) => collectionToJSON(await createCollection(c.env.DB, (ev.collection as CollectionRef).toRaw())));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
app.patch("/api/collections/:collection", async (c) => {
|
|
204
|
+
requireSuperuser(c);
|
|
205
|
+
const collection = await mustFindCollection(c, c.req.param("collection"), true);
|
|
206
|
+
const body = await readJSON(c);
|
|
207
|
+
return requestHookResult("onCollectionUpdateRequest", c, collection.name, { collection: new CollectionRef({ ...collectionToJSON(collection), ...body }) }, async (ev) => collectionToJSON(await updateCollection(c.env.DB, collection, (ev.collection as CollectionRef).toRaw())));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
app.delete("/api/collections/:collection", async (c) => {
|
|
211
|
+
requireSuperuser(c);
|
|
212
|
+
const collection = await mustFindCollection(c, c.req.param("collection"), true);
|
|
213
|
+
return requestHook("onCollectionDeleteRequest", c, collection.name, { collection: new CollectionRef(collection) }, async () => {
|
|
214
|
+
await deleteCollection(c.env.DB, collection);
|
|
215
|
+
try { await deletePrefix(c.env.STORAGE, `${collection.id}/`); } catch (err) { console.error("voidbase: file cleanup failed", err); }
|
|
216
|
+
return c.body(null, 204);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
app.delete("/api/collections/:collection/truncate", async (c) => {
|
|
221
|
+
requireSuperuser(c);
|
|
222
|
+
const collection = await mustFindCollection(c, c.req.param("collection"), true);
|
|
223
|
+
await truncateCollection(c.env.DB, collection);
|
|
224
|
+
try { await deletePrefix(c.env.STORAGE, `${collection.id}/`); } catch (err) { console.error("voidbase: file cleanup failed", err); }
|
|
225
|
+
return c.body(null, 204);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
app.put("/api/collections/import", async (c) => {
|
|
229
|
+
requireSuperuser(c);
|
|
230
|
+
const body = await readJSON(c, "An error occurred while loading the submitted data.");
|
|
231
|
+
const items = body.collections;
|
|
232
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
233
|
+
throw badRequest("An error occurred while validating the submitted data.", { collections: { code: "validation_required", message: "Cannot be blank." } });
|
|
234
|
+
}
|
|
235
|
+
return requestHook("onCollectionsImportRequest", c, null, { collections: items, deleteMissing: !!body.deleteMissing }, async (ev) => {
|
|
236
|
+
await importCollections(c.env.DB, ev.collections as Record<string, unknown>[], !!ev.deleteMissing);
|
|
237
|
+
return c.body(null, 204);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// --- records: auth --------------------------------------------------------
|
|
242
|
+
app.post("/api/collections/:collection/auth-with-password", async (c) => {
|
|
243
|
+
const collection = await mustFindCollection(c, c.req.param("collection"));
|
|
244
|
+
return authWithPassword(c, collection);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
app.post("/api/collections/:collection/auth-with-oauth2", async (c) => {
|
|
248
|
+
const collection = await mustFindCollection(c, c.req.param("collection"));
|
|
249
|
+
if (collection.type !== "auth") throw notFound("Missing or invalid auth collection context.");
|
|
250
|
+
return authWithOAuth2(c, collection, await recordContext(c));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
app.post("/api/collections/:collection/auth-refresh", async (c) => {
|
|
254
|
+
const collection = await mustFindCollection(c, c.req.param("collection"));
|
|
255
|
+
return authRefresh(c, collection);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
app.get("/api/collections/:collection/auth-methods", async (c) => {
|
|
259
|
+
const collection = await mustFindCollection(c, c.req.param("collection"));
|
|
260
|
+
return authMethods(c, collection);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// --- records --------------------------------------------------------------
|
|
264
|
+
export async function recordContextFor(c: Context<AppEnv>): Promise<RecordContext> { return recordContext(c); }
|
|
265
|
+
|
|
266
|
+
async function recordContext(c: Context<AppEnv>): Promise<RecordContext> {
|
|
267
|
+
const auth = c.get("auth");
|
|
268
|
+
const headers: Record<string, string> = {};
|
|
269
|
+
c.req.raw.headers.forEach((v, k) => { headers[k.toLowerCase().replace(/-/g, "_")] = v; });
|
|
270
|
+
const query: Record<string, string> = {};
|
|
271
|
+
new URL(c.req.url).searchParams.forEach((v, k) => { query[k] = v; });
|
|
272
|
+
return {
|
|
273
|
+
db: c.env.DB,
|
|
274
|
+
storage: c.env.STORAGE,
|
|
275
|
+
auth,
|
|
276
|
+
superuser: isSuperuser(auth),
|
|
277
|
+
request: { auth: auth ? { collection: auth.collection, row: auth.row } : null, method: c.req.method, query, headers, body: {}, context: c.req.header(BATCH_CONTEXT_HEADER) === batchContextToken() ? "batch" : "default" },
|
|
278
|
+
collections: await loadCollections(c.env.DB),
|
|
279
|
+
waitUntil: (p) => { try { c.executionCtx.waitUntil(p); } catch { void p; } },
|
|
280
|
+
hookEvent: (record, collection) => Object.assign(new RequestEvent(c, authToHookRecord(auth)), { record, collection: new CollectionRef(collection) }),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function readRecordBody(c: Context<AppEnv>): Promise<Record<string, unknown>> {
|
|
285
|
+
const ct = c.req.header("content-type") ?? "";
|
|
286
|
+
try {
|
|
287
|
+
if (ct.includes("multipart/form-data") || ct.includes("application/x-www-form-urlencoded")) {
|
|
288
|
+
const parsed = (await c.req.parseBody({ all: true })) as Record<string, unknown>;
|
|
289
|
+
// PocketBase merges a "@jsonPayload" form field (JSON) with the other fields and files
|
|
290
|
+
if (typeof parsed["@jsonPayload"] === "string") {
|
|
291
|
+
const payload = JSON.parse(parsed["@jsonPayload"] as string) as Record<string, unknown>;
|
|
292
|
+
delete parsed["@jsonPayload"];
|
|
293
|
+
return { ...payload, ...parsed };
|
|
294
|
+
}
|
|
295
|
+
return parsed;
|
|
296
|
+
}
|
|
297
|
+
const text = await c.req.text();
|
|
298
|
+
if (!text.trim()) return {};
|
|
299
|
+
const v = JSON.parse(text);
|
|
300
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not an object");
|
|
301
|
+
return v as Record<string, unknown>;
|
|
302
|
+
} catch {
|
|
303
|
+
throw badRequest(); // PocketBase's RequestInfo body loading fails with the generic message
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const listQuery = (c: Context<AppEnv>): ListQuery => ({
|
|
308
|
+
page: Number(c.req.query("page") ?? 1) || 1,
|
|
309
|
+
perPage: Number(c.req.query("perPage") ?? 30) || 30,
|
|
310
|
+
skipTotal: c.req.query("skipTotal") === "1" || c.req.query("skipTotal") === "true",
|
|
311
|
+
sort: c.req.query("sort") ?? "",
|
|
312
|
+
filter: c.req.query("filter") ?? "",
|
|
313
|
+
expand: c.req.query("expand") ?? "",
|
|
314
|
+
fields: c.req.query("fields") ?? "",
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
app.get("/api/collections/:collection/records", async (c) => {
|
|
318
|
+
const collection = await mustFindCollection(c, c.req.param("collection"));
|
|
319
|
+
const ctx = await recordContext(c);
|
|
320
|
+
return requestHookResult("onRecordsListRequest", c, collection.name, { collection: new CollectionRef(collection), records: null }, async () => listRecords(ctx, collection, listQuery(c)));
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
app.get("/api/collections/:collection/records/:id", async (c) => {
|
|
324
|
+
const collection = await mustFindCollection(c, c.req.param("collection"));
|
|
325
|
+
const ctx = await recordContext(c);
|
|
326
|
+
return requestHookResult("onRecordViewRequest", c, collection.name, { collection: new CollectionRef(collection), record: null }, async () => viewRecord(ctx, collection, c.req.param("id"), { expand: c.req.query("expand"), fields: c.req.query("fields") }));
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
app.post("/api/collections/:collection/records", async (c) => {
|
|
330
|
+
const collection = await mustFindCollection(c, c.req.param("collection"));
|
|
331
|
+
const ctx = await recordContext(c);
|
|
332
|
+
const body = await readRecordBody(c);
|
|
333
|
+
return c.json(await createRecord(ctx, collection, body, { expand: c.req.query("expand"), fields: c.req.query("fields") }));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
app.patch("/api/collections/:collection/records/:id", async (c) => {
|
|
337
|
+
const collection = await mustFindCollection(c, c.req.param("collection"));
|
|
338
|
+
const ctx = await recordContext(c);
|
|
339
|
+
const body = await readRecordBody(c);
|
|
340
|
+
return c.json(await updateRecord(ctx, collection, c.req.param("id"), body, { expand: c.req.query("expand"), fields: c.req.query("fields") }));
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
app.delete("/api/collections/:collection/records/:id", async (c) => {
|
|
344
|
+
const collection = await mustFindCollection(c, c.req.param("collection"));
|
|
345
|
+
const ctx = await recordContext(c);
|
|
346
|
+
await deleteRecord(ctx, collection, c.req.param("id"));
|
|
347
|
+
return c.body(null, 204);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// --- realtime -------------------------------------------------------------
|
|
351
|
+
app.get("/api/realtime", (c) => requestHook("onRealtimeConnectRequest", c, null, { client: null, idleTimeout: 300 }, () => realtimeConnect(c)));
|
|
352
|
+
app.post("/api/realtime", async (c) => {
|
|
353
|
+
let body: { clientId?: string; subscriptions?: string[] } = {};
|
|
354
|
+
try { const ct = c.req.header("content-type") ?? ""; body = ct.includes("json") ? await c.req.json() : (Object.fromEntries((await c.req.formData()).entries()) as unknown as typeof body); } catch { throw badRequest(); }
|
|
355
|
+
return requestHook("onRealtimeSubscribeRequest", c, null, { client: { id: String(body.clientId ?? "") }, subscriptions: Array.isArray(body.subscriptions) ? body.subscriptions : [] }, (ev) => realtimeSetSubscriptions(c, { clientId: String(body.clientId ?? ""), subscriptions: ev.subscriptions as string[] }));
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// --- files ----------------------------------------------------------------
|
|
359
|
+
app.get("/api/files/:collection/:recordId/:filename", async (c) => {
|
|
360
|
+
const collection = await findCollection(c.env.DB, c.req.param("collection"));
|
|
361
|
+
if (!collection) throw notFound("Missing or invalid collection context.");
|
|
362
|
+
const row = await one(c.env.DB, `SELECT * FROM ${ident(collection.name)} WHERE id = ? LIMIT 1`, [c.req.param("recordId")]);
|
|
363
|
+
if (!row) throw notFound();
|
|
364
|
+
const filename = c.req.param("filename");
|
|
365
|
+
const field = (collection.fields as Field[]).find((f) => {
|
|
366
|
+
if (f.type !== "file") return false;
|
|
367
|
+
const v = fromColumn(f, row[f.name]);
|
|
368
|
+
return Array.isArray(v) ? v.includes(filename) : v === filename;
|
|
369
|
+
});
|
|
370
|
+
if (!field) throw notFound();
|
|
371
|
+
if (field.protected && !(await protectedAccess(c, await recordContext(c), collection, row))) throw notFound();
|
|
372
|
+
const key = `${collection.id}/${row.id}/${filename}`;
|
|
373
|
+
let served: Awaited<ReturnType<typeof resolveServedFile>>;
|
|
374
|
+
try {
|
|
375
|
+
served = await resolveServedFile(c.env.STORAGE, key, filename, c.req.query("thumb") ?? "", ((field.thumbs as string[] | null | undefined) ?? []), c.req.header("Range"));
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (err instanceof RangeNotSatisfiable) {
|
|
378
|
+
const disposition = !parseBool(c.req.query("download")) && INLINE_SERVE_CONTENT_TYPES.includes(err.contentType) ? "inline" : "attachment";
|
|
379
|
+
return new Response("invalid range: failed to overlap\n", { status: 416, headers: { "Content-Range": `bytes */${err.size}`, "Content-Disposition": `${disposition}; filename=${JSON.stringify(err.name)}`, "Content-Type": "text/plain; charset=utf-8" } });
|
|
380
|
+
}
|
|
381
|
+
throw err;
|
|
382
|
+
}
|
|
383
|
+
if (!served) throw notFound();
|
|
384
|
+
return requestHook("onFileDownloadRequest", c, collection.name, { collection: new CollectionRef(collection), record: HookRecord.fromRow(collection, row), fileField: field, servedPath: key, servedName: served.name }, async (ev) => {
|
|
385
|
+
served = { ...served!, name: String(ev.servedName ?? served!.name) };
|
|
386
|
+
// PocketBase (tools/filesystem Serve): inline only for known-safe media types unless ?download=true,
|
|
387
|
+
// a few extensions override the sniffed content type, filename quoted, then http.ServeContent semantics.
|
|
388
|
+
const forceAttachment = parseBool(c.req.query("download"));
|
|
389
|
+
const disposition = !forceAttachment && INLINE_SERVE_CONTENT_TYPES.includes(served.contentType) ? "inline" : "attachment";
|
|
390
|
+
const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".")).toLowerCase() : "";
|
|
391
|
+
const contentType = MANUAL_EXTENSION_CONTENT_TYPES[ext] ?? served.contentType;
|
|
392
|
+
const headers = new Headers();
|
|
393
|
+
headers.set("Content-Disposition", `${disposition}; filename=${JSON.stringify(served.name)}`);
|
|
394
|
+
headers.set("Content-Type", contentType);
|
|
395
|
+
headers.set("Content-Security-Policy", "default-src 'none'; media-src 'self'; style-src 'unsafe-inline'; sandbox");
|
|
396
|
+
headers.set("Cache-Control", "max-age=2592000, stale-while-revalidate=86400");
|
|
397
|
+
headers.set("Last-Modified", served.uploaded.toUTCString());
|
|
398
|
+
headers.set("Accept-Ranges", "bytes");
|
|
399
|
+
headers.set("Vary", "Origin");
|
|
400
|
+
const ims = c.req.header("If-Modified-Since");
|
|
401
|
+
if (ims && !c.req.header("Range")) {
|
|
402
|
+
const since = Date.parse(ims);
|
|
403
|
+
if (!Number.isNaN(since) && Math.floor(served.uploaded.getTime() / 1000) <= Math.floor(since / 1000)) return new Response(null, { status: 304, headers });
|
|
404
|
+
}
|
|
405
|
+
if (served.range) {
|
|
406
|
+
headers.set("Content-Range", `bytes ${served.range.offset}-${served.range.offset + served.range.length - 1}/${served.size}`);
|
|
407
|
+
headers.set("Content-Length", String(served.range.length));
|
|
408
|
+
return new Response(served.body as BodyInit, { status: 206, headers });
|
|
409
|
+
}
|
|
410
|
+
headers.set("Content-Length", String(served.size));
|
|
411
|
+
return new Response(served.body as BodyInit, { headers });
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// tools/filesystem/filesystem.go
|
|
416
|
+
const INLINE_SERVE_CONTENT_TYPES = [
|
|
417
|
+
"image/png", "image/jpg", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/bmp",
|
|
418
|
+
"video/webm", "video/mp4", "video/3gpp", "video/quicktime", "video/x-ms-wmv",
|
|
419
|
+
"audio/basic", "audio/aiff", "audio/mpeg", "audio/midi", "audio/mp3", "audio/wave", "audio/wav", "audio/x-wav", "audio/x-mpeg", "audio/x-m4a", "audio/aac",
|
|
420
|
+
"application/pdf", "application/x-pdf",
|
|
421
|
+
];
|
|
422
|
+
const MANUAL_EXTENSION_CONTENT_TYPES: Record<string, string> = {
|
|
423
|
+
".svg": "image/svg+xml", ".css": "text/css", ".js": "text/javascript", ".mjs": "text/javascript",
|
|
424
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
425
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
426
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
427
|
+
};
|
|
428
|
+
// strconv.ParseBool
|
|
429
|
+
const parseBool = (v: string | undefined) => v !== undefined && ["1", "t", "T", "TRUE", "true", "True"].includes(v);
|
|
430
|
+
|
|
431
|
+
// --- helpers --------------------------------------------------------------
|
|
432
|
+
async function readJSON(c: Context<AppEnv>, message = "Failed to load the submitted data due to invalid formatting."): Promise<Record<string, unknown>> {
|
|
433
|
+
try {
|
|
434
|
+
const v = await c.req.json();
|
|
435
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("not an object");
|
|
436
|
+
return v as Record<string, unknown>;
|
|
437
|
+
} catch {
|
|
438
|
+
throw badRequest(message);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function paging(c: Context<AppEnv>) {
|
|
443
|
+
const page = Math.max(1, Number(c.req.query("page") ?? 1) || 1);
|
|
444
|
+
const perPage = Math.min(500, Math.max(1, Number(c.req.query("perPage") ?? 30) || 30));
|
|
445
|
+
const st = c.req.query("skipTotal");
|
|
446
|
+
return { page, perPage, skipTotal: st === "1" || st === "true" };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Record routes report "Missing collection context."; collection routes use the default 404 (as PocketBase does).
|
|
450
|
+
async function mustFindCollection(c: Context<AppEnv>, idOrName: string, collectionRoute = false): Promise<Collection> {
|
|
451
|
+
const collection = await findCollection(c.env.DB, idOrName);
|
|
452
|
+
if (!collection) throw collectionRoute ? notFound() : notFound("Missing collection context.");
|
|
453
|
+
return collection;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function sortBy<T extends object>(items: T[], sort: string, allowed: string[]): T[] {
|
|
457
|
+
const keys = sort.split(",").map((s) => s.trim()).filter(Boolean);
|
|
458
|
+
const out = [...items];
|
|
459
|
+
for (const k of keys.reverse()) {
|
|
460
|
+
const desc = k.startsWith("-");
|
|
461
|
+
const name = k.replace(/^[+-]/, "");
|
|
462
|
+
if (!allowed.includes(name)) throw badRequest(`Invalid sort field "${name}".`);
|
|
463
|
+
out.sort((a, b) => {
|
|
464
|
+
const x = (a as Record<string, unknown>)[name] as string | number | boolean;
|
|
465
|
+
const y = (b as Record<string, unknown>)[name] as string | number | boolean;
|
|
466
|
+
const r = x < y ? -1 : x > y ? 1 : 0;
|
|
467
|
+
return desc ? -r : r;
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// --- passkeys (the starter's Go webauthn routes, native here) ---------------
|
|
474
|
+
mountOAuth2Redirect(app);
|
|
475
|
+
mountSettingsApi(app);
|
|
476
|
+
const authDeps = {
|
|
477
|
+
collection: async (c: Context<AppEnv>) => { const coll = await mustFindCollection(c, c.req.param("collection") ?? ""); if (coll.type !== "auth") throw notFound("Missing or invalid auth collection context."); return coll; },
|
|
478
|
+
ctx: (c: Context<AppEnv>) => recordContext(c),
|
|
479
|
+
};
|
|
480
|
+
mountAuthFlows(app, authDeps);
|
|
481
|
+
mountAuthExtra(app, authDeps);
|
|
482
|
+
mountFilesApi(app);
|
|
483
|
+
mountBatch(app);
|
|
484
|
+
mountLogsApi(app);
|
|
485
|
+
mountCronsApi(app);
|
|
486
|
+
mountBackupsApi(app);
|
|
487
|
+
mountSqlApi(app);
|
|
488
|
+
|
|
489
|
+
// --- pb_hooks runtime ------------------------------------------------------
|
|
490
|
+
const valuesToRowFor = (c: Collection, values: Record<string, unknown>): Row => { const row: Row = {}; for (const f of c.fields as Field[]) row[f.name] = toColumn(f, values[f.name]); return row; };
|
|
491
|
+
// hook code that changes the schema must see the change in the same run ($app.findCollectionByNameOrId)
|
|
492
|
+
async function refreshStoreCollections(store: { collections: Map<string, Collection> }, db: D1Database) {
|
|
493
|
+
invalidateCollections();
|
|
494
|
+
const fresh = await loadCollections(db);
|
|
495
|
+
store.collections.clear();
|
|
496
|
+
for (const [k, v] of fresh) store.collections.set(k, v);
|
|
497
|
+
}
|
|
498
|
+
installServices({
|
|
499
|
+
saveRecord: async (rec) => saveHookRecord(await hookStore.getStore()!.ctx(), rec),
|
|
500
|
+
deleteRecord: async (rec) => { const ctx = await hookStore.getStore()!.ctx(); await deleteRecord({ ...ctx, superuser: true, hookEvent: undefined }, rec.collection().data, rec.id); },
|
|
501
|
+
findRecordById: async (collection, id) => {
|
|
502
|
+
const ctx = await hookStore.getStore()!.ctx();
|
|
503
|
+
const coll = ctx.collections.get(collection);
|
|
504
|
+
if (!coll) return null;
|
|
505
|
+
const row = await one(ctx.db, `SELECT * FROM ${ident(coll.name)} WHERE id = ? LIMIT 1`, [id]);
|
|
506
|
+
return row ? HookRecord.fromRow(coll, row) : null;
|
|
507
|
+
},
|
|
508
|
+
findRecordsByFilter: async (collection, filter, sort, limit, offset, params) => {
|
|
509
|
+
const ctx = await hookStore.getStore()!.ctx();
|
|
510
|
+
const coll = ctx.collections.get(collection);
|
|
511
|
+
if (!coll) return [];
|
|
512
|
+
let where = "1=1"; let ps: unknown[] = []; let joins = "";
|
|
513
|
+
if (filter.trim()) {
|
|
514
|
+
const bound = filter.replace(/\{:(\w+)\}/g, (_m, k: string) => JSON.stringify(params?.[k] ?? ""));
|
|
515
|
+
const compiled = compileFilter(bound, { base: coll, collections: ctx.collections, request: ctx.request, allowHiddenFields: true });
|
|
516
|
+
where = compiled.where; ps = compiled.params; joins = compiled.joins.map(renderJoin).join(" ");
|
|
517
|
+
}
|
|
518
|
+
const order = sort.trim() ? sort.split(",").map((s) => { const d = s.trim().startsWith("-"); const n = s.trim().replace(/^[+-]/, ""); return `${ident(coll.name)}.${ident(n)} ${d ? "DESC" : "ASC"}`; }).join(", ") : `${ident(coll.name)}.rowid ASC`;
|
|
519
|
+
const rows = await all(ctx.db, `SELECT DISTINCT ${ident(coll.name)}.* FROM ${ident(coll.name)} ${joins} WHERE ${where} ORDER BY ${order} LIMIT ? OFFSET ?`, [...ps, limit > 0 ? limit : 1000, offset]);
|
|
520
|
+
return rows.map((r: Row) => HookRecord.fromRow(coll, r));
|
|
521
|
+
},
|
|
522
|
+
countRecords: async (collection, where) => {
|
|
523
|
+
const ctx = await hookStore.getStore()!.ctx();
|
|
524
|
+
const coll = ctx.collections.get(collection);
|
|
525
|
+
if (!coll) throw new Error(`sql: no rows in result set (collection "${collection}")`);
|
|
526
|
+
let sql = "1=1"; let ps: unknown[] = []; let joins = "";
|
|
527
|
+
if (typeof where === "string" && where.trim()) {
|
|
528
|
+
const compiled = compileFilter(where, { base: coll, collections: ctx.collections, request: ctx.request, allowHiddenFields: true });
|
|
529
|
+
sql = compiled.where; ps = compiled.params; joins = compiled.joins.map(renderJoin).join(" ");
|
|
530
|
+
} else if (where && typeof where === "object") {
|
|
531
|
+
// dbx expression: [[col]] quoting and {:name} params
|
|
532
|
+
const params: unknown[] = [];
|
|
533
|
+
sql = where.sql.replace(/\[\[(\w+)\]\]/g, (_m, col: string) => ident(col)).replace(/\{:(\w+)\}/g, (_m, k: string) => { params.push(where.params[k] ?? null); return "?"; });
|
|
534
|
+
ps = params;
|
|
535
|
+
}
|
|
536
|
+
const row = await one<{ n: number }>(ctx.db, `SELECT COUNT(DISTINCT ${ident(coll.name)}.id) AS n FROM ${ident(coll.name)} ${joins} WHERE ${sql}`, ps);
|
|
537
|
+
return Number(row?.n ?? 0);
|
|
538
|
+
},
|
|
539
|
+
findAuthRecordByEmail: async (collection, email) => {
|
|
540
|
+
const ctx = await hookStore.getStore()!.ctx();
|
|
541
|
+
const coll = ctx.collections.get(collection);
|
|
542
|
+
if (!coll || coll.type !== "auth") return null;
|
|
543
|
+
const row = await one(ctx.db, `SELECT * FROM ${ident(coll.name)} WHERE email = ? LIMIT 1`, [email]);
|
|
544
|
+
return row ? HookRecord.fromRow(coll, row) : null;
|
|
545
|
+
},
|
|
546
|
+
findAuthRecordByToken: async (token, type) => {
|
|
547
|
+
const ctx = await hookStore.getStore()!.ctx();
|
|
548
|
+
const auth = await findAuthRecordByToken(ctx.db, token, type);
|
|
549
|
+
return auth ? HookRecord.fromRow(auth.collection, auth.row) : null;
|
|
550
|
+
},
|
|
551
|
+
expandRecords: async (records, expands) => {
|
|
552
|
+
if (!records.length || !expands.length) return;
|
|
553
|
+
const ctx = await hookStore.getStore()!.ctx();
|
|
554
|
+
const coll = records[0]!.collection().data;
|
|
555
|
+
const rows = records.map((r) => valuesToRowFor(coll, r.fieldsData()));
|
|
556
|
+
const map = await expandRecords({ db: ctx.db, collections: ctx.collections, auth: ctx.auth, superuser: true, request: ctx.request }, coll, rows, expands);
|
|
557
|
+
for (const r of records) { const e = map.get(String(r.id)); if (e && Object.keys(e).length) r.expand = { ...(r.expand ?? {}), ...e }; }
|
|
558
|
+
},
|
|
559
|
+
saveCollection: async (ref) => {
|
|
560
|
+
const store = hookStore.getStore()!;
|
|
561
|
+
const ctx = await store.ctx();
|
|
562
|
+
const raw = ref.toRaw();
|
|
563
|
+
const existing = (raw.id ? ctx.collections.get(String(raw.id)) : undefined) ?? (raw.name ? ctx.collections.get(String(raw.name)) : undefined);
|
|
564
|
+
const saved = existing ? await updateCollection(ctx.db, existing, raw) : await createCollection(ctx.db, raw);
|
|
565
|
+
Object.assign(ref.data, saved);
|
|
566
|
+
await refreshStoreCollections(store, ctx.db);
|
|
567
|
+
return ref;
|
|
568
|
+
},
|
|
569
|
+
deleteCollection: async (ref) => {
|
|
570
|
+
const store = hookStore.getStore()!;
|
|
571
|
+
const ctx = await store.ctx();
|
|
572
|
+
const existing = ctx.collections.get(ref.id) ?? ctx.collections.get(ref.name);
|
|
573
|
+
if (!existing) throw new Error(`sql: no rows in result set (collection "${ref.id || ref.name}")`);
|
|
574
|
+
await deleteCollection(ctx.db, existing);
|
|
575
|
+
try { await deletePrefix(ctx.storage, `${existing.id}/`); } catch (err) { console.error("voidbase: file cleanup failed", err); }
|
|
576
|
+
await refreshStoreCollections(store, ctx.db);
|
|
577
|
+
},
|
|
578
|
+
// $app.newMailClient().send(): synchronous like PocketBase's mailer, errors surface to the hook
|
|
579
|
+
sendMail: async (msg) => { const ctx = await hookStore.getStore()!.ctx(); await sendMail(ctx.db, { from: msg.from, to: msg.to, cc: msg.cc, bcc: msg.bcc, subject: msg.subject, html: msg.html, text: msg.text, headers: msg.headers }, { inline: true }); },
|
|
580
|
+
});
|
|
581
|
+
loadHooks();
|
|
582
|
+
mountHookRoutes(app);
|