dineway 0.1.3
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/LICENSE +9 -0
- package/README.md +89 -0
- package/dist/adapters-BlzWJG82.d.mts +106 -0
- package/dist/apply-CAPvMfoU.mjs +1339 -0
- package/dist/astro/index.d.mts +50 -0
- package/dist/astro/index.mjs +1326 -0
- package/dist/astro/middleware/auth.d.mts +30 -0
- package/dist/astro/middleware/auth.mjs +708 -0
- package/dist/astro/middleware/redirect.d.mts +21 -0
- package/dist/astro/middleware/redirect.mjs +62 -0
- package/dist/astro/middleware/request-context.d.mts +17 -0
- package/dist/astro/middleware/request-context.mjs +1371 -0
- package/dist/astro/middleware/setup.d.mts +19 -0
- package/dist/astro/middleware/setup.mjs +46 -0
- package/dist/astro/middleware.d.mts +12 -0
- package/dist/astro/middleware.mjs +1716 -0
- package/dist/astro/types.d.mts +269 -0
- package/dist/astro/types.mjs +1 -0
- package/dist/base64-F8-DUraK.mjs +58 -0
- package/dist/byline-DeWCMU_i.mjs +234 -0
- package/dist/bylines-DyqBV9EQ.mjs +137 -0
- package/dist/chunk-ClPoSABd.mjs +21 -0
- package/dist/cli/index.d.mts +1 -0
- package/dist/cli/index.mjs +3987 -0
- package/dist/client/external-auth-headers.d.mts +38 -0
- package/dist/client/external-auth-headers.mjs +101 -0
- package/dist/client/index.d.mts +397 -0
- package/dist/client/index.mjs +345 -0
- package/dist/config-Cq8H0SfX.mjs +46 -0
- package/dist/connection-C9pxzuag.mjs +52 -0
- package/dist/content-zSgdNmnt.mjs +836 -0
- package/dist/db/index.d.mts +4 -0
- package/dist/db/index.mjs +62 -0
- package/dist/db/libsql.d.mts +10 -0
- package/dist/db/libsql.mjs +21 -0
- package/dist/db/postgres.d.mts +10 -0
- package/dist/db/postgres.mjs +29 -0
- package/dist/db/sqlite.d.mts +10 -0
- package/dist/db/sqlite.mjs +15 -0
- package/dist/default-WYlzADZL.mjs +80 -0
- package/dist/dialect-helpers-B9uSp2GJ.mjs +89 -0
- package/dist/error-DrxtnGPg.mjs +26 -0
- package/dist/index-C-jx21qs.d.mts +4771 -0
- package/dist/index.d.mts +16 -0
- package/dist/index.mjs +30 -0
- package/dist/load-C6FCD1FU.mjs +27 -0
- package/dist/loader-qKmo0wAY.mjs +446 -0
- package/dist/manifest-schema-CTSEyIJ3.mjs +186 -0
- package/dist/media/index.d.mts +25 -0
- package/dist/media/index.mjs +54 -0
- package/dist/media/local-runtime.d.mts +38 -0
- package/dist/media/local-runtime.mjs +132 -0
- package/dist/media-DMTr80Gv.mjs +199 -0
- package/dist/mode-BlyYtIFO.mjs +22 -0
- package/dist/page/index.d.mts +148 -0
- package/dist/page/index.mjs +419 -0
- package/dist/placeholder-B3knXwNc.mjs +267 -0
- package/dist/placeholder-bOx1xCTY.d.mts +283 -0
- package/dist/plugin-utils.d.mts +57 -0
- package/dist/plugin-utils.mjs +77 -0
- package/dist/plugins/adapt-sandbox-entry.d.mts +21 -0
- package/dist/plugins/adapt-sandbox-entry.mjs +112 -0
- package/dist/query-BiaPl_g2.mjs +459 -0
- package/dist/redirect-JPqLAbxa.mjs +328 -0
- package/dist/registry-DSd1GWB8.mjs +851 -0
- package/dist/request-context.d.mts +49 -0
- package/dist/request-context.mjs +42 -0
- package/dist/runner-B5l1JfOj.d.mts +26 -0
- package/dist/runner-BGUGywgG.mjs +1529 -0
- package/dist/runtime.d.mts +25 -0
- package/dist/runtime.mjs +41 -0
- package/dist/search-BNruJHDL.mjs +11054 -0
- package/dist/seed/index.d.mts +3 -0
- package/dist/seed/index.mjs +15 -0
- package/dist/seo/index.d.mts +69 -0
- package/dist/seo/index.mjs +69 -0
- package/dist/storage/local.d.mts +38 -0
- package/dist/storage/local.mjs +165 -0
- package/dist/storage/s3.d.mts +31 -0
- package/dist/storage/s3.mjs +174 -0
- package/dist/tokens-4vgYuXsZ.mjs +170 -0
- package/dist/transport-C5FYnid7.mjs +417 -0
- package/dist/transport-gIL-e43D.d.mts +41 -0
- package/dist/types-BawVha09.mjs +30 -0
- package/dist/types-BgQeVaPj.d.mts +192 -0
- package/dist/types-CLLdsG3g.d.mts +103 -0
- package/dist/types-D38djUXv.d.mts +1196 -0
- package/dist/types-DShnjzb6.mjs +15 -0
- package/dist/types-DkvMXalq.d.mts +425 -0
- package/dist/types-DuNbGKjF.mjs +74 -0
- package/dist/types-ju-_ORz7.d.mts +182 -0
- package/dist/validate-CXnRKfJK.mjs +327 -0
- package/dist/validate-CqRJb_xU.mjs +96 -0
- package/dist/validate-DVKJJ-M_.d.mts +377 -0
- package/locals.d.ts +47 -0
- package/package.json +313 -0
|
@@ -0,0 +1,1716 @@
|
|
|
1
|
+
import "../connection-C9pxzuag.mjs";
|
|
2
|
+
import { a as isSqlite } from "../dialect-helpers-B9uSp2GJ.mjs";
|
|
3
|
+
import { r as runMigrations } from "../runner-BGUGywgG.mjs";
|
|
4
|
+
import { $ as CronExecutor, $t as handleContentSchedule, At as handleMediaGet, Bt as handleContentCountScheduled, Ct as createPluginBundleStore, Et as PluginStateRepository, Ft as handleRevisionRestore, Gt as handleContentDuplicate, Ht as handleContentCreate, J as devConsoleEmailDeliver, Jt as handleContentList, K as PluginRouteRegistry, Kt as handleContentGet, Mt as handleMediaUpdate, Nt as handleRevisionGet, Ot as handleMediaCreate, Pt as handleRevisionList, Q as resolveExclusiveHooks, Qt as handleContentRestore, Rt as hashString, Ut as handleContentDelete, Vt as handleContentCountTrashed, Wt as handleContentDiscardDraft, Xt as handleContentPermanentDelete, Y as EmailPipeline, Yt as handleContentListTrashed, Z as createHookPipeline, Zt as handleContentPublish, en as handleContentTranslations, et as extractRequestMeta, in as validateRev, jt as handleMediaList, kt as handleMediaDelete, nn as handleContentUnschedule, nt as definePlugin, q as DEV_CONSOLE_EMAIL_PLUGIN_ID, qt as handleContentGetIncludingTrashed, rn as handleContentUpdate, tn as handleContentUnpublish, tt as sanitizeHeadersForSandbox, zt as handleContentCompare } from "../search-BNruJHDL.mjs";
|
|
5
|
+
import { r as RevisionRepository } from "../content-zSgdNmnt.mjs";
|
|
6
|
+
import "../base64-F8-DUraK.mjs";
|
|
7
|
+
import "../types-BawVha09.mjs";
|
|
8
|
+
import { t as MediaRepository } from "../media-DMTr80Gv.mjs";
|
|
9
|
+
import { f as OptionsRepository } from "../apply-CAPvMfoU.mjs";
|
|
10
|
+
import { i as FTSManager, n as SchemaRegistry } from "../registry-DSd1GWB8.mjs";
|
|
11
|
+
import "../redirect-JPqLAbxa.mjs";
|
|
12
|
+
import "../byline-DeWCMU_i.mjs";
|
|
13
|
+
import { n as normalizeMediaValue } from "../placeholder-B3knXwNc.mjs";
|
|
14
|
+
import { i as setI18nConfig } from "../config-Cq8H0SfX.mjs";
|
|
15
|
+
import { getRequestContext, runWithContext } from "../request-context.mjs";
|
|
16
|
+
import { n as getDb } from "../loader-qKmo0wAY.mjs";
|
|
17
|
+
import { r as normalizeManifestRoute } from "../manifest-schema-CTSEyIJ3.mjs";
|
|
18
|
+
import "../query-BiaPl_g2.mjs";
|
|
19
|
+
import "../tokens-4vgYuXsZ.mjs";
|
|
20
|
+
import "../bylines-DyqBV9EQ.mjs";
|
|
21
|
+
import "../load-C6FCD1FU.mjs";
|
|
22
|
+
import "../index.mjs";
|
|
23
|
+
import { t as getAuthMode } from "../mode-BlyYtIFO.mjs";
|
|
24
|
+
import { Kysely, sql } from "kysely";
|
|
25
|
+
import { defineMiddleware } from "astro:middleware";
|
|
26
|
+
import virtualConfig from "virtual:dineway/config";
|
|
27
|
+
import { createDialect } from "virtual:dineway/dialect";
|
|
28
|
+
import { mediaProviders } from "virtual:dineway/media-providers";
|
|
29
|
+
import { plugins } from "virtual:dineway/plugins";
|
|
30
|
+
import { createSandboxRunner, sandboxEnabled } from "virtual:dineway/sandbox-runner";
|
|
31
|
+
import { sandboxedPlugins } from "virtual:dineway/sandboxed-plugins";
|
|
32
|
+
import { createStorage } from "virtual:dineway/storage";
|
|
33
|
+
import { createKyselyAdapter } from "@dineway-ai/auth/adapters/kysely";
|
|
34
|
+
|
|
35
|
+
//#region src/auth/challenge-store.ts
|
|
36
|
+
/**
|
|
37
|
+
* Clean up expired challenges.
|
|
38
|
+
* Should be called periodically (e.g., on startup, or via cron).
|
|
39
|
+
*/
|
|
40
|
+
async function cleanupExpiredChallenges(db) {
|
|
41
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
42
|
+
const result = await db.deleteFrom("auth_challenges").where("expires_at", "<", now).executeTakeFirst();
|
|
43
|
+
return Number(result.numDeletedRows ?? 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
//#endregion
|
|
47
|
+
//#region src/cleanup.ts
|
|
48
|
+
/**
|
|
49
|
+
* System cleanup
|
|
50
|
+
*
|
|
51
|
+
* Runs periodic maintenance tasks that prevent unbounded accumulation of
|
|
52
|
+
* expired or stale data. Called from cron scheduler ticks and (for latency-
|
|
53
|
+
* sensitive subsystems) inline during relevant requests.
|
|
54
|
+
*
|
|
55
|
+
* Each subsystem cleanup is independent and non-fatal -- if one fails, the
|
|
56
|
+
* rest still run. Failures are logged but never surface to callers.
|
|
57
|
+
*/
|
|
58
|
+
/** Max revisions to keep per entry during periodic pruning */
|
|
59
|
+
const REVISION_KEEP_COUNT = 50;
|
|
60
|
+
/** Only prune entries that exceed this threshold */
|
|
61
|
+
const REVISION_PRUNE_THRESHOLD = REVISION_KEEP_COUNT;
|
|
62
|
+
/**
|
|
63
|
+
* Run all system cleanup tasks.
|
|
64
|
+
*
|
|
65
|
+
* Safe to call frequently -- each task is a single DELETE with a WHERE clause,
|
|
66
|
+
* so repeated calls with nothing to clean are cheap (no-op queries).
|
|
67
|
+
*
|
|
68
|
+
* @param db - The database instance
|
|
69
|
+
* @param storage - Optional storage backend for deleting orphaned files.
|
|
70
|
+
* When omitted, pending upload DB rows are still deleted but the
|
|
71
|
+
* corresponding files in object storage are not removed.
|
|
72
|
+
*/
|
|
73
|
+
async function runSystemCleanup(db, storage) {
|
|
74
|
+
const result = {
|
|
75
|
+
challenges: -1,
|
|
76
|
+
expiredTokens: -1,
|
|
77
|
+
pendingUploads: -1,
|
|
78
|
+
pendingUploadFiles: -1,
|
|
79
|
+
revisionsPruned: -1
|
|
80
|
+
};
|
|
81
|
+
try {
|
|
82
|
+
result.challenges = await cleanupExpiredChallenges(db);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error("[cleanup] Failed to clean expired challenges:", error);
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
await createKyselyAdapter(db).deleteExpiredTokens();
|
|
88
|
+
result.expiredTokens = 0;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error("[cleanup] Failed to clean expired tokens:", error);
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const orphanedKeys = await new MediaRepository(db).cleanupPendingUploads();
|
|
94
|
+
result.pendingUploads = orphanedKeys.length;
|
|
95
|
+
if (storage && orphanedKeys.length > 0) {
|
|
96
|
+
let filesDeleted = 0;
|
|
97
|
+
for (const key of orphanedKeys) try {
|
|
98
|
+
await storage.delete(key);
|
|
99
|
+
filesDeleted++;
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error(`[cleanup] Failed to delete storage file ${key}:`, error);
|
|
102
|
+
}
|
|
103
|
+
result.pendingUploadFiles = filesDeleted;
|
|
104
|
+
} else result.pendingUploadFiles = 0;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
console.error("[cleanup] Failed to clean pending uploads:", error);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
result.revisionsPruned = await pruneExcessiveRevisions(db);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
console.error("[cleanup] Failed to prune revisions:", error);
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Find entries with more than REVISION_PRUNE_THRESHOLD revisions and prune
|
|
117
|
+
* them down to REVISION_KEEP_COUNT.
|
|
118
|
+
*/
|
|
119
|
+
async function pruneExcessiveRevisions(db) {
|
|
120
|
+
const entries = await sql`
|
|
121
|
+
SELECT collection, entry_id, COUNT(*) as cnt
|
|
122
|
+
FROM revisions
|
|
123
|
+
GROUP BY collection, entry_id
|
|
124
|
+
HAVING cnt > ${REVISION_PRUNE_THRESHOLD}
|
|
125
|
+
`.execute(db);
|
|
126
|
+
if (entries.rows.length === 0) return 0;
|
|
127
|
+
const revisionRepo = new RevisionRepository(db);
|
|
128
|
+
let totalPruned = 0;
|
|
129
|
+
for (const row of entries.rows) try {
|
|
130
|
+
const pruned = await revisionRepo.pruneOldRevisions(row.collection, row.entry_id, REVISION_KEEP_COUNT);
|
|
131
|
+
totalPruned += pruned;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.error(`[cleanup] Failed to prune revisions for ${row.collection}/${row.entry_id}:`, error);
|
|
134
|
+
}
|
|
135
|
+
return totalPruned;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/comments/moderator.ts
|
|
140
|
+
/** Plugin ID for the built-in default comment moderator */
|
|
141
|
+
const DEFAULT_COMMENT_MODERATOR_PLUGIN_ID = "dineway-default-comment-moderator";
|
|
142
|
+
/**
|
|
143
|
+
* The comment:moderate handler for the built-in default moderator.
|
|
144
|
+
*/
|
|
145
|
+
async function defaultCommentModerate(event, _ctx) {
|
|
146
|
+
const { comment, collectionSettings, priorApprovedCount } = event;
|
|
147
|
+
if (collectionSettings.commentsAutoApproveUsers && comment.authorUserId) return {
|
|
148
|
+
status: "approved",
|
|
149
|
+
reason: "Authenticated CMS user"
|
|
150
|
+
};
|
|
151
|
+
if (collectionSettings.commentsModeration === "none") return {
|
|
152
|
+
status: "approved",
|
|
153
|
+
reason: "Moderation disabled"
|
|
154
|
+
};
|
|
155
|
+
if (collectionSettings.commentsModeration === "first_time" && priorApprovedCount > 0) return {
|
|
156
|
+
status: "approved",
|
|
157
|
+
reason: "Returning commenter"
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
status: "pending",
|
|
161
|
+
reason: "Held for review"
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/plugins/sandbox/runtime-options.ts
|
|
167
|
+
function createSandboxRunnerRuntimeOptions(input) {
|
|
168
|
+
return {
|
|
169
|
+
db: input.db,
|
|
170
|
+
mediaStorage: input.storage ?? void 0,
|
|
171
|
+
cronReschedule: input.cronReschedule,
|
|
172
|
+
siteInfo: input.siteInfo ? {
|
|
173
|
+
name: input.siteInfo.siteName ?? "",
|
|
174
|
+
url: input.siteInfo.siteUrl ?? "",
|
|
175
|
+
locale: input.siteInfo.locale ?? "en"
|
|
176
|
+
} : void 0
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
//#endregion
|
|
181
|
+
//#region src/plugins/scheduler/node.ts
|
|
182
|
+
/** Minimum polling interval (ms) — prevents tight loops if next_run_at is in the past */
|
|
183
|
+
const MIN_INTERVAL_MS = 1e3;
|
|
184
|
+
/** Maximum polling interval (ms) — wake up periodically to check for stale locks */
|
|
185
|
+
const MAX_INTERVAL_MS = 300 * 1e3;
|
|
186
|
+
var NodeCronScheduler = class {
|
|
187
|
+
timer = null;
|
|
188
|
+
running = false;
|
|
189
|
+
systemCleanup = null;
|
|
190
|
+
constructor(executor) {
|
|
191
|
+
this.executor = executor;
|
|
192
|
+
}
|
|
193
|
+
setSystemCleanup(fn) {
|
|
194
|
+
this.systemCleanup = fn;
|
|
195
|
+
}
|
|
196
|
+
start() {
|
|
197
|
+
this.running = true;
|
|
198
|
+
this.arm();
|
|
199
|
+
}
|
|
200
|
+
stop() {
|
|
201
|
+
this.running = false;
|
|
202
|
+
if (this.timer) {
|
|
203
|
+
clearTimeout(this.timer);
|
|
204
|
+
this.timer = null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
reschedule() {
|
|
208
|
+
if (!this.running) return;
|
|
209
|
+
if (this.timer) {
|
|
210
|
+
clearTimeout(this.timer);
|
|
211
|
+
this.timer = null;
|
|
212
|
+
}
|
|
213
|
+
this.arm();
|
|
214
|
+
}
|
|
215
|
+
arm() {
|
|
216
|
+
if (!this.running) return;
|
|
217
|
+
this.executor.getNextDueTime().then((nextDue) => {
|
|
218
|
+
if (!this.running) return void 0;
|
|
219
|
+
let delayMs;
|
|
220
|
+
if (nextDue) {
|
|
221
|
+
const dueAt = new Date(nextDue).getTime();
|
|
222
|
+
delayMs = Math.max(dueAt - Date.now(), MIN_INTERVAL_MS);
|
|
223
|
+
delayMs = Math.min(delayMs, MAX_INTERVAL_MS);
|
|
224
|
+
} else delayMs = MAX_INTERVAL_MS;
|
|
225
|
+
this.timer = setTimeout(() => {
|
|
226
|
+
if (!this.running) return;
|
|
227
|
+
this.executeTick();
|
|
228
|
+
}, delayMs);
|
|
229
|
+
if (this.timer && typeof this.timer === "object" && "unref" in this.timer) this.timer.unref();
|
|
230
|
+
}).catch((error) => {
|
|
231
|
+
console.error("[cron:node] Failed to get next due time:", error);
|
|
232
|
+
if (this.running) {
|
|
233
|
+
this.timer = setTimeout(() => this.arm(), MAX_INTERVAL_MS);
|
|
234
|
+
if (this.timer && typeof this.timer === "object" && "unref" in this.timer) this.timer.unref();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
executeTick() {
|
|
239
|
+
if (!this.running) return;
|
|
240
|
+
const tasks = [this.executor.tick(), this.executor.recoverStaleLocks()];
|
|
241
|
+
if (this.systemCleanup) tasks.push(this.systemCleanup());
|
|
242
|
+
Promise.allSettled(tasks).then((results) => {
|
|
243
|
+
for (const r of results) if (r.status === "rejected") console.error("[cron:node] Tick task failed:", r.reason);
|
|
244
|
+
}).finally(() => {
|
|
245
|
+
if (this.running) this.arm();
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/plugins/scheduler/piggyback.ts
|
|
252
|
+
/** Minimum interval between tick attempts (ms) */
|
|
253
|
+
const DEBOUNCE_MS = 60 * 1e3;
|
|
254
|
+
var PiggybackScheduler = class {
|
|
255
|
+
lastTickAt = 0;
|
|
256
|
+
running = false;
|
|
257
|
+
systemCleanup = null;
|
|
258
|
+
constructor(executor) {
|
|
259
|
+
this.executor = executor;
|
|
260
|
+
}
|
|
261
|
+
setSystemCleanup(fn) {
|
|
262
|
+
this.systemCleanup = fn;
|
|
263
|
+
}
|
|
264
|
+
start() {
|
|
265
|
+
this.running = true;
|
|
266
|
+
}
|
|
267
|
+
stop() {
|
|
268
|
+
this.running = false;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* No-op for piggyback — tick happens on next request.
|
|
272
|
+
*/
|
|
273
|
+
reschedule() {}
|
|
274
|
+
/**
|
|
275
|
+
* Call this from middleware on each request.
|
|
276
|
+
* Debounced: only actually ticks if enough time has passed.
|
|
277
|
+
*/
|
|
278
|
+
onRequest() {
|
|
279
|
+
if (!this.running) return;
|
|
280
|
+
const now = Date.now();
|
|
281
|
+
if (now - this.lastTickAt < DEBOUNCE_MS) return;
|
|
282
|
+
this.lastTickAt = now;
|
|
283
|
+
const tasks = [this.executor.tick(), this.executor.recoverStaleLocks()];
|
|
284
|
+
if (this.systemCleanup) tasks.push(this.systemCleanup());
|
|
285
|
+
Promise.allSettled(tasks).then((results) => {
|
|
286
|
+
for (const r of results) if (r.status === "rejected") console.error("[cron:piggyback] Tick task failed:", r.reason);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
//#endregion
|
|
292
|
+
//#region src/plugins/scheduler/strategy.ts
|
|
293
|
+
function hasUnref(handle) {
|
|
294
|
+
return typeof handle === "object" && handle !== null && "unref" in handle && typeof handle.unref === "function";
|
|
295
|
+
}
|
|
296
|
+
function shouldUsePiggybackScheduler(createTimer = () => globalThis.setTimeout(() => {}, 0), cancelTimer = (handle) => globalThis.clearTimeout(handle)) {
|
|
297
|
+
const handle = createTimer();
|
|
298
|
+
cancelTimer(handle);
|
|
299
|
+
return !hasUnref(handle);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
//#endregion
|
|
303
|
+
//#region src/dineway-runtime.ts
|
|
304
|
+
const LEADING_SLASH_PATTERN = /^\//;
|
|
305
|
+
const VALID_METADATA_KINDS = new Set([
|
|
306
|
+
"meta",
|
|
307
|
+
"property",
|
|
308
|
+
"link",
|
|
309
|
+
"jsonld"
|
|
310
|
+
]);
|
|
311
|
+
/** Security-critical allowlist for link rel values from sandboxed plugins */
|
|
312
|
+
const VALID_LINK_REL = new Set([
|
|
313
|
+
"canonical",
|
|
314
|
+
"alternate",
|
|
315
|
+
"author",
|
|
316
|
+
"license",
|
|
317
|
+
"site.standard.document"
|
|
318
|
+
]);
|
|
319
|
+
/**
|
|
320
|
+
* Runtime validation for sandboxed plugin metadata contributions.
|
|
321
|
+
* Sandboxed plugins return `unknown` across the RPC boundary — we must
|
|
322
|
+
* verify the shape before passing to the metadata collector.
|
|
323
|
+
*/
|
|
324
|
+
function isValidMetadataContribution(c) {
|
|
325
|
+
if (!c || typeof c !== "object" || !("kind" in c)) return false;
|
|
326
|
+
const obj = c;
|
|
327
|
+
if (typeof obj.kind !== "string" || !VALID_METADATA_KINDS.has(obj.kind)) return false;
|
|
328
|
+
switch (obj.kind) {
|
|
329
|
+
case "meta": return typeof obj.name === "string" && typeof obj.content === "string";
|
|
330
|
+
case "property": return typeof obj.property === "string" && typeof obj.content === "string";
|
|
331
|
+
case "link": return typeof obj.href === "string" && typeof obj.rel === "string" && VALID_LINK_REL.has(obj.rel);
|
|
332
|
+
case "jsonld": return obj.graph != null && typeof obj.graph === "object";
|
|
333
|
+
default: return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function isKyselyDatabase(value) {
|
|
337
|
+
return value instanceof Kysely;
|
|
338
|
+
}
|
|
339
|
+
function getRequestContextDatabase() {
|
|
340
|
+
const db = getRequestContext()?.db;
|
|
341
|
+
return isKyselyDatabase(db) ? db : null;
|
|
342
|
+
}
|
|
343
|
+
function parseStringArray(value) {
|
|
344
|
+
if (!value) return [];
|
|
345
|
+
try {
|
|
346
|
+
const parsed = JSON.parse(value);
|
|
347
|
+
return Array.isArray(parsed) && parsed.every((entry) => typeof entry === "string") ? parsed.toSorted() : [];
|
|
348
|
+
} catch {
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Map schema field types to editor field kinds
|
|
354
|
+
*/
|
|
355
|
+
const FIELD_TYPE_TO_KIND = {
|
|
356
|
+
string: "string",
|
|
357
|
+
slug: "string",
|
|
358
|
+
text: "richText",
|
|
359
|
+
number: "number",
|
|
360
|
+
integer: "number",
|
|
361
|
+
boolean: "boolean",
|
|
362
|
+
datetime: "datetime",
|
|
363
|
+
select: "select",
|
|
364
|
+
multiSelect: "multiSelect",
|
|
365
|
+
portableText: "portableText",
|
|
366
|
+
image: "image",
|
|
367
|
+
file: "file",
|
|
368
|
+
reference: "reference",
|
|
369
|
+
json: "json",
|
|
370
|
+
repeater: "repeater"
|
|
371
|
+
};
|
|
372
|
+
/**
|
|
373
|
+
* Convert a ContentItem to Record<string, unknown> for hook consumption.
|
|
374
|
+
* Hooks receive the full item as a flat record.
|
|
375
|
+
*/
|
|
376
|
+
function contentItemToRecord(item) {
|
|
377
|
+
return { ...item };
|
|
378
|
+
}
|
|
379
|
+
const dbCache = /* @__PURE__ */ new Map();
|
|
380
|
+
let dbInitPromise = null;
|
|
381
|
+
const storageCache = /* @__PURE__ */ new Map();
|
|
382
|
+
const sandboxedPluginCache = /* @__PURE__ */ new Map();
|
|
383
|
+
const marketplacePluginKeys = /* @__PURE__ */ new Set();
|
|
384
|
+
/** Manifest metadata for marketplace plugins: pluginId -> manifest admin config */
|
|
385
|
+
const marketplaceManifestCache = /* @__PURE__ */ new Map();
|
|
386
|
+
/** Route metadata for sandboxed plugins: pluginId -> routeName -> RouteMeta */
|
|
387
|
+
const sandboxedRouteMetaCache = /* @__PURE__ */ new Map();
|
|
388
|
+
let sandboxRunner = null;
|
|
389
|
+
/**
|
|
390
|
+
* DinewayRuntime - singleton per worker
|
|
391
|
+
*/
|
|
392
|
+
var DinewayRuntime = class DinewayRuntime {
|
|
393
|
+
/**
|
|
394
|
+
* The singleton database instance (worker-lifetime cached).
|
|
395
|
+
* Use the `db` getter instead — it checks the request context first
|
|
396
|
+
* for per-request overrides from preview/playground sidecars and other
|
|
397
|
+
* request-scoped database flows.
|
|
398
|
+
*/
|
|
399
|
+
_db;
|
|
400
|
+
storage;
|
|
401
|
+
configuredPlugins;
|
|
402
|
+
sandboxedPlugins;
|
|
403
|
+
sandboxedPluginEntries;
|
|
404
|
+
schemaRegistry;
|
|
405
|
+
_hooks;
|
|
406
|
+
config;
|
|
407
|
+
mediaProviders;
|
|
408
|
+
mediaProviderEntries;
|
|
409
|
+
cronExecutor;
|
|
410
|
+
email;
|
|
411
|
+
cronScheduler;
|
|
412
|
+
enabledPlugins;
|
|
413
|
+
pluginStates;
|
|
414
|
+
/** Current hook pipeline. Use the `hooks` getter for external access. */
|
|
415
|
+
get hooks() {
|
|
416
|
+
return this._hooks;
|
|
417
|
+
}
|
|
418
|
+
/** All plugins eligible for the hook pipeline (includes built-in plugins).
|
|
419
|
+
* Stored so we can rebuild the pipeline when plugins are enabled/disabled. */
|
|
420
|
+
allPipelinePlugins;
|
|
421
|
+
/** Factory options for the hook pipeline context factory */
|
|
422
|
+
pipelineFactoryOptions;
|
|
423
|
+
/** Dependencies needed for exclusive hook resolution */
|
|
424
|
+
runtimeDeps;
|
|
425
|
+
/** Mutable ref for the cron invokeCronHook closure to read the current pipeline */
|
|
426
|
+
pipelineRef;
|
|
427
|
+
/**
|
|
428
|
+
* Get the database instance for the current request.
|
|
429
|
+
*
|
|
430
|
+
* Checks the ALS-based request context first — middleware sets a
|
|
431
|
+
* per-request Kysely instance there for preview/playground session
|
|
432
|
+
* databases and other request-scoped overrides. Falls back to the
|
|
433
|
+
* singleton instance.
|
|
434
|
+
*/
|
|
435
|
+
get db() {
|
|
436
|
+
const requestDb = getRequestContextDatabase();
|
|
437
|
+
if (requestDb) return requestDb;
|
|
438
|
+
return this._db;
|
|
439
|
+
}
|
|
440
|
+
constructor(db, storage, configuredPlugins, sandboxedPlugins, sandboxedPluginEntries, hooks, enabledPlugins, pluginStates, config, mediaProviders, mediaProviderEntries, cronExecutor, cronScheduler, emailPipeline, allPipelinePlugins, pipelineFactoryOptions, runtimeDeps, pipelineRef) {
|
|
441
|
+
this._db = db;
|
|
442
|
+
this.storage = storage;
|
|
443
|
+
this.configuredPlugins = configuredPlugins;
|
|
444
|
+
this.sandboxedPlugins = sandboxedPlugins;
|
|
445
|
+
this.sandboxedPluginEntries = sandboxedPluginEntries;
|
|
446
|
+
this.schemaRegistry = new SchemaRegistry(db);
|
|
447
|
+
this._hooks = hooks;
|
|
448
|
+
this.enabledPlugins = enabledPlugins;
|
|
449
|
+
this.pluginStates = pluginStates;
|
|
450
|
+
this.config = config;
|
|
451
|
+
this.mediaProviders = mediaProviders;
|
|
452
|
+
this.mediaProviderEntries = mediaProviderEntries;
|
|
453
|
+
this.cronExecutor = cronExecutor;
|
|
454
|
+
this.cronScheduler = cronScheduler;
|
|
455
|
+
this.email = emailPipeline;
|
|
456
|
+
this.allPipelinePlugins = allPipelinePlugins;
|
|
457
|
+
this.pipelineFactoryOptions = pipelineFactoryOptions;
|
|
458
|
+
this.runtimeDeps = runtimeDeps;
|
|
459
|
+
this.pipelineRef = pipelineRef;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Get the sandbox runner instance (for marketplace install/update)
|
|
463
|
+
*/
|
|
464
|
+
getSandboxRunner() {
|
|
465
|
+
return sandboxRunner;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Tick the cron system from request context (piggyback mode).
|
|
469
|
+
* Call this from middleware on each request to ensure cron tasks
|
|
470
|
+
* execute even when no dedicated scheduler is available.
|
|
471
|
+
*/
|
|
472
|
+
tickCron() {
|
|
473
|
+
if (this.cronScheduler instanceof PiggybackScheduler) this.cronScheduler.onRequest();
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Stop the cron scheduler gracefully.
|
|
477
|
+
* Call during worker shutdown or hot-reload.
|
|
478
|
+
*/
|
|
479
|
+
async stopCron() {
|
|
480
|
+
if (this.cronScheduler) await this.cronScheduler.stop();
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Update in-memory plugin status and rebuild the hook pipeline.
|
|
484
|
+
*
|
|
485
|
+
* Rebuilding the pipeline ensures disabled plugins' hooks stop firing
|
|
486
|
+
* and re-enabled plugins' hooks start firing again without a restart.
|
|
487
|
+
* Exclusive hook selections are re-resolved after each rebuild.
|
|
488
|
+
*/
|
|
489
|
+
async setPluginStatus(pluginId, status) {
|
|
490
|
+
this.pluginStates.set(pluginId, status);
|
|
491
|
+
if (status === "active") {
|
|
492
|
+
this.enabledPlugins.add(pluginId);
|
|
493
|
+
await this.rebuildHookPipeline();
|
|
494
|
+
await this._hooks.runPluginActivate(pluginId);
|
|
495
|
+
} else {
|
|
496
|
+
await this._hooks.runPluginDeactivate(pluginId);
|
|
497
|
+
this.enabledPlugins.delete(pluginId);
|
|
498
|
+
await this.rebuildHookPipeline();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Rebuild the hook pipeline from the current set of enabled plugins.
|
|
503
|
+
*
|
|
504
|
+
* Filters `allPipelinePlugins` to only those in `enabledPlugins`,
|
|
505
|
+
* creates a fresh HookPipeline, re-resolves exclusive hook selections,
|
|
506
|
+
* and re-wires the context factory so existing references (cron
|
|
507
|
+
* callbacks, email pipeline) use the new pipeline.
|
|
508
|
+
*/
|
|
509
|
+
async rebuildHookPipeline() {
|
|
510
|
+
const newPipeline = createHookPipeline(this.allPipelinePlugins.filter((p) => this.enabledPlugins.has(p.id)), this.pipelineFactoryOptions);
|
|
511
|
+
await DinewayRuntime.resolveExclusiveHooks(newPipeline, this.db, this.runtimeDeps);
|
|
512
|
+
if (this.email) newPipeline.setContextFactory({
|
|
513
|
+
db: this.db,
|
|
514
|
+
emailPipeline: this.email
|
|
515
|
+
});
|
|
516
|
+
if (this.cronScheduler) {
|
|
517
|
+
const scheduler = this.cronScheduler;
|
|
518
|
+
newPipeline.setContextFactory({ cronReschedule: () => scheduler.reschedule() });
|
|
519
|
+
}
|
|
520
|
+
if (this.email) this.email.setPipeline(newPipeline);
|
|
521
|
+
this.pipelineRef.current = newPipeline;
|
|
522
|
+
this._hooks = newPipeline;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Synchronize marketplace plugin runtime state with DB + storage.
|
|
526
|
+
*
|
|
527
|
+
* Ensures install/update/uninstall changes take effect immediately in the
|
|
528
|
+
* current worker: loads newly active plugins and removes uninstalled ones.
|
|
529
|
+
*/
|
|
530
|
+
async syncMarketplacePlugins() {
|
|
531
|
+
if (!this.config.marketplace || !this.storage) return;
|
|
532
|
+
if (!sandboxRunner || !sandboxRunner.isAvailable()) return;
|
|
533
|
+
try {
|
|
534
|
+
const marketplaceStates = await new PluginStateRepository(this.db).getMarketplacePlugins();
|
|
535
|
+
const bundleStore = createPluginBundleStore(this.storage);
|
|
536
|
+
const desired = /* @__PURE__ */ new Map();
|
|
537
|
+
for (const state of marketplaceStates) {
|
|
538
|
+
this.pluginStates.set(state.pluginId, state.status);
|
|
539
|
+
if (state.status === "active") this.enabledPlugins.add(state.pluginId);
|
|
540
|
+
else this.enabledPlugins.delete(state.pluginId);
|
|
541
|
+
if (state.status !== "active") continue;
|
|
542
|
+
desired.set(state.pluginId, state.marketplaceVersion ?? state.version);
|
|
543
|
+
}
|
|
544
|
+
const keysToRemove = [];
|
|
545
|
+
for (const key of marketplacePluginKeys) {
|
|
546
|
+
const [pluginId] = key.split(":");
|
|
547
|
+
if (!pluginId) continue;
|
|
548
|
+
const desiredVersion = desired.get(pluginId);
|
|
549
|
+
if (desiredVersion && key === `${pluginId}:${desiredVersion}`) continue;
|
|
550
|
+
keysToRemove.push(key);
|
|
551
|
+
}
|
|
552
|
+
for (const key of keysToRemove) {
|
|
553
|
+
const [pluginId] = key.split(":");
|
|
554
|
+
if (!pluginId) continue;
|
|
555
|
+
if (!desired.get(pluginId)) {
|
|
556
|
+
this.pluginStates.delete(pluginId);
|
|
557
|
+
this.enabledPlugins.delete(pluginId);
|
|
558
|
+
}
|
|
559
|
+
const existing = sandboxedPluginCache.get(key);
|
|
560
|
+
if (existing) try {
|
|
561
|
+
await existing.terminate();
|
|
562
|
+
} catch (error) {
|
|
563
|
+
console.warn(`Dineway: Failed to terminate sandboxed plugin ${key}:`, error);
|
|
564
|
+
}
|
|
565
|
+
sandboxedPluginCache.delete(key);
|
|
566
|
+
this.sandboxedPlugins.delete(key);
|
|
567
|
+
marketplacePluginKeys.delete(key);
|
|
568
|
+
if (pluginId) {
|
|
569
|
+
sandboxedRouteMetaCache.delete(pluginId);
|
|
570
|
+
marketplaceManifestCache.delete(pluginId);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
for (const [pluginId, version] of desired) {
|
|
574
|
+
const key = `${pluginId}:${version}`;
|
|
575
|
+
if (sandboxedPluginCache.has(key)) {
|
|
576
|
+
marketplacePluginKeys.add(key);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
const bundle = await bundleStore.read(pluginId, version);
|
|
580
|
+
if (!bundle) {
|
|
581
|
+
console.warn(`Dineway: Marketplace plugin ${pluginId}@${version} not found in bundle storage`);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
const loaded = await sandboxRunner.load(bundle.manifest, bundle.backendCode);
|
|
585
|
+
sandboxedPluginCache.set(key, loaded);
|
|
586
|
+
this.sandboxedPlugins.set(key, loaded);
|
|
587
|
+
marketplacePluginKeys.add(key);
|
|
588
|
+
marketplaceManifestCache.set(pluginId, {
|
|
589
|
+
id: bundle.manifest.id,
|
|
590
|
+
version: bundle.manifest.version,
|
|
591
|
+
admin: bundle.manifest.admin
|
|
592
|
+
});
|
|
593
|
+
if (bundle.manifest.routes.length > 0) {
|
|
594
|
+
const routeMetaMap = /* @__PURE__ */ new Map();
|
|
595
|
+
for (const entry of bundle.manifest.routes) {
|
|
596
|
+
const normalized = normalizeManifestRoute(entry);
|
|
597
|
+
routeMetaMap.set(normalized.name, { public: normalized.public === true });
|
|
598
|
+
}
|
|
599
|
+
sandboxedRouteMetaCache.set(pluginId, routeMetaMap);
|
|
600
|
+
} else sandboxedRouteMetaCache.delete(pluginId);
|
|
601
|
+
}
|
|
602
|
+
} catch (error) {
|
|
603
|
+
console.error("Dineway: Failed to sync marketplace plugins:", error);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Create and initialize the runtime
|
|
608
|
+
*/
|
|
609
|
+
static async create(deps) {
|
|
610
|
+
const db = await DinewayRuntime.getDatabase(deps);
|
|
611
|
+
if (isSqlite(db)) try {
|
|
612
|
+
const repaired = await new FTSManager(db).verifyAndRepairAll();
|
|
613
|
+
if (repaired > 0) console.log(`Repaired ${repaired} corrupted FTS index(es) at startup`);
|
|
614
|
+
} catch {}
|
|
615
|
+
const storage = DinewayRuntime.getStorage(deps);
|
|
616
|
+
let pluginStates = /* @__PURE__ */ new Map();
|
|
617
|
+
try {
|
|
618
|
+
const states = await db.selectFrom("_plugin_state").select(["plugin_id", "status"]).execute();
|
|
619
|
+
pluginStates = new Map(states.map((s) => [s.plugin_id, s.status]));
|
|
620
|
+
} catch {}
|
|
621
|
+
const enabledPlugins = /* @__PURE__ */ new Set();
|
|
622
|
+
for (const plugin of deps.plugins) {
|
|
623
|
+
const status = pluginStates.get(plugin.id);
|
|
624
|
+
if (status === void 0 || status === "active") enabledPlugins.add(plugin.id);
|
|
625
|
+
}
|
|
626
|
+
let siteInfo;
|
|
627
|
+
try {
|
|
628
|
+
const optionsRepo = new OptionsRepository(db);
|
|
629
|
+
const siteName = await optionsRepo.get("dineway:site_title");
|
|
630
|
+
const siteUrl = await optionsRepo.get("dineway:site_url");
|
|
631
|
+
const locale = await optionsRepo.get("dineway:locale");
|
|
632
|
+
siteInfo = {
|
|
633
|
+
siteName: siteName ?? void 0,
|
|
634
|
+
siteUrl: siteUrl ?? void 0,
|
|
635
|
+
locale: locale ?? void 0
|
|
636
|
+
};
|
|
637
|
+
} catch {}
|
|
638
|
+
const allPipelinePlugins = [...deps.plugins];
|
|
639
|
+
if (import.meta.env.DEV) try {
|
|
640
|
+
const devConsolePlugin = definePlugin({
|
|
641
|
+
id: DEV_CONSOLE_EMAIL_PLUGIN_ID,
|
|
642
|
+
version: "0.0.0",
|
|
643
|
+
capabilities: ["email:provide"],
|
|
644
|
+
hooks: { "email:deliver": {
|
|
645
|
+
exclusive: true,
|
|
646
|
+
handler: devConsoleEmailDeliver
|
|
647
|
+
} }
|
|
648
|
+
});
|
|
649
|
+
allPipelinePlugins.push(devConsolePlugin);
|
|
650
|
+
enabledPlugins.add(devConsolePlugin.id);
|
|
651
|
+
} catch (error) {
|
|
652
|
+
console.warn("[email] Failed to register dev console email provider:", error);
|
|
653
|
+
}
|
|
654
|
+
try {
|
|
655
|
+
const defaultModeratorPlugin = definePlugin({
|
|
656
|
+
id: DEFAULT_COMMENT_MODERATOR_PLUGIN_ID,
|
|
657
|
+
version: "0.0.0",
|
|
658
|
+
capabilities: ["read:users"],
|
|
659
|
+
hooks: { "comment:moderate": {
|
|
660
|
+
exclusive: true,
|
|
661
|
+
handler: defaultCommentModerate
|
|
662
|
+
} }
|
|
663
|
+
});
|
|
664
|
+
allPipelinePlugins.push(defaultModeratorPlugin);
|
|
665
|
+
enabledPlugins.add(defaultModeratorPlugin.id);
|
|
666
|
+
} catch (error) {
|
|
667
|
+
console.warn("[comments] Failed to register default moderator:", error);
|
|
668
|
+
}
|
|
669
|
+
const enabledPluginList = allPipelinePlugins.filter((p) => enabledPlugins.has(p.id));
|
|
670
|
+
const pipelineFactoryOptions = {
|
|
671
|
+
db,
|
|
672
|
+
storage: storage ?? void 0,
|
|
673
|
+
siteInfo
|
|
674
|
+
};
|
|
675
|
+
const pipeline = createHookPipeline(enabledPluginList, pipelineFactoryOptions);
|
|
676
|
+
let cronScheduler = null;
|
|
677
|
+
const sandboxRunnerOptions = createSandboxRunnerRuntimeOptions({
|
|
678
|
+
db,
|
|
679
|
+
storage,
|
|
680
|
+
siteInfo,
|
|
681
|
+
cronReschedule: () => cronScheduler?.reschedule()
|
|
682
|
+
});
|
|
683
|
+
const sandboxedPlugins = await DinewayRuntime.loadSandboxedPlugins(deps, sandboxRunnerOptions);
|
|
684
|
+
if (deps.config.marketplace && storage) await DinewayRuntime.loadMarketplacePlugins(db, storage, deps, sandboxedPlugins, sandboxRunnerOptions);
|
|
685
|
+
const mediaProviders = /* @__PURE__ */ new Map();
|
|
686
|
+
const mediaProviderEntries = deps.mediaProviderEntries ?? [];
|
|
687
|
+
const providerContext = {
|
|
688
|
+
db,
|
|
689
|
+
storage
|
|
690
|
+
};
|
|
691
|
+
for (const entry of mediaProviderEntries) try {
|
|
692
|
+
const provider = entry.createProvider(providerContext);
|
|
693
|
+
mediaProviders.set(entry.id, provider);
|
|
694
|
+
} catch (error) {
|
|
695
|
+
console.warn(`Failed to initialize media provider "${entry.id}":`, error);
|
|
696
|
+
}
|
|
697
|
+
await DinewayRuntime.resolveExclusiveHooks(pipeline, db, deps);
|
|
698
|
+
const emailPipeline = new EmailPipeline(pipeline);
|
|
699
|
+
if (sandboxRunner) sandboxRunner.setEmailSend((message, pluginId) => emailPipeline.send(message, pluginId));
|
|
700
|
+
const pipelineRef = { current: pipeline };
|
|
701
|
+
const invokeCronHook = async (pluginId, event) => {
|
|
702
|
+
const result = await pipelineRef.current.invokeCronHook(pluginId, event);
|
|
703
|
+
if (!result.success && result.error) throw result.error;
|
|
704
|
+
};
|
|
705
|
+
pipeline.setContextFactory({
|
|
706
|
+
db,
|
|
707
|
+
emailPipeline
|
|
708
|
+
});
|
|
709
|
+
let cronExecutor = null;
|
|
710
|
+
try {
|
|
711
|
+
cronExecutor = new CronExecutor(db, invokeCronHook);
|
|
712
|
+
const recovered = await cronExecutor.recoverStaleLocks();
|
|
713
|
+
if (recovered > 0) console.log(`[cron] Recovered ${recovered} stale task lock(s)`);
|
|
714
|
+
if (shouldUsePiggybackScheduler()) cronScheduler = new PiggybackScheduler(cronExecutor);
|
|
715
|
+
else cronScheduler = new NodeCronScheduler(cronExecutor);
|
|
716
|
+
cronScheduler.setSystemCleanup(async () => {
|
|
717
|
+
try {
|
|
718
|
+
await runSystemCleanup(db, storage ?? void 0);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
console.error("[cleanup] System cleanup failed:", error);
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
pipeline.setContextFactory({ cronReschedule: () => cronScheduler?.reschedule() });
|
|
724
|
+
await cronScheduler.start();
|
|
725
|
+
} catch (error) {
|
|
726
|
+
console.warn("[cron] Failed to initialize cron system:", error);
|
|
727
|
+
}
|
|
728
|
+
return new DinewayRuntime(db, storage, deps.plugins, sandboxedPlugins, deps.sandboxedPluginEntries, pipeline, enabledPlugins, pluginStates, deps.config, mediaProviders, mediaProviderEntries, cronExecutor, cronScheduler, emailPipeline, allPipelinePlugins, pipelineFactoryOptions, deps, pipelineRef);
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Get a media provider by ID
|
|
732
|
+
*/
|
|
733
|
+
getMediaProvider(providerId) {
|
|
734
|
+
return this.mediaProviders.get(providerId);
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Get all media provider entries (for admin UI)
|
|
738
|
+
*/
|
|
739
|
+
getMediaProviderList() {
|
|
740
|
+
return this.mediaProviderEntries.map((e) => ({
|
|
741
|
+
id: e.id,
|
|
742
|
+
name: e.name,
|
|
743
|
+
icon: e.icon,
|
|
744
|
+
capabilities: e.capabilities
|
|
745
|
+
}));
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Get or create database instance
|
|
749
|
+
*/
|
|
750
|
+
static async getDatabase(deps) {
|
|
751
|
+
const requestDb = getRequestContextDatabase();
|
|
752
|
+
if (requestDb) return requestDb;
|
|
753
|
+
const dbConfig = deps.config.database;
|
|
754
|
+
if (!dbConfig) try {
|
|
755
|
+
return await getDb();
|
|
756
|
+
} catch {
|
|
757
|
+
throw new Error("Dineway database not configured. Either configure database in astro.config.mjs or use dinewayLoader in live.config.ts");
|
|
758
|
+
}
|
|
759
|
+
const cacheKey = dbConfig.entrypoint;
|
|
760
|
+
const cached = dbCache.get(cacheKey);
|
|
761
|
+
if (cached) return cached;
|
|
762
|
+
if (dbInitPromise) return dbInitPromise;
|
|
763
|
+
dbInitPromise = (async () => {
|
|
764
|
+
const db = new Kysely({ dialect: deps.createDialect(dbConfig.config) });
|
|
765
|
+
await runMigrations(db);
|
|
766
|
+
try {
|
|
767
|
+
const [collectionCount, setupOption] = await Promise.all([db.selectFrom("_dineway_collections").select((eb) => eb.fn.countAll().as("count")).executeTakeFirstOrThrow(), db.selectFrom("options").select("value").where("name", "=", "dineway:setup_complete").executeTakeFirst()]);
|
|
768
|
+
const setupDone = (() => {
|
|
769
|
+
try {
|
|
770
|
+
return setupOption && JSON.parse(setupOption.value) === true;
|
|
771
|
+
} catch {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
})();
|
|
775
|
+
if (collectionCount.count === 0 && !setupDone) {
|
|
776
|
+
const { applySeed } = await import("../apply-CAPvMfoU.mjs").then((n) => n.n);
|
|
777
|
+
const { loadSeed } = await import("../load-C6FCD1FU.mjs").then((n) => n.r);
|
|
778
|
+
const { validateSeed } = await import("../validate-CXnRKfJK.mjs").then((n) => n.n);
|
|
779
|
+
const seed = await loadSeed();
|
|
780
|
+
if (validateSeed(seed).valid) {
|
|
781
|
+
await applySeed(db, seed, { onConflict: "skip" });
|
|
782
|
+
console.log("Auto-seeded default collections");
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
} catch {}
|
|
786
|
+
dbCache.set(cacheKey, db);
|
|
787
|
+
return db;
|
|
788
|
+
})();
|
|
789
|
+
try {
|
|
790
|
+
return await dbInitPromise;
|
|
791
|
+
} finally {
|
|
792
|
+
dbInitPromise = null;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
/**
|
|
796
|
+
* Get or create storage instance
|
|
797
|
+
*/
|
|
798
|
+
static getStorage(deps) {
|
|
799
|
+
const storageConfig = deps.config.storage;
|
|
800
|
+
if (!storageConfig || !deps.createStorage) return null;
|
|
801
|
+
const cacheKey = storageConfig.entrypoint;
|
|
802
|
+
const cached = storageCache.get(cacheKey);
|
|
803
|
+
if (cached) return cached;
|
|
804
|
+
const storage = deps.createStorage(storageConfig.config);
|
|
805
|
+
storageCache.set(cacheKey, storage);
|
|
806
|
+
return storage;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Load sandboxed plugins using SandboxRunner
|
|
810
|
+
*/
|
|
811
|
+
static async loadSandboxedPlugins(deps, runnerOptions) {
|
|
812
|
+
if (sandboxedPluginCache.size > 0) return sandboxedPluginCache;
|
|
813
|
+
if (!deps.sandboxEnabled || deps.sandboxedPluginEntries.length === 0) return sandboxedPluginCache;
|
|
814
|
+
if (!sandboxRunner && deps.createSandboxRunner) sandboxRunner = deps.createSandboxRunner(runnerOptions);
|
|
815
|
+
if (!sandboxRunner) return sandboxedPluginCache;
|
|
816
|
+
if (!sandboxRunner.isAvailable()) {
|
|
817
|
+
console.debug("Dineway: Sandbox runner not available (missing bindings), skipping sandbox");
|
|
818
|
+
return sandboxedPluginCache;
|
|
819
|
+
}
|
|
820
|
+
for (const entry of deps.sandboxedPluginEntries) {
|
|
821
|
+
const pluginKey = `${entry.id}:${entry.version}`;
|
|
822
|
+
if (sandboxedPluginCache.has(pluginKey)) continue;
|
|
823
|
+
try {
|
|
824
|
+
const manifest = {
|
|
825
|
+
id: entry.id,
|
|
826
|
+
version: entry.version,
|
|
827
|
+
capabilities: entry.capabilities ?? [],
|
|
828
|
+
allowedHosts: entry.allowedHosts ?? [],
|
|
829
|
+
storage: entry.storage ?? {},
|
|
830
|
+
hooks: [],
|
|
831
|
+
routes: [],
|
|
832
|
+
admin: {}
|
|
833
|
+
};
|
|
834
|
+
const plugin = await sandboxRunner.load(manifest, entry.code);
|
|
835
|
+
sandboxedPluginCache.set(pluginKey, plugin);
|
|
836
|
+
console.log(`Dineway: Loaded sandboxed plugin ${pluginKey} with capabilities: [${manifest.capabilities.join(", ")}]`);
|
|
837
|
+
} catch (error) {
|
|
838
|
+
console.error(`Dineway: Failed to load sandboxed plugin ${entry.id}:`, error);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return sandboxedPluginCache;
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Cold-start: load marketplace-installed plugins from site-local bundle storage
|
|
845
|
+
*
|
|
846
|
+
* Queries _plugin_state for source='marketplace' rows, fetches each bundle
|
|
847
|
+
* from persisted storage, and loads via SandboxRunner.
|
|
848
|
+
*/
|
|
849
|
+
static async loadMarketplacePlugins(db, storage, deps, cache, runnerOptions) {
|
|
850
|
+
if (!sandboxRunner && deps.createSandboxRunner) sandboxRunner = deps.createSandboxRunner(runnerOptions);
|
|
851
|
+
if (!sandboxRunner || !sandboxRunner.isAvailable()) return;
|
|
852
|
+
try {
|
|
853
|
+
const marketplacePlugins = await new PluginStateRepository(db).getMarketplacePlugins();
|
|
854
|
+
const bundleStore = createPluginBundleStore(storage);
|
|
855
|
+
for (const plugin of marketplacePlugins) {
|
|
856
|
+
if (plugin.status !== "active") continue;
|
|
857
|
+
const version = plugin.marketplaceVersion ?? plugin.version;
|
|
858
|
+
const pluginKey = `${plugin.pluginId}:${version}`;
|
|
859
|
+
if (cache.has(pluginKey)) continue;
|
|
860
|
+
try {
|
|
861
|
+
const bundle = await bundleStore.read(plugin.pluginId, version);
|
|
862
|
+
if (!bundle) {
|
|
863
|
+
console.warn(`Dineway: Marketplace plugin ${plugin.pluginId}@${version} not found in bundle storage`);
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
const loaded = await sandboxRunner.load(bundle.manifest, bundle.backendCode);
|
|
867
|
+
cache.set(pluginKey, loaded);
|
|
868
|
+
marketplacePluginKeys.add(pluginKey);
|
|
869
|
+
marketplaceManifestCache.set(plugin.pluginId, {
|
|
870
|
+
id: bundle.manifest.id,
|
|
871
|
+
version: bundle.manifest.version,
|
|
872
|
+
admin: bundle.manifest.admin
|
|
873
|
+
});
|
|
874
|
+
if (bundle.manifest.routes.length > 0) {
|
|
875
|
+
const routeMeta = /* @__PURE__ */ new Map();
|
|
876
|
+
for (const entry of bundle.manifest.routes) {
|
|
877
|
+
const normalized = normalizeManifestRoute(entry);
|
|
878
|
+
routeMeta.set(normalized.name, { public: normalized.public === true });
|
|
879
|
+
}
|
|
880
|
+
sandboxedRouteMetaCache.set(plugin.pluginId, routeMeta);
|
|
881
|
+
}
|
|
882
|
+
console.log(`Dineway: Loaded marketplace plugin ${pluginKey} with capabilities: [${bundle.manifest.capabilities.join(", ")}]`);
|
|
883
|
+
} catch (error) {
|
|
884
|
+
console.error(`Dineway: Failed to load marketplace plugin ${plugin.pluginId}:`, error);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
} catch {}
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Resolve exclusive hook selections on startup.
|
|
891
|
+
*
|
|
892
|
+
* Delegates to the shared resolveExclusiveHooks() in hooks.ts.
|
|
893
|
+
* The runtime version considers all pipeline providers as "active" since
|
|
894
|
+
* the pipeline was already built from only active/enabled plugins.
|
|
895
|
+
*/
|
|
896
|
+
static async resolveExclusiveHooks(pipeline, db, deps) {
|
|
897
|
+
if (pipeline.getRegisteredExclusiveHooks().length === 0) return;
|
|
898
|
+
let optionsRepo;
|
|
899
|
+
try {
|
|
900
|
+
optionsRepo = new OptionsRepository(db);
|
|
901
|
+
} catch {
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
const preferredHints = /* @__PURE__ */ new Map();
|
|
905
|
+
for (const entry of deps.sandboxedPluginEntries) if (entry.preferred && entry.preferred.length > 0) preferredHints.set(entry.id, entry.preferred);
|
|
906
|
+
await resolveExclusiveHooks({
|
|
907
|
+
pipeline,
|
|
908
|
+
isActive: () => true,
|
|
909
|
+
getOption: (key) => optionsRepo.get(key),
|
|
910
|
+
setOption: (key, value) => optionsRepo.set(key, value),
|
|
911
|
+
deleteOption: async (key) => {
|
|
912
|
+
await optionsRepo.delete(key);
|
|
913
|
+
},
|
|
914
|
+
preferredHints
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
/**
|
|
918
|
+
* Build the manifest (rebuilt on each request for freshness)
|
|
919
|
+
*/
|
|
920
|
+
async getManifest() {
|
|
921
|
+
const manifestCollections = {};
|
|
922
|
+
try {
|
|
923
|
+
const registry = new SchemaRegistry(this.db);
|
|
924
|
+
const dbCollections = await registry.listCollections();
|
|
925
|
+
for (const collection of dbCollections) {
|
|
926
|
+
const collectionWithFields = await registry.getCollectionWithFields(collection.slug);
|
|
927
|
+
const fields = {};
|
|
928
|
+
if (collectionWithFields?.fields) for (const field of collectionWithFields.fields) {
|
|
929
|
+
const entry = {
|
|
930
|
+
kind: FIELD_TYPE_TO_KIND[field.type] ?? "string",
|
|
931
|
+
label: field.label,
|
|
932
|
+
required: field.required
|
|
933
|
+
};
|
|
934
|
+
if (field.widget) entry.widget = field.widget;
|
|
935
|
+
if (field.options) entry.options = field.options;
|
|
936
|
+
if (field.validation?.options) entry.options = field.validation.options.map((v) => ({
|
|
937
|
+
value: v,
|
|
938
|
+
label: v.charAt(0).toUpperCase() + v.slice(1)
|
|
939
|
+
}));
|
|
940
|
+
if (field.type === "repeater" && field.validation) entry.validation = field.validation;
|
|
941
|
+
fields[field.slug] = entry;
|
|
942
|
+
}
|
|
943
|
+
manifestCollections[collection.slug] = {
|
|
944
|
+
label: collection.label,
|
|
945
|
+
labelSingular: collection.labelSingular || collection.label,
|
|
946
|
+
supports: collection.supports || [],
|
|
947
|
+
hasSeo: collection.hasSeo,
|
|
948
|
+
urlPattern: collection.urlPattern,
|
|
949
|
+
fields
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
} catch (error) {
|
|
953
|
+
console.debug("Dineway: Could not load database collections:", error);
|
|
954
|
+
}
|
|
955
|
+
const manifestPlugins = {};
|
|
956
|
+
for (const plugin of this.configuredPlugins) {
|
|
957
|
+
const status = this.pluginStates.get(plugin.id);
|
|
958
|
+
const enabled = status === void 0 || status === "active";
|
|
959
|
+
const hasAdminEntry = !!plugin.admin?.entry;
|
|
960
|
+
const hasAdminPages = (plugin.admin?.pages?.length ?? 0) > 0;
|
|
961
|
+
const hasWidgets = (plugin.admin?.widgets?.length ?? 0) > 0;
|
|
962
|
+
let adminMode = "none";
|
|
963
|
+
if (hasAdminEntry) adminMode = "react";
|
|
964
|
+
else if (hasAdminPages || hasWidgets) adminMode = "blocks";
|
|
965
|
+
manifestPlugins[plugin.id] = {
|
|
966
|
+
version: plugin.version,
|
|
967
|
+
enabled,
|
|
968
|
+
adminMode,
|
|
969
|
+
adminPages: plugin.admin?.pages ?? [],
|
|
970
|
+
dashboardWidgets: plugin.admin?.widgets ?? [],
|
|
971
|
+
portableTextBlocks: plugin.admin?.portableTextBlocks,
|
|
972
|
+
fieldWidgets: plugin.admin?.fieldWidgets
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
for (const entry of this.sandboxedPluginEntries) {
|
|
976
|
+
const status = this.pluginStates.get(entry.id);
|
|
977
|
+
const enabled = status === void 0 || status === "active";
|
|
978
|
+
const hasAdminPages = (entry.adminPages?.length ?? 0) > 0;
|
|
979
|
+
const hasWidgets = (entry.adminWidgets?.length ?? 0) > 0;
|
|
980
|
+
manifestPlugins[entry.id] = {
|
|
981
|
+
version: entry.version,
|
|
982
|
+
enabled,
|
|
983
|
+
sandboxed: true,
|
|
984
|
+
adminMode: hasAdminPages || hasWidgets ? "blocks" : "none",
|
|
985
|
+
adminPages: entry.adminPages ?? [],
|
|
986
|
+
dashboardWidgets: entry.adminWidgets ?? []
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
for (const [pluginId, meta] of marketplaceManifestCache) {
|
|
990
|
+
if (manifestPlugins[pluginId]) continue;
|
|
991
|
+
const enabled = this.pluginStates.get(pluginId) === "active";
|
|
992
|
+
const pages = meta.admin?.pages;
|
|
993
|
+
const widgets = meta.admin?.widgets;
|
|
994
|
+
const hasAdminPages = (pages?.length ?? 0) > 0;
|
|
995
|
+
const hasWidgets = (widgets?.length ?? 0) > 0;
|
|
996
|
+
manifestPlugins[pluginId] = {
|
|
997
|
+
version: meta.version,
|
|
998
|
+
enabled,
|
|
999
|
+
sandboxed: true,
|
|
1000
|
+
adminMode: hasAdminPages || hasWidgets ? "blocks" : "none",
|
|
1001
|
+
adminPages: pages ?? [],
|
|
1002
|
+
dashboardWidgets: widgets ?? []
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
let manifestTaxonomies = [];
|
|
1006
|
+
try {
|
|
1007
|
+
manifestTaxonomies = (await this.db.selectFrom("_dineway_taxonomy_defs").selectAll().orderBy("name").execute()).map((row) => ({
|
|
1008
|
+
name: row.name,
|
|
1009
|
+
label: row.label,
|
|
1010
|
+
labelSingular: row.label_singular ?? void 0,
|
|
1011
|
+
hierarchical: row.hierarchical === 1,
|
|
1012
|
+
collections: parseStringArray(row.collections)
|
|
1013
|
+
}));
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
console.debug("Dineway: Could not load taxonomy definitions:", error);
|
|
1016
|
+
}
|
|
1017
|
+
const manifestHash = await hashString(JSON.stringify(manifestCollections) + JSON.stringify(manifestPlugins) + JSON.stringify(manifestTaxonomies));
|
|
1018
|
+
const authMode = getAuthMode(this.config);
|
|
1019
|
+
const authModeValue = authMode.type === "external" ? authMode.providerType : "passkey";
|
|
1020
|
+
const i18nConfig = virtualConfig?.i18n;
|
|
1021
|
+
const i18n = i18nConfig && i18nConfig.locales && i18nConfig.locales.length > 1 ? {
|
|
1022
|
+
defaultLocale: i18nConfig.defaultLocale,
|
|
1023
|
+
locales: i18nConfig.locales
|
|
1024
|
+
} : void 0;
|
|
1025
|
+
return {
|
|
1026
|
+
version: "0.1.0",
|
|
1027
|
+
hash: manifestHash,
|
|
1028
|
+
collections: manifestCollections,
|
|
1029
|
+
plugins: manifestPlugins,
|
|
1030
|
+
taxonomies: manifestTaxonomies,
|
|
1031
|
+
authMode: authModeValue,
|
|
1032
|
+
i18n,
|
|
1033
|
+
marketplace: !!this.config.marketplace
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Invalidate the cached manifest (no-op now that we don't cache).
|
|
1038
|
+
* Kept for API compatibility.
|
|
1039
|
+
*/
|
|
1040
|
+
invalidateManifest() {}
|
|
1041
|
+
async handleContentList(collection, params) {
|
|
1042
|
+
return handleContentList(this.db, collection, params);
|
|
1043
|
+
}
|
|
1044
|
+
async handleContentGet(collection, id, locale) {
|
|
1045
|
+
return handleContentGet(this.db, collection, id, locale);
|
|
1046
|
+
}
|
|
1047
|
+
async handleContentGetIncludingTrashed(collection, id, locale) {
|
|
1048
|
+
return handleContentGetIncludingTrashed(this.db, collection, id, locale);
|
|
1049
|
+
}
|
|
1050
|
+
async handleContentCreate(collection, body) {
|
|
1051
|
+
let processedData = body.data;
|
|
1052
|
+
if (this.hooks.hasHooks("content:beforeSave")) processedData = (await this.hooks.runContentBeforeSave(body.data, collection, true)).content;
|
|
1053
|
+
processedData = await this.runSandboxedBeforeSave(processedData, collection, true);
|
|
1054
|
+
processedData = await this.normalizeMediaFields(collection, processedData);
|
|
1055
|
+
const result = await handleContentCreate(this.db, collection, {
|
|
1056
|
+
...body,
|
|
1057
|
+
data: processedData,
|
|
1058
|
+
authorId: body.authorId,
|
|
1059
|
+
bylines: body.bylines
|
|
1060
|
+
});
|
|
1061
|
+
if (result.success && result.data) this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, true);
|
|
1062
|
+
return result;
|
|
1063
|
+
}
|
|
1064
|
+
async handleContentUpdate(collection, id, body) {
|
|
1065
|
+
const { ContentRepository } = await import("../content-zSgdNmnt.mjs").then((n) => n.n);
|
|
1066
|
+
const repo = new ContentRepository(this.db);
|
|
1067
|
+
const resolvedItem = await repo.findByIdOrSlug(collection, id);
|
|
1068
|
+
const resolvedId = resolvedItem?.id ?? id;
|
|
1069
|
+
if (body._rev) {
|
|
1070
|
+
if (!resolvedItem) return {
|
|
1071
|
+
success: false,
|
|
1072
|
+
error: {
|
|
1073
|
+
code: "NOT_FOUND",
|
|
1074
|
+
message: `Content item not found: ${id}`
|
|
1075
|
+
}
|
|
1076
|
+
};
|
|
1077
|
+
const revCheck = validateRev(body._rev, resolvedItem);
|
|
1078
|
+
if (!revCheck.valid) return {
|
|
1079
|
+
success: false,
|
|
1080
|
+
error: {
|
|
1081
|
+
code: "CONFLICT",
|
|
1082
|
+
message: revCheck.message
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
const { _rev: _discardedRev, ...bodyWithoutRev } = body;
|
|
1087
|
+
let processedData = bodyWithoutRev.data;
|
|
1088
|
+
if (bodyWithoutRev.data) {
|
|
1089
|
+
if (this.hooks.hasHooks("content:beforeSave")) processedData = (await this.hooks.runContentBeforeSave(bodyWithoutRev.data, collection, false)).content;
|
|
1090
|
+
processedData = await this.runSandboxedBeforeSave(processedData, collection, false);
|
|
1091
|
+
processedData = await this.normalizeMediaFields(collection, processedData);
|
|
1092
|
+
}
|
|
1093
|
+
let usesDraftRevisions = false;
|
|
1094
|
+
if (processedData) try {
|
|
1095
|
+
if ((await this.schemaRegistry.getCollectionWithFields(collection))?.supports?.includes("revisions")) {
|
|
1096
|
+
usesDraftRevisions = true;
|
|
1097
|
+
const revisionRepo = new RevisionRepository(this.db);
|
|
1098
|
+
const existing = await repo.findById(collection, resolvedId);
|
|
1099
|
+
if (existing) {
|
|
1100
|
+
let baseData;
|
|
1101
|
+
if (existing.draftRevisionId) baseData = (await revisionRepo.findById(existing.draftRevisionId))?.data ?? existing.data;
|
|
1102
|
+
else baseData = existing.data;
|
|
1103
|
+
const mergedData = {
|
|
1104
|
+
...baseData,
|
|
1105
|
+
...processedData
|
|
1106
|
+
};
|
|
1107
|
+
if (bodyWithoutRev.slug !== void 0) mergedData._slug = bodyWithoutRev.slug;
|
|
1108
|
+
if (bodyWithoutRev.skipRevision && existing.draftRevisionId) await revisionRepo.updateData(existing.draftRevisionId, mergedData);
|
|
1109
|
+
else {
|
|
1110
|
+
const revision = await revisionRepo.create({
|
|
1111
|
+
collection,
|
|
1112
|
+
entryId: resolvedId,
|
|
1113
|
+
data: mergedData,
|
|
1114
|
+
authorId: bodyWithoutRev.authorId ?? void 0
|
|
1115
|
+
});
|
|
1116
|
+
const tableName = `ec_${collection}`;
|
|
1117
|
+
await sql`
|
|
1118
|
+
UPDATE ${sql.ref(tableName)}
|
|
1119
|
+
SET draft_revision_id = ${revision.id},
|
|
1120
|
+
updated_at = ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1121
|
+
WHERE id = ${resolvedId}
|
|
1122
|
+
`.execute(this.db);
|
|
1123
|
+
revisionRepo.pruneOldRevisions(collection, resolvedId, 50).catch(() => {});
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
} catch {}
|
|
1128
|
+
const result = await handleContentUpdate(this.db, collection, resolvedId, {
|
|
1129
|
+
...bodyWithoutRev,
|
|
1130
|
+
data: usesDraftRevisions ? void 0 : processedData,
|
|
1131
|
+
slug: usesDraftRevisions ? void 0 : bodyWithoutRev.slug,
|
|
1132
|
+
authorId: bodyWithoutRev.authorId,
|
|
1133
|
+
bylines: bodyWithoutRev.bylines
|
|
1134
|
+
});
|
|
1135
|
+
if (result.success && result.data) this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, false);
|
|
1136
|
+
return result;
|
|
1137
|
+
}
|
|
1138
|
+
async handleContentDelete(collection, id) {
|
|
1139
|
+
if (this.hooks.hasHooks("content:beforeDelete")) {
|
|
1140
|
+
const { allowed } = await this.hooks.runContentBeforeDelete(id, collection);
|
|
1141
|
+
if (!allowed) return {
|
|
1142
|
+
success: false,
|
|
1143
|
+
error: {
|
|
1144
|
+
code: "DELETE_BLOCKED",
|
|
1145
|
+
message: "Delete blocked by plugin hook"
|
|
1146
|
+
}
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
if (!await this.runSandboxedBeforeDelete(id, collection)) return {
|
|
1150
|
+
success: false,
|
|
1151
|
+
error: {
|
|
1152
|
+
code: "DELETE_BLOCKED",
|
|
1153
|
+
message: "Delete blocked by sandboxed plugin hook"
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
const result = await handleContentDelete(this.db, collection, id);
|
|
1157
|
+
if (result.success) this.runAfterDeleteHooks(id, collection);
|
|
1158
|
+
return result;
|
|
1159
|
+
}
|
|
1160
|
+
async handleContentListTrashed(collection, params = {}) {
|
|
1161
|
+
return handleContentListTrashed(this.db, collection, params);
|
|
1162
|
+
}
|
|
1163
|
+
async handleContentRestore(collection, id) {
|
|
1164
|
+
return handleContentRestore(this.db, collection, id);
|
|
1165
|
+
}
|
|
1166
|
+
async handleContentPermanentDelete(collection, id) {
|
|
1167
|
+
return handleContentPermanentDelete(this.db, collection, id);
|
|
1168
|
+
}
|
|
1169
|
+
async handleContentCountTrashed(collection) {
|
|
1170
|
+
return handleContentCountTrashed(this.db, collection);
|
|
1171
|
+
}
|
|
1172
|
+
async handleContentDuplicate(collection, id, authorId) {
|
|
1173
|
+
return handleContentDuplicate(this.db, collection, id, authorId);
|
|
1174
|
+
}
|
|
1175
|
+
async handleContentPublish(collection, id) {
|
|
1176
|
+
const result = await handleContentPublish(this.db, collection, id);
|
|
1177
|
+
if (result.success && result.data) this.runAfterPublishHooks(contentItemToRecord(result.data.item), collection);
|
|
1178
|
+
return result;
|
|
1179
|
+
}
|
|
1180
|
+
async handleContentUnpublish(collection, id) {
|
|
1181
|
+
const result = await handleContentUnpublish(this.db, collection, id);
|
|
1182
|
+
if (result.success && result.data) this.runAfterUnpublishHooks(contentItemToRecord(result.data.item), collection);
|
|
1183
|
+
return result;
|
|
1184
|
+
}
|
|
1185
|
+
async handleContentSchedule(collection, id, scheduledAt) {
|
|
1186
|
+
return handleContentSchedule(this.db, collection, id, scheduledAt);
|
|
1187
|
+
}
|
|
1188
|
+
async handleContentUnschedule(collection, id) {
|
|
1189
|
+
return handleContentUnschedule(this.db, collection, id);
|
|
1190
|
+
}
|
|
1191
|
+
async handleContentCountScheduled(collection) {
|
|
1192
|
+
return handleContentCountScheduled(this.db, collection);
|
|
1193
|
+
}
|
|
1194
|
+
async handleContentDiscardDraft(collection, id) {
|
|
1195
|
+
return handleContentDiscardDraft(this.db, collection, id);
|
|
1196
|
+
}
|
|
1197
|
+
async handleContentCompare(collection, id) {
|
|
1198
|
+
return handleContentCompare(this.db, collection, id);
|
|
1199
|
+
}
|
|
1200
|
+
async handleContentTranslations(collection, id) {
|
|
1201
|
+
return handleContentTranslations(this.db, collection, id);
|
|
1202
|
+
}
|
|
1203
|
+
async handleMediaList(params) {
|
|
1204
|
+
return handleMediaList(this.db, params);
|
|
1205
|
+
}
|
|
1206
|
+
async handleMediaGet(id) {
|
|
1207
|
+
return handleMediaGet(this.db, id);
|
|
1208
|
+
}
|
|
1209
|
+
async handleMediaCreate(input) {
|
|
1210
|
+
let processedInput = input;
|
|
1211
|
+
if (this.hooks.hasHooks("media:beforeUpload")) {
|
|
1212
|
+
const hookResult = await this.hooks.runMediaBeforeUpload({
|
|
1213
|
+
name: input.filename,
|
|
1214
|
+
type: input.mimeType,
|
|
1215
|
+
size: input.size || 0
|
|
1216
|
+
});
|
|
1217
|
+
processedInput = {
|
|
1218
|
+
...input,
|
|
1219
|
+
filename: hookResult.file.name,
|
|
1220
|
+
mimeType: hookResult.file.type,
|
|
1221
|
+
size: hookResult.file.size
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
const result = await handleMediaCreate(this.db, processedInput);
|
|
1225
|
+
if (result.success && this.hooks.hasHooks("media:afterUpload")) {
|
|
1226
|
+
const item = result.data.item;
|
|
1227
|
+
const mediaItem = {
|
|
1228
|
+
id: item.id,
|
|
1229
|
+
filename: item.filename,
|
|
1230
|
+
mimeType: item.mimeType,
|
|
1231
|
+
size: item.size,
|
|
1232
|
+
url: `/media/${item.id}/${item.filename}`,
|
|
1233
|
+
createdAt: item.createdAt
|
|
1234
|
+
};
|
|
1235
|
+
this.hooks.runMediaAfterUpload(mediaItem).catch((err) => console.error("Dineway afterUpload hook error:", err));
|
|
1236
|
+
}
|
|
1237
|
+
return result;
|
|
1238
|
+
}
|
|
1239
|
+
async handleMediaUpdate(id, input) {
|
|
1240
|
+
return handleMediaUpdate(this.db, id, input);
|
|
1241
|
+
}
|
|
1242
|
+
async handleMediaDelete(id) {
|
|
1243
|
+
return handleMediaDelete(this.db, id);
|
|
1244
|
+
}
|
|
1245
|
+
async handleRevisionList(collection, entryId, params = {}) {
|
|
1246
|
+
return handleRevisionList(this.db, collection, entryId, params);
|
|
1247
|
+
}
|
|
1248
|
+
async handleRevisionGet(revisionId) {
|
|
1249
|
+
return handleRevisionGet(this.db, revisionId);
|
|
1250
|
+
}
|
|
1251
|
+
async handleRevisionRestore(revisionId, callerUserId) {
|
|
1252
|
+
return handleRevisionRestore(this.db, revisionId, callerUserId);
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Get route metadata for a plugin route without invoking the handler.
|
|
1256
|
+
* Used by the catch-all route to decide auth before dispatch.
|
|
1257
|
+
* Returns null if the plugin or route doesn't exist.
|
|
1258
|
+
*/
|
|
1259
|
+
getPluginRouteMeta(pluginId, path) {
|
|
1260
|
+
if (!this.isPluginEnabled(pluginId)) return null;
|
|
1261
|
+
const routeKey = path.replace(LEADING_SLASH_PATTERN, "");
|
|
1262
|
+
const trustedPlugin = this.configuredPlugins.find((p) => p.id === pluginId);
|
|
1263
|
+
if (trustedPlugin) {
|
|
1264
|
+
const route = trustedPlugin.routes[routeKey];
|
|
1265
|
+
if (!route) return null;
|
|
1266
|
+
return { public: route.public === true };
|
|
1267
|
+
}
|
|
1268
|
+
const meta = sandboxedRouteMetaCache.get(pluginId);
|
|
1269
|
+
if (meta) {
|
|
1270
|
+
const routeMeta = meta.get(routeKey);
|
|
1271
|
+
if (routeMeta) return routeMeta;
|
|
1272
|
+
}
|
|
1273
|
+
if (routeKey === "admin") {
|
|
1274
|
+
const manifestMeta = marketplaceManifestCache.get(pluginId);
|
|
1275
|
+
if (manifestMeta?.admin?.pages?.length || manifestMeta?.admin?.widgets?.length) return { public: false };
|
|
1276
|
+
const entry = this.sandboxedPluginEntries.find((e) => e.id === pluginId);
|
|
1277
|
+
if (entry?.adminPages?.length || entry?.adminWidgets?.length) return { public: false };
|
|
1278
|
+
}
|
|
1279
|
+
if (this.findSandboxedPlugin(pluginId)) return { public: false };
|
|
1280
|
+
return null;
|
|
1281
|
+
}
|
|
1282
|
+
async handlePluginApiRoute(pluginId, _method, path, request) {
|
|
1283
|
+
if (!this.isPluginEnabled(pluginId)) return {
|
|
1284
|
+
success: false,
|
|
1285
|
+
error: {
|
|
1286
|
+
code: "NOT_FOUND",
|
|
1287
|
+
message: `Plugin not enabled: ${pluginId}`
|
|
1288
|
+
}
|
|
1289
|
+
};
|
|
1290
|
+
const trustedPlugin = this.configuredPlugins.find((p) => p.id === pluginId);
|
|
1291
|
+
if (trustedPlugin && this.enabledPlugins.has(trustedPlugin.id)) {
|
|
1292
|
+
const routeRegistry = new PluginRouteRegistry({
|
|
1293
|
+
db: this.db,
|
|
1294
|
+
emailPipeline: this.email ?? void 0
|
|
1295
|
+
});
|
|
1296
|
+
routeRegistry.register(trustedPlugin);
|
|
1297
|
+
const routeKey = path.replace(LEADING_SLASH_PATTERN, "");
|
|
1298
|
+
let body = void 0;
|
|
1299
|
+
try {
|
|
1300
|
+
body = await request.json();
|
|
1301
|
+
} catch {}
|
|
1302
|
+
return routeRegistry.invoke(pluginId, routeKey, {
|
|
1303
|
+
request,
|
|
1304
|
+
body
|
|
1305
|
+
});
|
|
1306
|
+
}
|
|
1307
|
+
const sandboxedPlugin = this.findSandboxedPlugin(pluginId);
|
|
1308
|
+
if (sandboxedPlugin) return this.handleSandboxedRoute(sandboxedPlugin, path, request);
|
|
1309
|
+
return {
|
|
1310
|
+
success: false,
|
|
1311
|
+
error: {
|
|
1312
|
+
code: "NOT_FOUND",
|
|
1313
|
+
message: `Plugin not found: ${pluginId}`
|
|
1314
|
+
}
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
findSandboxedPlugin(pluginId) {
|
|
1318
|
+
for (const [key, plugin] of this.sandboxedPlugins) if (key.startsWith(pluginId + ":")) return plugin;
|
|
1319
|
+
}
|
|
1320
|
+
/**
|
|
1321
|
+
* Normalize image/file fields in content data.
|
|
1322
|
+
* Fills missing dimensions, storageKey, mimeType, and filename from providers.
|
|
1323
|
+
*/
|
|
1324
|
+
async normalizeMediaFields(collection, data) {
|
|
1325
|
+
let collectionInfo;
|
|
1326
|
+
try {
|
|
1327
|
+
collectionInfo = await this.schemaRegistry.getCollectionWithFields(collection);
|
|
1328
|
+
} catch {
|
|
1329
|
+
return data;
|
|
1330
|
+
}
|
|
1331
|
+
if (!collectionInfo?.fields) return data;
|
|
1332
|
+
const imageFields = collectionInfo.fields.filter((f) => f.type === "image" || f.type === "file");
|
|
1333
|
+
if (imageFields.length === 0) return data;
|
|
1334
|
+
const getProvider = (id) => this.getMediaProvider(id);
|
|
1335
|
+
const result = { ...data };
|
|
1336
|
+
for (const field of imageFields) {
|
|
1337
|
+
const value = result[field.slug];
|
|
1338
|
+
if (value == null) continue;
|
|
1339
|
+
try {
|
|
1340
|
+
const normalized = await normalizeMediaValue(value, getProvider);
|
|
1341
|
+
if (normalized) result[field.slug] = normalized;
|
|
1342
|
+
} catch {}
|
|
1343
|
+
}
|
|
1344
|
+
return result;
|
|
1345
|
+
}
|
|
1346
|
+
async runSandboxedBeforeSave(content, collection, isNew) {
|
|
1347
|
+
let result = content;
|
|
1348
|
+
for (const [pluginKey, plugin] of this.sandboxedPlugins) {
|
|
1349
|
+
const [id] = pluginKey.split(":");
|
|
1350
|
+
if (!id || !this.isPluginEnabled(id)) continue;
|
|
1351
|
+
try {
|
|
1352
|
+
const hookResult = await plugin.invokeHook("content:beforeSave", {
|
|
1353
|
+
content: result,
|
|
1354
|
+
collection,
|
|
1355
|
+
isNew
|
|
1356
|
+
});
|
|
1357
|
+
if (hookResult && typeof hookResult === "object" && !Array.isArray(hookResult)) {
|
|
1358
|
+
const record = {};
|
|
1359
|
+
for (const [k, v] of Object.entries(hookResult)) record[k] = v;
|
|
1360
|
+
result = record;
|
|
1361
|
+
}
|
|
1362
|
+
} catch (error) {
|
|
1363
|
+
console.error(`Dineway: Sandboxed plugin ${id} beforeSave hook error:`, error);
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
return result;
|
|
1367
|
+
}
|
|
1368
|
+
async runSandboxedBeforeDelete(id, collection) {
|
|
1369
|
+
for (const [pluginKey, plugin] of this.sandboxedPlugins) {
|
|
1370
|
+
const [pluginId] = pluginKey.split(":");
|
|
1371
|
+
if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
|
|
1372
|
+
try {
|
|
1373
|
+
if (await plugin.invokeHook("content:beforeDelete", {
|
|
1374
|
+
id,
|
|
1375
|
+
collection
|
|
1376
|
+
}) === false) return false;
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
console.error(`Dineway: Sandboxed plugin ${pluginId} beforeDelete hook error:`, error);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
return true;
|
|
1382
|
+
}
|
|
1383
|
+
runAfterSaveHooks(content, collection, isNew) {
|
|
1384
|
+
if (this.hooks.hasHooks("content:afterSave")) this.hooks.runContentAfterSave(content, collection, isNew).catch((err) => console.error("Dineway afterSave hook error:", err));
|
|
1385
|
+
for (const [pluginKey, plugin] of this.sandboxedPlugins) {
|
|
1386
|
+
const [id] = pluginKey.split(":");
|
|
1387
|
+
if (!id || !this.isPluginEnabled(id)) continue;
|
|
1388
|
+
plugin.invokeHook("content:afterSave", {
|
|
1389
|
+
content,
|
|
1390
|
+
collection,
|
|
1391
|
+
isNew
|
|
1392
|
+
}).catch((err) => console.error(`Dineway: Sandboxed plugin ${id} afterSave error:`, err));
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
runAfterDeleteHooks(id, collection) {
|
|
1396
|
+
if (this.hooks.hasHooks("content:afterDelete")) this.hooks.runContentAfterDelete(id, collection).catch((err) => console.error("Dineway afterDelete hook error:", err));
|
|
1397
|
+
for (const [pluginKey, plugin] of this.sandboxedPlugins) {
|
|
1398
|
+
const [pluginId] = pluginKey.split(":");
|
|
1399
|
+
if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
|
|
1400
|
+
plugin.invokeHook("content:afterDelete", {
|
|
1401
|
+
id,
|
|
1402
|
+
collection
|
|
1403
|
+
}).catch((err) => console.error(`Dineway: Sandboxed plugin ${pluginId} afterDelete error:`, err));
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
runAfterPublishHooks(content, collection) {
|
|
1407
|
+
if (this.hooks.hasHooks("content:afterPublish")) this.hooks.runContentAfterPublish(content, collection).catch((err) => console.error("Dineway afterPublish hook error:", err));
|
|
1408
|
+
for (const [pluginKey, plugin] of this.sandboxedPlugins) {
|
|
1409
|
+
const [pluginId] = pluginKey.split(":");
|
|
1410
|
+
if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
|
|
1411
|
+
plugin.invokeHook("content:afterPublish", {
|
|
1412
|
+
content,
|
|
1413
|
+
collection
|
|
1414
|
+
}).catch((err) => console.error(`Dineway: Sandboxed plugin ${pluginId} afterPublish error:`, err));
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
runAfterUnpublishHooks(content, collection) {
|
|
1418
|
+
if (this.hooks.hasHooks("content:afterUnpublish")) this.hooks.runContentAfterUnpublish(content, collection).catch((err) => console.error("Dineway afterUnpublish hook error:", err));
|
|
1419
|
+
for (const [pluginKey, plugin] of this.sandboxedPlugins) {
|
|
1420
|
+
const [pluginId] = pluginKey.split(":");
|
|
1421
|
+
if (!pluginId || !this.isPluginEnabled(pluginId)) continue;
|
|
1422
|
+
plugin.invokeHook("content:afterUnpublish", {
|
|
1423
|
+
content,
|
|
1424
|
+
collection
|
|
1425
|
+
}).catch((err) => console.error(`Dineway: Sandboxed plugin ${pluginId} afterUnpublish error:`, err));
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
async handleSandboxedRoute(plugin, path, request) {
|
|
1429
|
+
const routeName = path.replace(LEADING_SLASH_PATTERN, "");
|
|
1430
|
+
let body = void 0;
|
|
1431
|
+
try {
|
|
1432
|
+
body = await request.json();
|
|
1433
|
+
} catch {}
|
|
1434
|
+
try {
|
|
1435
|
+
const headers = sanitizeHeadersForSandbox(request.headers);
|
|
1436
|
+
const meta = extractRequestMeta(request);
|
|
1437
|
+
return {
|
|
1438
|
+
success: true,
|
|
1439
|
+
data: await plugin.invokeRoute(routeName, body, {
|
|
1440
|
+
url: request.url,
|
|
1441
|
+
method: request.method,
|
|
1442
|
+
headers,
|
|
1443
|
+
meta
|
|
1444
|
+
})
|
|
1445
|
+
};
|
|
1446
|
+
} catch (error) {
|
|
1447
|
+
console.error(`Dineway: Sandboxed plugin route error:`, error);
|
|
1448
|
+
return {
|
|
1449
|
+
success: false,
|
|
1450
|
+
error: {
|
|
1451
|
+
code: "ROUTE_ERROR",
|
|
1452
|
+
message: error instanceof Error ? error.message : String(error)
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Cache for page contributions. Uses a WeakMap keyed on the PublicPageContext
|
|
1459
|
+
* object so results are collected once per page context per request, even when
|
|
1460
|
+
* multiple render components (DinewayHead, DinewayBodyStart, DinewayBodyEnd)
|
|
1461
|
+
* request contributions from the same page.
|
|
1462
|
+
*/
|
|
1463
|
+
pageContributionCache = /* @__PURE__ */ new WeakMap();
|
|
1464
|
+
/**
|
|
1465
|
+
* Collect all page contributions (metadata + fragments) in a single pass.
|
|
1466
|
+
* Results are cached by page context object identity.
|
|
1467
|
+
*/
|
|
1468
|
+
async collectPageContributions(page) {
|
|
1469
|
+
const cached = this.pageContributionCache.get(page);
|
|
1470
|
+
if (cached) return cached;
|
|
1471
|
+
const promise = this.doCollectPageContributions(page);
|
|
1472
|
+
this.pageContributionCache.set(page, promise);
|
|
1473
|
+
return promise;
|
|
1474
|
+
}
|
|
1475
|
+
async doCollectPageContributions(page) {
|
|
1476
|
+
const metadata = [];
|
|
1477
|
+
const fragments = [];
|
|
1478
|
+
if (this.hooks.hasHooks("page:metadata")) {
|
|
1479
|
+
const results = await this.hooks.runPageMetadata({ page });
|
|
1480
|
+
for (const r of results) metadata.push(...r.contributions);
|
|
1481
|
+
}
|
|
1482
|
+
if (this.hooks.hasHooks("page:fragments")) {
|
|
1483
|
+
const results = await this.hooks.runPageFragments({ page });
|
|
1484
|
+
for (const r of results) fragments.push(...r.contributions);
|
|
1485
|
+
}
|
|
1486
|
+
for (const [pluginKey, plugin] of this.sandboxedPlugins) {
|
|
1487
|
+
const [id] = pluginKey.split(":");
|
|
1488
|
+
if (!id || !this.isPluginEnabled(id)) continue;
|
|
1489
|
+
try {
|
|
1490
|
+
const result = await plugin.invokeHook("page:metadata", { page });
|
|
1491
|
+
if (result != null) {
|
|
1492
|
+
const items = Array.isArray(result) ? result : [result];
|
|
1493
|
+
for (const item of items) if (isValidMetadataContribution(item)) metadata.push(item);
|
|
1494
|
+
}
|
|
1495
|
+
} catch (error) {
|
|
1496
|
+
console.error(`Dineway: Sandboxed plugin ${id} page:metadata error:`, error);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
return {
|
|
1500
|
+
metadata,
|
|
1501
|
+
fragments
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Collect page metadata contributions from trusted and sandboxed plugins.
|
|
1506
|
+
* Delegates to the single-pass collector and returns the metadata portion.
|
|
1507
|
+
*/
|
|
1508
|
+
async collectPageMetadata(page) {
|
|
1509
|
+
const { metadata } = await this.collectPageContributions(page);
|
|
1510
|
+
return metadata;
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Collect page fragment contributions from trusted plugins only.
|
|
1514
|
+
* Delegates to the single-pass collector and returns the fragments portion.
|
|
1515
|
+
*/
|
|
1516
|
+
async collectPageFragments(page) {
|
|
1517
|
+
const { fragments } = await this.collectPageContributions(page);
|
|
1518
|
+
return fragments;
|
|
1519
|
+
}
|
|
1520
|
+
isPluginEnabled(pluginId) {
|
|
1521
|
+
const status = this.pluginStates.get(pluginId);
|
|
1522
|
+
return status === void 0 || status === "active";
|
|
1523
|
+
}
|
|
1524
|
+
};
|
|
1525
|
+
|
|
1526
|
+
//#endregion
|
|
1527
|
+
//#region src/astro/middleware.ts
|
|
1528
|
+
/**
|
|
1529
|
+
* Dineway middleware
|
|
1530
|
+
*
|
|
1531
|
+
* Thin wrapper that initializes DinewayRuntime and attaches it to locals.
|
|
1532
|
+
* All heavy lifting happens in DinewayRuntime.
|
|
1533
|
+
*/
|
|
1534
|
+
let runtimeInstance = null;
|
|
1535
|
+
let runtimeInitializing = false;
|
|
1536
|
+
/** Whether i18n config has been initialized from the virtual module */
|
|
1537
|
+
let i18nInitialized = false;
|
|
1538
|
+
/**
|
|
1539
|
+
* Whether we've verified the database has been set up.
|
|
1540
|
+
* On a fresh deployment the first request may hit a public page, bypassing
|
|
1541
|
+
* runtime init. Without this check, template helpers like getSiteSettings()
|
|
1542
|
+
* would query an empty database and crash. Once verified (or once the runtime
|
|
1543
|
+
* has initialized via an admin/API request), this stays true for the worker's
|
|
1544
|
+
* lifetime.
|
|
1545
|
+
*/
|
|
1546
|
+
let setupVerified = false;
|
|
1547
|
+
/**
|
|
1548
|
+
* Get Dineway configuration from virtual module
|
|
1549
|
+
*/
|
|
1550
|
+
function getConfig() {
|
|
1551
|
+
if (virtualConfig && typeof virtualConfig === "object") {
|
|
1552
|
+
if (!i18nInitialized) {
|
|
1553
|
+
i18nInitialized = true;
|
|
1554
|
+
const config = virtualConfig;
|
|
1555
|
+
if (config.i18n && typeof config.i18n === "object") setI18nConfig(config.i18n);
|
|
1556
|
+
else setI18nConfig(null);
|
|
1557
|
+
}
|
|
1558
|
+
return virtualConfig;
|
|
1559
|
+
}
|
|
1560
|
+
return null;
|
|
1561
|
+
}
|
|
1562
|
+
/**
|
|
1563
|
+
* Get plugins from virtual module
|
|
1564
|
+
*/
|
|
1565
|
+
function getPlugins() {
|
|
1566
|
+
return plugins || [];
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Build runtime dependencies from virtual modules
|
|
1570
|
+
*/
|
|
1571
|
+
function buildDependencies(config) {
|
|
1572
|
+
return {
|
|
1573
|
+
config,
|
|
1574
|
+
plugins: getPlugins(),
|
|
1575
|
+
createDialect,
|
|
1576
|
+
createStorage,
|
|
1577
|
+
sandboxEnabled,
|
|
1578
|
+
sandboxedPluginEntries: sandboxedPlugins || [],
|
|
1579
|
+
createSandboxRunner,
|
|
1580
|
+
mediaProviderEntries: mediaProviders || []
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Get or create the runtime instance
|
|
1585
|
+
*/
|
|
1586
|
+
async function getRuntime(config) {
|
|
1587
|
+
if (runtimeInstance) return runtimeInstance;
|
|
1588
|
+
if (runtimeInitializing) {
|
|
1589
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1590
|
+
return getRuntime(config);
|
|
1591
|
+
}
|
|
1592
|
+
runtimeInitializing = true;
|
|
1593
|
+
try {
|
|
1594
|
+
const deps = buildDependencies(config);
|
|
1595
|
+
const runtime = await DinewayRuntime.create(deps);
|
|
1596
|
+
runtimeInstance = runtime;
|
|
1597
|
+
return runtime;
|
|
1598
|
+
} finally {
|
|
1599
|
+
runtimeInitializing = false;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Baseline security headers applied to all responses.
|
|
1604
|
+
* Admin routes get additional headers (strict CSP) from auth middleware.
|
|
1605
|
+
*/
|
|
1606
|
+
function setBaselineSecurityHeaders(response) {
|
|
1607
|
+
response.headers.set("X-Content-Type-Options", "nosniff");
|
|
1608
|
+
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
1609
|
+
response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
|
|
1610
|
+
if (!response.headers.has("Content-Security-Policy")) response.headers.set("X-Frame-Options", "SAMEORIGIN");
|
|
1611
|
+
}
|
|
1612
|
+
/** Public routes that require the runtime (sitemap, robots.txt, etc.) */
|
|
1613
|
+
const PUBLIC_RUNTIME_ROUTES = new Set(["/sitemap.xml", "/robots.txt"]);
|
|
1614
|
+
const SITEMAP_COLLECTION_RE = /^\/sitemap-[a-z][a-z0-9_]*\.xml$/;
|
|
1615
|
+
const onRequest = defineMiddleware(async (context, next) => {
|
|
1616
|
+
const { locals, cookies } = context;
|
|
1617
|
+
const url = context.url;
|
|
1618
|
+
const isDinewayRoute = url.pathname.startsWith("/_dineway");
|
|
1619
|
+
const isPublicRuntimeRoute = PUBLIC_RUNTIME_ROUTES.has(url.pathname) || SITEMAP_COLLECTION_RE.test(url.pathname);
|
|
1620
|
+
const hasEditCookie = cookies.get("dineway-edit-mode")?.value === "true";
|
|
1621
|
+
const hasPreviewToken = url.searchParams.has("_preview");
|
|
1622
|
+
const requestDb = locals.__dinewayRequestDb;
|
|
1623
|
+
if (!isDinewayRoute && !isPublicRuntimeRoute && !hasEditCookie && !hasPreviewToken) {
|
|
1624
|
+
if (!(context.isPrerendered ? null : await context.session?.get("user")) && !requestDb) {
|
|
1625
|
+
if (!setupVerified) try {
|
|
1626
|
+
const { getDb } = await import("../loader-qKmo0wAY.mjs").then((n) => n.r);
|
|
1627
|
+
await (await getDb()).selectFrom("_dineway_migrations").selectAll().limit(1).execute();
|
|
1628
|
+
setupVerified = true;
|
|
1629
|
+
} catch {
|
|
1630
|
+
return context.redirect("/_dineway/admin/setup");
|
|
1631
|
+
}
|
|
1632
|
+
const config = getConfig();
|
|
1633
|
+
if (config) try {
|
|
1634
|
+
const runtime = await getRuntime(config);
|
|
1635
|
+
setupVerified = true;
|
|
1636
|
+
locals.dineway = {
|
|
1637
|
+
collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
|
|
1638
|
+
collectPageFragments: runtime.collectPageFragments.bind(runtime)
|
|
1639
|
+
};
|
|
1640
|
+
} catch {}
|
|
1641
|
+
const response = await next();
|
|
1642
|
+
setBaselineSecurityHeaders(response);
|
|
1643
|
+
return response;
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
const config = getConfig();
|
|
1647
|
+
if (!config) {
|
|
1648
|
+
console.error("Dineway: No configuration found");
|
|
1649
|
+
return next();
|
|
1650
|
+
}
|
|
1651
|
+
const doInit = async () => {
|
|
1652
|
+
try {
|
|
1653
|
+
const runtime = await getRuntime(config);
|
|
1654
|
+
setupVerified = true;
|
|
1655
|
+
locals.dinewayManifest = await runtime.getManifest();
|
|
1656
|
+
locals.dineway = {
|
|
1657
|
+
handleContentList: runtime.handleContentList.bind(runtime),
|
|
1658
|
+
handleContentGet: runtime.handleContentGet.bind(runtime),
|
|
1659
|
+
handleContentCreate: runtime.handleContentCreate.bind(runtime),
|
|
1660
|
+
handleContentUpdate: runtime.handleContentUpdate.bind(runtime),
|
|
1661
|
+
handleContentDelete: runtime.handleContentDelete.bind(runtime),
|
|
1662
|
+
handleContentListTrashed: runtime.handleContentListTrashed.bind(runtime),
|
|
1663
|
+
handleContentRestore: runtime.handleContentRestore.bind(runtime),
|
|
1664
|
+
handleContentPermanentDelete: runtime.handleContentPermanentDelete.bind(runtime),
|
|
1665
|
+
handleContentCountTrashed: runtime.handleContentCountTrashed.bind(runtime),
|
|
1666
|
+
handleContentGetIncludingTrashed: runtime.handleContentGetIncludingTrashed.bind(runtime),
|
|
1667
|
+
handleContentDuplicate: runtime.handleContentDuplicate.bind(runtime),
|
|
1668
|
+
handleContentPublish: runtime.handleContentPublish.bind(runtime),
|
|
1669
|
+
handleContentUnpublish: runtime.handleContentUnpublish.bind(runtime),
|
|
1670
|
+
handleContentSchedule: runtime.handleContentSchedule.bind(runtime),
|
|
1671
|
+
handleContentUnschedule: runtime.handleContentUnschedule.bind(runtime),
|
|
1672
|
+
handleContentCountScheduled: runtime.handleContentCountScheduled.bind(runtime),
|
|
1673
|
+
handleContentDiscardDraft: runtime.handleContentDiscardDraft.bind(runtime),
|
|
1674
|
+
handleContentCompare: runtime.handleContentCompare.bind(runtime),
|
|
1675
|
+
handleContentTranslations: runtime.handleContentTranslations.bind(runtime),
|
|
1676
|
+
handleMediaList: runtime.handleMediaList.bind(runtime),
|
|
1677
|
+
handleMediaGet: runtime.handleMediaGet.bind(runtime),
|
|
1678
|
+
handleMediaCreate: runtime.handleMediaCreate.bind(runtime),
|
|
1679
|
+
handleMediaUpdate: runtime.handleMediaUpdate.bind(runtime),
|
|
1680
|
+
handleMediaDelete: runtime.handleMediaDelete.bind(runtime),
|
|
1681
|
+
handleRevisionList: runtime.handleRevisionList.bind(runtime),
|
|
1682
|
+
handleRevisionGet: runtime.handleRevisionGet.bind(runtime),
|
|
1683
|
+
handleRevisionRestore: runtime.handleRevisionRestore.bind(runtime),
|
|
1684
|
+
handlePluginApiRoute: runtime.handlePluginApiRoute.bind(runtime),
|
|
1685
|
+
getPluginRouteMeta: runtime.getPluginRouteMeta.bind(runtime),
|
|
1686
|
+
getMediaProvider: runtime.getMediaProvider.bind(runtime),
|
|
1687
|
+
getMediaProviderList: runtime.getMediaProviderList.bind(runtime),
|
|
1688
|
+
collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
|
|
1689
|
+
collectPageFragments: runtime.collectPageFragments.bind(runtime),
|
|
1690
|
+
storage: runtime.storage,
|
|
1691
|
+
db: runtime.db,
|
|
1692
|
+
hooks: runtime.hooks,
|
|
1693
|
+
email: runtime.email,
|
|
1694
|
+
configuredPlugins: runtime.configuredPlugins,
|
|
1695
|
+
config,
|
|
1696
|
+
invalidateManifest: runtime.invalidateManifest.bind(runtime),
|
|
1697
|
+
getSandboxRunner: runtime.getSandboxRunner.bind(runtime),
|
|
1698
|
+
syncMarketplacePlugins: runtime.syncMarketplacePlugins.bind(runtime),
|
|
1699
|
+
setPluginStatus: runtime.setPluginStatus.bind(runtime)
|
|
1700
|
+
};
|
|
1701
|
+
} catch (error) {
|
|
1702
|
+
console.error("Dineway middleware error:", error);
|
|
1703
|
+
}
|
|
1704
|
+
const response = await next();
|
|
1705
|
+
setBaselineSecurityHeaders(response);
|
|
1706
|
+
return response;
|
|
1707
|
+
};
|
|
1708
|
+
if (requestDb) return runWithContext({
|
|
1709
|
+
editMode: context.cookies.get("dineway-edit-mode")?.value === "true",
|
|
1710
|
+
db: requestDb
|
|
1711
|
+
}, doInit);
|
|
1712
|
+
return doInit();
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
//#endregion
|
|
1716
|
+
export { onRequest as default, onRequest };
|