@beechcms/api 0.6.0-preview.4 → 0.6.1
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/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/chart-CspfxV2U.js +39 -0
- package/assets/dashboard/assets/index-DtDfJyaF.js +644 -0
- package/assets/dashboard/assets/index-Dve7eEqA.css +1 -0
- package/assets/dashboard/assets/{pie-chart-recharts-BMWpjFzf.js → pie-chart-recharts-Bx2IXAjA.js} +1 -1
- package/assets/dashboard/assets/timeseries-chart-recharts-BCWufwI1.js +1 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +17 -18
- package/assets/dashboard/noResult.svg +42 -42
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/dist/auth/constants.d.ts +5 -5
- package/dist/features/automations/{automations.handler.d.ts → api/automations.handler.d.ts} +1 -1
- package/dist/features/automations/{automation-runner.utils.d.ts → engine/automation-runner.utils.d.ts} +1 -1
- package/dist/features/automations/{action-executors → executors}/edit-field.executor.d.ts +1 -1
- package/dist/features/automations/{action-executors → executors}/index.d.ts +1 -1
- package/dist/features/automations/{action-executors → executors}/send-mail.executor.d.ts +1 -1
- package/dist/features/automations/{action-executors → executors}/set-variable.executor.d.ts +1 -1
- package/dist/features/automations/{action-executors → executors}/webhook.executor.d.ts +1 -1
- package/dist/features/automations/{when-evaluator.d.ts → filters/when-evaluator.d.ts} +1 -1
- package/dist/features/automations/index.d.ts +7 -7
- package/dist/features/content/constants.d.ts +10 -10
- package/dist/features/content/handlers/kanban-move.d.ts +3 -0
- package/dist/features/content/handlers/kanban-position.d.ts +3 -0
- package/dist/features/content/handlers/view-config.d.ts +4 -0
- package/dist/{search.d.ts → features/search/search.d.ts} +1 -1
- package/dist/features/upload/index.d.ts +9 -0
- package/dist/{widget.d.ts → features/widget/widget.d.ts} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1091 -439
- package/dist/{middleware.d.ts → middleware/auth.middleware.d.ts} +1 -1
- package/dist/middleware/repository.middleware.d.ts +2 -1
- package/dist/public/access-policy.d.ts +1 -1
- package/dist/public/problem-details.d.ts +1 -0
- package/dist/public/public-errors.d.ts +4 -4
- package/dist/rate-limit/cloudflare-rate-limiter.d.ts +4 -1
- package/dist/rate-limit/in-memory-rate-limiter.d.ts +2 -1
- package/dist/shared/{schema-mutator.d1.d.ts → db/migrations/schema-mutator.d1.d.ts} +1 -0
- package/dist/shared/db/repositories/content.repository.d1.d.ts +259 -0
- package/dist/shared/db/repositories/kanban-position.repository.d1.d.ts +9 -0
- package/dist/shared/{seed-layout.repository.d1.d.ts → db/repositories/seed-layout.repository.d1.d.ts} +3 -1
- package/dist/shared/email/email.provider.d.ts +1 -0
- package/dist/{features → shared}/email/email.types.d.ts +1 -16
- package/dist/{features → shared}/email/providers/resend.d.ts +1 -2
- package/dist/{features → shared}/email/providers/smtp.d.ts +1 -2
- package/dist/shared/jobs/queue-consumer.d.ts +9 -0
- package/dist/shared/services/queue/cloudflare-queue-service.d.ts +11 -0
- package/dist/shared/services/queue/in-memory-queue-service.d.ts +16 -0
- package/dist/shared/storage/s3-bucket.d.ts +0 -1
- package/dist/shared/storage/upload.d.ts +7 -0
- package/dist/{media-utils.d.ts → shared/utils/media-utils.d.ts} +4 -2
- package/dist/types.d.ts +93 -2
- package/migrations/0000_v040_base.sql +265 -265
- package/migrations/0029_automations.sql +14 -14
- package/migrations/0030_test_seeds.sql +125 -125
- package/package.json +5 -4
- package/assets/dashboard/assets/chart-BwlCt-IA.js +0 -39
- package/assets/dashboard/assets/index-BcVN-DNy.js +0 -642
- package/assets/dashboard/assets/index-Cf1yRCqG.css +0 -1
- package/assets/dashboard/assets/timeseries-chart-recharts-8_GwAFR0.js +0 -1
- package/dist/features/email/email.provider.d.ts +0 -37
- package/dist/shared/content.repository.d1.d.ts +0 -68
- package/dist/upload.d.ts +0 -16
- package/dist/auth/{bcrypt-hash-provider.d.ts → providers/hash.provider.d.ts} +0 -0
- package/dist/auth/{jose-token-service.d.ts → providers/jwt-token.service.d.ts} +0 -0
- package/dist/auth/{login.d.ts → utils/login-helpers.d.ts} +0 -0
- package/dist/auth/{refresh.d.ts → utils/refresh-token.d.ts} +0 -0
- package/dist/features/automations/{automations.schema.d.ts → api/automations.schema.d.ts} +16 -16
- /package/dist/features/automations/{automation-runner.d.ts → engine/automation-runner.d.ts} +0 -0
- /package/dist/features/automations/{cron-runner.d.ts → engine/cron-runner.d.ts} +0 -0
- /package/dist/features/automations/{cron-runner.utils.d.ts → engine/cron-runner.utils.d.ts} +0 -0
- /package/dist/features/automations/{context-resolver.d.ts → evaluator/context-resolver.d.ts} +0 -0
- /package/dist/features/automations/{template-grammar.d.ts → evaluator/template-grammar.d.ts} +0 -0
- /package/dist/features/automations/{var-access-resolver.d.ts → evaluator/var-access-resolver.d.ts} +0 -0
- /package/dist/features/automations/{action-executors → executors}/create-entry.executor.d.ts +0 -0
- /package/dist/features/automations/{filter-translation.d.ts → filters/filter-translation.d.ts} +0 -0
- /package/dist/features/automations/{when-pushdown.d.ts → filters/when-pushdown.d.ts} +0 -0
- /package/dist/{search-utils.d.ts → features/search/search-utils.d.ts} +0 -0
- /package/dist/shared/{demo-data-sql.d.ts → db/migrations/demo-data-sql.d.ts} +0 -0
- /package/dist/shared/{automations.repository.d1.d.ts → db/repositories/automations.repository.d1.d.ts} +0 -0
- /package/dist/shared/{base.repository.d1.d.ts → db/repositories/base.repository.d1.d.ts} +0 -0
- /package/dist/shared/{d1-activity-log.repository.d.ts → db/repositories/d1-activity-log.repository.d.ts} +0 -0
- /package/dist/shared/{d1-analytics.repository.d.ts → db/repositories/d1-analytics.repository.d.ts} +0 -0
- /package/dist/shared/{d1-content-scan.repository.d.ts → db/repositories/d1-content-scan.repository.d.ts} +0 -0
- /package/dist/shared/{d1-notification.repository.d.ts → db/repositories/d1-notification.repository.d.ts} +0 -0
- /package/dist/shared/{d1-password-reset-token.repository.d.ts → db/repositories/d1-password-reset-token.repository.d.ts} +0 -0
- /package/dist/shared/{d1-search.repository.d.ts → db/repositories/d1-search.repository.d.ts} +0 -0
- /package/dist/shared/{d1-session.repository.d.ts → db/repositories/d1-session.repository.d.ts} +0 -0
- /package/dist/shared/{d1-setup-checklist.repository.d.ts → db/repositories/d1-setup-checklist.repository.d.ts} +0 -0
- /package/dist/shared/{d1-user.repository.d.ts → db/repositories/d1-user.repository.d.ts} +0 -0
- /package/dist/shared/{d1-widget.repository.d.ts → db/repositories/d1-widget.repository.d.ts} +0 -0
- /package/dist/shared/{dashboard-layout.repository.d1.d.ts → db/repositories/dashboard-layout.repository.d1.d.ts} +0 -0
- /package/dist/shared/{demo-data.repository.d1.d.ts → db/repositories/demo-data.repository.d1.d.ts} +0 -0
- /package/dist/shared/{idempotency.repository.d1.d.ts → db/repositories/idempotency.repository.d1.d.ts} +0 -0
- /package/dist/shared/{in-memory-seed.repository.d.ts → db/repositories/in-memory-seed.repository.d.ts} +0 -0
- /package/dist/shared/{media.repository.d1.d.ts → db/repositories/media.repository.d1.d.ts} +0 -0
- /package/dist/shared/{seed.repository.d1.d.ts → db/repositories/seed.repository.d1.d.ts} +0 -0
- /package/dist/shared/{site-settings.repository.d1.d.ts → db/repositories/site-settings.repository.d1.d.ts} +0 -0
- /package/dist/shared/{system-stats.repository.d1.d.ts → db/repositories/system-stats.repository.d1.d.ts} +0 -0
- /package/dist/{features → shared}/email/email.service.d.ts +0 -0
- /package/dist/{features → shared}/email/index.d.ts +0 -0
- /package/dist/{features → shared}/email/templates/automation-mail.d.ts +0 -0
- /package/dist/{features → shared}/email/templates/password-changed.d.ts +0 -0
- /package/dist/{features → shared}/email/templates/password-reset.d.ts +0 -0
- /package/dist/{features → shared}/email/templates/shell.d.ts +0 -0
- /package/dist/shared/{fts-sync.d.ts → jobs/fts-sync.d.ts} +0 -0
- /package/dist/shared/{apply-policies.d.ts → policies/apply-policies.d.ts} +0 -0
- /package/dist/shared/{d1-activity-logger.d.ts → services/activity-log/d1-activity-logger.d.ts} +0 -0
- /package/dist/shared/{in-memory-activity-logger.d.ts → services/activity-log/in-memory-activity-logger.d.ts} +0 -0
- /package/dist/shared/{seed-registry-cache.d.ts → services/cache/seed-registry-cache.d.ts} +0 -0
- /package/dist/shared/{fixed-clock.d.ts → services/clock/fixed-clock.d.ts} +0 -0
- /package/dist/shared/{sequential-id-generator.d.ts → services/id-generator/sequential-id-generator.d.ts} +0 -0
- /package/dist/shared/{background-notification-service.d.ts → services/notification/background-notification-service.d.ts} +0 -0
- /package/dist/shared/{in-memory-notification-service.d.ts → services/notification/in-memory-notification-service.d.ts} +0 -0
- /package/dist/shared/{qstash-notification-service.d.ts → services/notification/qstash-notification-service.d.ts} +0 -0
- /package/dist/shared/{execution-context-scheduler.d.ts → services/scheduler/execution-context-scheduler.d.ts} +0 -0
- /package/dist/shared/{content-utils.d.ts → utils/content-utils.d.ts} +0 -0
- /package/dist/shared/{query-utils.d.ts → utils/query-utils.d.ts} +0 -0
- /package/dist/shared/{request-utils.d.ts → utils/request-utils.d.ts} +0 -0
- /package/dist/shared/{storage-utils.d.ts → utils/storage-utils.d.ts} +0 -0
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { cors } from "hono/cors";
|
|
|
4
4
|
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
|
5
5
|
import { sha256hex as sha256hex7, SystemClock as SystemClock6, SystemIdGenerator as SystemIdGenerator4 } from "@beechcms/core";
|
|
6
6
|
|
|
7
|
-
// src/shared/request-utils.ts
|
|
7
|
+
// src/shared/utils/request-utils.ts
|
|
8
8
|
var CLOUDFLARE_CLIENT_IP_HEADER = "cf-connecting-ip";
|
|
9
9
|
var UNKNOWN_IP = "unknown";
|
|
10
10
|
function getClientIp(request) {
|
|
@@ -22,11 +22,12 @@ var AUTH_ERRORS = {
|
|
|
22
22
|
RATE_LIMIT_EXCEEDED: "Too many requests"
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
// src/auth/login.ts
|
|
25
|
+
// src/auth/utils/login-helpers.ts
|
|
26
26
|
var EMAIL_REGEX = /^[^\s@]+@[^\s@.]+(?:\.[^\s@.]+)+$/;
|
|
27
27
|
var MAX_EMAIL_LENGTH = 254;
|
|
28
28
|
var MIN_PASSWORD_LENGTH = 8;
|
|
29
29
|
var MAX_PASSWORD_LENGTH = 128;
|
|
30
|
+
var MAX_PASSWORD_BYTES = 72;
|
|
30
31
|
var DUMMY_PASSWORD_HASH = "$2a$10$SbkRFOafACxVM2ahxerVDu3tSkCXWm29b62WdB.4WGG02Qjsfzni6";
|
|
31
32
|
function parseLoginBody(body) {
|
|
32
33
|
if (body === null || typeof body !== "object") return null;
|
|
@@ -35,23 +36,23 @@ function parseLoginBody(body) {
|
|
|
35
36
|
const password = obj.password;
|
|
36
37
|
if (typeof email !== "string" || typeof password !== "string") return null;
|
|
37
38
|
if (!email.trim() || !password) return null;
|
|
38
|
-
return { email: email.trim(), password };
|
|
39
|
+
return { email: email.trim().toLowerCase(), password };
|
|
39
40
|
}
|
|
40
41
|
function validateLoginInput(email, password) {
|
|
41
|
-
return email.length <= MAX_EMAIL_LENGTH && EMAIL_REGEX.test(email) && password.
|
|
42
|
+
return email.length <= MAX_EMAIL_LENGTH && EMAIL_REGEX.test(email) && password.length >= MIN_PASSWORD_LENGTH && password.length <= MAX_PASSWORD_LENGTH && new TextEncoder().encode(password).length <= MAX_PASSWORD_BYTES;
|
|
42
43
|
}
|
|
43
44
|
async function verifyPassword(plainPassword, hash, hashProvider) {
|
|
44
45
|
return hashProvider.verify(plainPassword, hash);
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
// src/auth/refresh.ts
|
|
48
|
+
// src/auth/utils/refresh-token.ts
|
|
48
49
|
function generateRefreshToken() {
|
|
49
50
|
const bytes = new Uint8Array(32);
|
|
50
51
|
crypto.getRandomValues(bytes);
|
|
51
52
|
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
// src/middleware.ts
|
|
55
|
+
// src/middleware/auth.middleware.ts
|
|
55
56
|
import { HTTPException } from "hono/http-exception";
|
|
56
57
|
var UNAUTHORIZED_JSON = JSON.stringify({ error: "Unauthorized" });
|
|
57
58
|
function unauthorizedResponse() {
|
|
@@ -80,9 +81,9 @@ function authMiddleware() {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
// src/features/content/index.ts
|
|
83
|
-
import { Hono
|
|
84
|
+
import { Hono } from "hono";
|
|
84
85
|
|
|
85
|
-
// src/shared/query-utils.ts
|
|
86
|
+
// src/shared/utils/query-utils.ts
|
|
86
87
|
var QUERY_FILTER_OPERATOR_SET = /* @__PURE__ */ new Set([
|
|
87
88
|
"eq",
|
|
88
89
|
"gt",
|
|
@@ -163,7 +164,7 @@ function toEngineFilters(groups) {
|
|
|
163
164
|
}));
|
|
164
165
|
}
|
|
165
166
|
|
|
166
|
-
// src/shared/apply-policies.ts
|
|
167
|
+
// src/shared/policies/apply-policies.ts
|
|
167
168
|
import { resolvePolicies, sha256hex } from "@beechcms/core";
|
|
168
169
|
var PrivacyPolicyError = class extends Error {
|
|
169
170
|
status = 501;
|
|
@@ -277,7 +278,8 @@ function publicProblem(c, input) {
|
|
|
277
278
|
body.errors = input.errors;
|
|
278
279
|
}
|
|
279
280
|
return c.json(body, input.status, {
|
|
280
|
-
"Content-Type": "application/problem+json"
|
|
281
|
+
"Content-Type": "application/problem+json",
|
|
282
|
+
...input.headers
|
|
281
283
|
});
|
|
282
284
|
}
|
|
283
285
|
|
|
@@ -296,6 +298,7 @@ var CONTENT_ERRORS = {
|
|
|
296
298
|
};
|
|
297
299
|
|
|
298
300
|
// src/features/content/handlers/list.ts
|
|
301
|
+
import { resolveKanbanConfig } from "@beechcms/core";
|
|
299
302
|
async function buildRelationsMap(context, seed, entries) {
|
|
300
303
|
const relationBranches = seed.branches.filter(
|
|
301
304
|
(b) => b.type === "relation"
|
|
@@ -333,9 +336,9 @@ async function buildRelationsMap(context, seed, entries) {
|
|
|
333
336
|
});
|
|
334
337
|
const map = {};
|
|
335
338
|
for (const item of items) {
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
const label =
|
|
339
|
+
const row = item;
|
|
340
|
+
const id = row.id;
|
|
341
|
+
const label = row[labelAlias];
|
|
339
342
|
map[id] = label != null && label !== "" ? String(label) : id;
|
|
340
343
|
}
|
|
341
344
|
relations[branch.alias] = map;
|
|
@@ -374,12 +377,44 @@ async function listHandler(context) {
|
|
|
374
377
|
const limit = Math.min(parsePositiveInt(query.limit, 25), 100);
|
|
375
378
|
const offset = (page - 1) * limit;
|
|
376
379
|
const orderBy = sortBy ? { column: sortBy, dir: sortDirRaw === "desc" ? "DESC" : "ASC" } : void 0;
|
|
380
|
+
const kanbanAxis = cleanStr(query.kanbanAxis);
|
|
381
|
+
let kanbanOrder;
|
|
382
|
+
if (kanbanAxis) {
|
|
383
|
+
const compat = resolveKanbanConfig(seed);
|
|
384
|
+
if (!compat.compatible || !compat.candidates.some((c) => c.branchId === kanbanAxis)) {
|
|
385
|
+
return publicProblem(context, {
|
|
386
|
+
type: "content-invalid-kanban-axis",
|
|
387
|
+
title: "Bad Request",
|
|
388
|
+
status: 400,
|
|
389
|
+
detail: "kanbanAxis is not a valid candidate for this seed"
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
kanbanOrder = { seedSlug: slug, axisBranchId: kanbanAxis };
|
|
393
|
+
}
|
|
394
|
+
let allFilters = engineFilters;
|
|
395
|
+
if (kanbanOrder) {
|
|
396
|
+
const rawFilters2 = cleanStr(query.filters);
|
|
397
|
+
if (rawFilters2) {
|
|
398
|
+
try {
|
|
399
|
+
const parsed = JSON.parse(rawFilters2);
|
|
400
|
+
if (Array.isArray(parsed)) {
|
|
401
|
+
allFilters = parsed.filter(
|
|
402
|
+
(g) => g !== null && typeof g === "object" && typeof g.column === "string" && Array.isArray(g.conditions)
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
} catch {
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
allFilters = [];
|
|
409
|
+
}
|
|
410
|
+
}
|
|
377
411
|
const repository = context.get("repository");
|
|
378
412
|
const { items, total } = await repository.findMany(seed, {
|
|
379
|
-
filters:
|
|
380
|
-
orderBy,
|
|
413
|
+
filters: allFilters,
|
|
414
|
+
orderBy: kanbanOrder ? void 0 : orderBy,
|
|
381
415
|
search: search || void 0,
|
|
382
|
-
pagination: { limit, offset }
|
|
416
|
+
pagination: { limit, offset },
|
|
417
|
+
kanbanOrder
|
|
383
418
|
});
|
|
384
419
|
const entries = await Promise.all(items.map(async (item) => {
|
|
385
420
|
let hasPendingDraft = false;
|
|
@@ -393,7 +428,7 @@ async function listHandler(context) {
|
|
|
393
428
|
// Repository returns "pure" data including system fields
|
|
394
429
|
};
|
|
395
430
|
}));
|
|
396
|
-
const hasQueryParams = Boolean(search) || Boolean(sortBy) || Boolean(query.filters) || query.page !== void 0 || query.limit !== void 0;
|
|
431
|
+
const hasQueryParams = Boolean(search) || Boolean(sortBy) || Boolean(query.filters) || Boolean(kanbanAxis) || query.page !== void 0 || query.limit !== void 0;
|
|
397
432
|
if (!hasQueryParams) {
|
|
398
433
|
return context.json(entries);
|
|
399
434
|
}
|
|
@@ -822,173 +857,47 @@ async function updateHandler(context) {
|
|
|
822
857
|
}
|
|
823
858
|
}
|
|
824
859
|
|
|
825
|
-
// src/upload.ts
|
|
826
|
-
import { Hono } from "hono";
|
|
827
|
-
import { isMimeAccepted } from "@beechcms/core";
|
|
828
|
-
var DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
829
|
-
var ABSOLUTE_MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
|
|
830
|
-
var PRESIGN_TTL_SECONDS = 900;
|
|
831
|
-
function resolveMaxUploadBytes(env) {
|
|
832
|
-
const raw = env.MAX_UPLOAD_BYTES;
|
|
833
|
-
if (!raw) return DEFAULT_MAX_UPLOAD_BYTES;
|
|
834
|
-
const parsed = Number.parseInt(raw, 10);
|
|
835
|
-
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_UPLOAD_BYTES;
|
|
836
|
-
return Math.min(parsed, ABSOLUTE_MAX_UPLOAD_BYTES);
|
|
837
|
-
}
|
|
838
|
-
function sanitizeFilename(name) {
|
|
839
|
-
const base = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 100);
|
|
840
|
-
return base || "file";
|
|
841
|
-
}
|
|
842
|
-
function generateObjectKey(originalName) {
|
|
843
|
-
const timestamp = Math.floor(Date.now() / 1e3);
|
|
844
|
-
return `${timestamp}-${sanitizeFilename(originalName)}`;
|
|
845
|
-
}
|
|
860
|
+
// src/shared/storage/upload.ts
|
|
846
861
|
async function deleteR2Objects(c, objectKeys) {
|
|
847
862
|
const { bucket, mediaRepository, systemStatsRepository } = c.var;
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
863
|
+
await Promise.all(
|
|
864
|
+
objectKeys.map(async (key) => {
|
|
865
|
+
const media = await mediaRepository.getByKey(key).catch(() => null);
|
|
866
|
+
if (!media) {
|
|
867
|
+
throw new Error(`Media object not found: ${key}`);
|
|
868
|
+
}
|
|
869
|
+
const size = media.size_bytes ?? 0;
|
|
852
870
|
await bucket.delete(key);
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
console.warn(`Failed to untrack media object: ${key}`, err);
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
var uploadRoutes = new Hono();
|
|
865
|
-
uploadRoutes.post("/upload/presign", async (c) => {
|
|
866
|
-
let body;
|
|
867
|
-
try {
|
|
868
|
-
body = await c.req.json();
|
|
869
|
-
} catch {
|
|
870
|
-
return c.json({ error: "Invalid JSON body" }, 400);
|
|
871
|
-
}
|
|
872
|
-
const { filename, mimeType, sizeBytes } = body;
|
|
873
|
-
if (typeof filename !== "string" || !filename.trim()) return c.json({ error: "filename is required" }, 400);
|
|
874
|
-
if (typeof mimeType !== "string" || !mimeType.trim()) return c.json({ error: "mimeType is required" }, 400);
|
|
875
|
-
if (typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || sizeBytes <= 0) {
|
|
876
|
-
return c.json({ error: "sizeBytes must be a positive number" }, 400);
|
|
877
|
-
}
|
|
878
|
-
const maxBytes = resolveMaxUploadBytes(c.env);
|
|
879
|
-
if (sizeBytes > maxBytes) return c.json({ error: `File too large. Max ${maxBytes} bytes` }, 400);
|
|
880
|
-
if (!isMimeAccepted(mimeType, "any")) return c.json({ error: "File type not allowed" }, 400);
|
|
881
|
-
const key = generateObjectKey(filename);
|
|
882
|
-
const uploadUrl = await c.var.bucket.presignPut(key, {
|
|
883
|
-
expiresIn: PRESIGN_TTL_SECONDS,
|
|
884
|
-
contentType: mimeType,
|
|
885
|
-
contentLength: sizeBytes
|
|
886
|
-
});
|
|
887
|
-
return c.json({ uploadUrl, key, expiresIn: PRESIGN_TTL_SECONDS }, 200);
|
|
888
|
-
});
|
|
889
|
-
uploadRoutes.post("/upload/confirm", async (c) => {
|
|
890
|
-
const { bucket, mediaRepository: mediaRepo, systemStatsRepository: statsRepo } = c.var;
|
|
891
|
-
let body;
|
|
892
|
-
try {
|
|
893
|
-
body = await c.req.json();
|
|
894
|
-
} catch {
|
|
895
|
-
return c.json({ error: "Invalid JSON body" }, 400);
|
|
896
|
-
}
|
|
897
|
-
const { key } = body;
|
|
898
|
-
if (typeof key !== "string" || !key.trim()) return c.json({ error: "key is required" }, 400);
|
|
899
|
-
const existing = await mediaRepo.getByKey(key);
|
|
900
|
-
if (existing) return c.json({ url: bucket.getUrl(key) }, 200);
|
|
901
|
-
const head = await bucket.head(key);
|
|
902
|
-
if (!head) return c.json({ error: "Object not found in storage" }, 404);
|
|
903
|
-
const uploadedBy = c.var.jwtPayload?.sub ?? "";
|
|
904
|
-
const size = head.size ?? 0;
|
|
905
|
-
const mime = head.contentType ?? "application/octet-stream";
|
|
906
|
-
const filename = key.replace(/^\d+-/, "") || key;
|
|
907
|
-
await statsRepo.incrementStorage(size);
|
|
908
|
-
await mediaRepo.trackUpload({
|
|
909
|
-
key,
|
|
910
|
-
filename,
|
|
911
|
-
mime_type: mime,
|
|
912
|
-
size_bytes: size,
|
|
913
|
-
uploaded_by: uploadedBy
|
|
914
|
-
});
|
|
915
|
-
const jwtPayload = c.get("jwtPayload");
|
|
916
|
-
if (jwtPayload) {
|
|
917
|
-
c.get("activityLogger").log({
|
|
918
|
-
action: "upload",
|
|
919
|
-
entityType: "media",
|
|
920
|
-
entityId: key,
|
|
921
|
-
details: { name: filename, size, type: mime },
|
|
922
|
-
actor: {
|
|
923
|
-
id: jwtPayload.sub,
|
|
924
|
-
email: jwtPayload.email ?? "unknown",
|
|
925
|
-
name: [jwtPayload.name, jwtPayload.surname].filter(Boolean).join(" ") || null
|
|
871
|
+
try {
|
|
872
|
+
await mediaRepository.untrack(key);
|
|
873
|
+
if (size > 0) {
|
|
874
|
+
await systemStatsRepository.decrementStorage(size);
|
|
875
|
+
}
|
|
876
|
+
} catch (err) {
|
|
877
|
+
console.warn(`Failed to untrack media object: ${key}`, err);
|
|
926
878
|
}
|
|
927
|
-
})
|
|
928
|
-
|
|
929
|
-
return c.json({ url: bucket.getUrl(key) }, 200);
|
|
930
|
-
});
|
|
931
|
-
uploadRoutes.get("/upload/download-url/:key", async (c) => {
|
|
932
|
-
const key = decodeURIComponent(c.req.param("key") ?? "");
|
|
933
|
-
if (!key) return c.json({ error: "Missing key" }, 400);
|
|
934
|
-
const head = await c.var.bucket.head(key);
|
|
935
|
-
if (!head) return c.json({ error: "Object not found" }, 404);
|
|
936
|
-
const downloadUrl = await c.var.bucket.presignGet(key, { expiresIn: PRESIGN_TTL_SECONDS });
|
|
937
|
-
return c.json({ downloadUrl, expiresIn: PRESIGN_TTL_SECONDS }, 200);
|
|
938
|
-
});
|
|
939
|
-
uploadRoutes.delete("/upload/:key", async (c) => {
|
|
940
|
-
const key = c.req.param("key");
|
|
941
|
-
if (!key) return c.json({ error: "Missing key" }, 400);
|
|
942
|
-
await deleteR2Objects(c, [decodeURIComponent(key)]);
|
|
943
|
-
return c.json({ success: true }, 200);
|
|
944
|
-
});
|
|
945
|
-
var FORCE_DOWNLOAD_MIME_PREFIXES = [
|
|
946
|
-
"image/svg",
|
|
947
|
-
// image/svg+xml, image/svg
|
|
948
|
-
"text/",
|
|
949
|
-
// text/html, text/xml, text/javascript, …
|
|
950
|
-
"application/xml",
|
|
951
|
-
"application/xhtml",
|
|
952
|
-
"application/javascript",
|
|
953
|
-
"application/x-javascript"
|
|
954
|
-
];
|
|
955
|
-
function isActiveMimeType(mime) {
|
|
956
|
-
const lower = mime.toLowerCase();
|
|
957
|
-
return FORCE_DOWNLOAD_MIME_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
|
879
|
+
})
|
|
880
|
+
);
|
|
958
881
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
} else {
|
|
971
|
-
headers.set("Content-Type", originalMime);
|
|
882
|
+
|
|
883
|
+
// src/shared/utils/media-utils.ts
|
|
884
|
+
var MEDIA_URL_PATTERN = /\/api\/media\/([^?#]+)/;
|
|
885
|
+
function extractMediaKey(mediaUrl, cdnUrl) {
|
|
886
|
+
const urlStr = String(mediaUrl);
|
|
887
|
+
if (cdnUrl) {
|
|
888
|
+
const normalizedCdn = cdnUrl.replace(/\/$/, "");
|
|
889
|
+
if (urlStr.startsWith(normalizedCdn)) {
|
|
890
|
+
const keyPart = urlStr.slice(normalizedCdn.length).replace(/^\//, "");
|
|
891
|
+
const keyWithoutQuery = keyPart.split(/[?#]/)[0];
|
|
892
|
+
return keyWithoutQuery ? decodeURIComponent(keyWithoutQuery) : null;
|
|
972
893
|
}
|
|
973
|
-
headers.set("Content-Security-Policy", "default-src 'none'; sandbox");
|
|
974
|
-
headers.set("X-Content-Type-Options", "nosniff");
|
|
975
|
-
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
976
|
-
return new Response(object.body, { status: 200, headers });
|
|
977
|
-
} catch (err) {
|
|
978
|
-
console.error(`[serveMediaHandler] Error serving file ${key}:`, err);
|
|
979
|
-
return new Response("Internal error", { status: 500 });
|
|
980
894
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
// src/media-utils.ts
|
|
984
|
-
var MEDIA_URL_PATTERN = /\/api\/media\/([^/?#]+)/;
|
|
985
|
-
function extractMediaKey(mediaUrl) {
|
|
986
|
-
const match = MEDIA_URL_PATTERN.exec(String(mediaUrl));
|
|
895
|
+
const match = MEDIA_URL_PATTERN.exec(urlStr);
|
|
987
896
|
return match ? decodeURIComponent(match[1]) : null;
|
|
988
897
|
}
|
|
989
|
-
function collectMediaKeysRecursive(value, collectedKeys) {
|
|
898
|
+
function collectMediaKeysRecursive(value, collectedKeys, cdnUrl) {
|
|
990
899
|
if (typeof value === "string") {
|
|
991
|
-
const r2Key = extractMediaKey(value);
|
|
900
|
+
const r2Key = extractMediaKey(value, cdnUrl);
|
|
992
901
|
if (r2Key) {
|
|
993
902
|
collectedKeys.add(r2Key);
|
|
994
903
|
return;
|
|
@@ -996,29 +905,29 @@ function collectMediaKeysRecursive(value, collectedKeys) {
|
|
|
996
905
|
try {
|
|
997
906
|
const parsed = JSON.parse(value);
|
|
998
907
|
if (parsed !== value) {
|
|
999
|
-
collectMediaKeysRecursive(parsed, collectedKeys);
|
|
908
|
+
collectMediaKeysRecursive(parsed, collectedKeys, cdnUrl);
|
|
1000
909
|
}
|
|
1001
910
|
} catch {
|
|
1002
911
|
}
|
|
1003
912
|
return;
|
|
1004
913
|
}
|
|
1005
914
|
if (Array.isArray(value)) {
|
|
1006
|
-
for (const item of value) collectMediaKeysRecursive(item, collectedKeys);
|
|
915
|
+
for (const item of value) collectMediaKeysRecursive(item, collectedKeys, cdnUrl);
|
|
1007
916
|
return;
|
|
1008
917
|
}
|
|
1009
918
|
if (value != null && typeof value === "object") {
|
|
1010
919
|
for (const nestedValue of Object.values(value)) {
|
|
1011
|
-
collectMediaKeysRecursive(nestedValue, collectedKeys);
|
|
920
|
+
collectMediaKeysRecursive(nestedValue, collectedKeys, cdnUrl);
|
|
1012
921
|
}
|
|
1013
922
|
}
|
|
1014
923
|
}
|
|
1015
|
-
function extractMediaKeysFromData(seed, entryData) {
|
|
924
|
+
function extractMediaKeysFromData(seed, entryData, cdnUrl) {
|
|
1016
925
|
const r2Keys = /* @__PURE__ */ new Set();
|
|
1017
926
|
for (const branch of seed.branches) {
|
|
1018
927
|
if (branch.type !== "file" && branch.type !== "json") continue;
|
|
1019
928
|
const fieldValue = entryData[branch.alias];
|
|
1020
929
|
if (fieldValue == null) continue;
|
|
1021
|
-
collectMediaKeysRecursive(fieldValue, r2Keys);
|
|
930
|
+
collectMediaKeysRecursive(fieldValue, r2Keys, cdnUrl);
|
|
1022
931
|
}
|
|
1023
932
|
return [...r2Keys];
|
|
1024
933
|
}
|
|
@@ -1052,7 +961,8 @@ async function deleteHandler(context) {
|
|
|
1052
961
|
const title = row.title || row.name || entryId;
|
|
1053
962
|
logContentActivity(context, "delete", entryId, schemaSlug, String(title));
|
|
1054
963
|
dispatchContentAutomation(context, schemaSlug, "delete", { ...row, id: entryId });
|
|
1055
|
-
const
|
|
964
|
+
const cdnUrl = context.env.MEDIA_CDN_URL;
|
|
965
|
+
const r2ObjectKeys = extractMediaKeysFromData(seed, row, cdnUrl);
|
|
1056
966
|
if (r2ObjectKeys.length > 0) {
|
|
1057
967
|
await deleteR2Objects(context, r2ObjectKeys).catch((error) => {
|
|
1058
968
|
if (context.env.ENV !== "production") {
|
|
@@ -1214,7 +1124,7 @@ async function bulkHandler(context) {
|
|
|
1214
1124
|
}
|
|
1215
1125
|
}
|
|
1216
1126
|
const repository = context.get("repository");
|
|
1217
|
-
const { updated, failed } = await repository.bulkUpdate(
|
|
1127
|
+
const { updated, failed } = await repository.bulkUpdate(seed, ids, resolvedFields);
|
|
1218
1128
|
const jwtPayload = context.get("jwtPayload");
|
|
1219
1129
|
context.get("activityLogger").log({
|
|
1220
1130
|
action: "bulk_update",
|
|
@@ -1239,8 +1149,160 @@ async function bulkHandler(context) {
|
|
|
1239
1149
|
return context.json({ updated, failed: failedProblems });
|
|
1240
1150
|
}
|
|
1241
1151
|
|
|
1152
|
+
// src/features/content/handlers/kanban-position.ts
|
|
1153
|
+
import { resolveKanbanConfig as resolveKanbanConfig2, EntryNotFoundError as EntryNotFoundError3 } from "@beechcms/core";
|
|
1154
|
+
async function kanbanPositionHandler(context) {
|
|
1155
|
+
const slug = context.req.param("slug");
|
|
1156
|
+
const id = context.req.param("id");
|
|
1157
|
+
if (!slug || !id) {
|
|
1158
|
+
return publicProblem(context, { type: "content-invalid-slug-or-id", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID });
|
|
1159
|
+
}
|
|
1160
|
+
const seed = context.get("getSeed")(slug);
|
|
1161
|
+
if (!seed) {
|
|
1162
|
+
return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
|
|
1163
|
+
}
|
|
1164
|
+
let body;
|
|
1165
|
+
try {
|
|
1166
|
+
body = await context.req.json();
|
|
1167
|
+
} catch {
|
|
1168
|
+
return publicProblem(context, { type: "content-invalid-body", title: "Bad Request", status: 400, detail: "Invalid JSON body" });
|
|
1169
|
+
}
|
|
1170
|
+
const { position, axisBranchId } = body;
|
|
1171
|
+
if (typeof position !== "string" || !position || typeof axisBranchId !== "string" || !axisBranchId) {
|
|
1172
|
+
return publicProblem(context, { type: "content-invalid-body", title: "Bad Request", status: 400, detail: "position and axisBranchId are required strings" });
|
|
1173
|
+
}
|
|
1174
|
+
const compat = resolveKanbanConfig2(seed);
|
|
1175
|
+
if (!compat.compatible || !compat.candidates.some((c) => c.branchId === axisBranchId)) {
|
|
1176
|
+
return publicProblem(context, { type: "content-invalid-kanban-axis", title: "Bad Request", status: 400, detail: "axisBranchId is not a valid kanban candidate for this seed" });
|
|
1177
|
+
}
|
|
1178
|
+
try {
|
|
1179
|
+
await context.get("repository").findById(seed, id);
|
|
1180
|
+
} catch (error) {
|
|
1181
|
+
if (error instanceof EntryNotFoundError3) {
|
|
1182
|
+
return publicProblem(context, { type: "content-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.NOT_FOUND });
|
|
1183
|
+
}
|
|
1184
|
+
throw error;
|
|
1185
|
+
}
|
|
1186
|
+
await context.get("kanbanPositionRepository").setPosition(slug, id, axisBranchId, position);
|
|
1187
|
+
return context.json({ success: true });
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// src/features/content/handlers/kanban-move.ts
|
|
1191
|
+
import {
|
|
1192
|
+
resolveKanbanConfig as resolveKanbanConfig3,
|
|
1193
|
+
validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload3
|
|
1194
|
+
} from "@beechcms/core";
|
|
1195
|
+
async function kanbanMoveHandler(context) {
|
|
1196
|
+
const slug = context.req.param("slug");
|
|
1197
|
+
const id = context.req.param("id");
|
|
1198
|
+
if (!slug || !id) {
|
|
1199
|
+
return publicProblem(context, { type: "content-invalid-slug-or-id", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID });
|
|
1200
|
+
}
|
|
1201
|
+
const seed = context.get("getSeed")(slug);
|
|
1202
|
+
if (!seed) {
|
|
1203
|
+
return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
|
|
1204
|
+
}
|
|
1205
|
+
let body;
|
|
1206
|
+
try {
|
|
1207
|
+
const raw = await context.req.json();
|
|
1208
|
+
if (typeof raw.position !== "string" || !raw.position || typeof raw.axisBranchId !== "string" || !raw.axisBranchId) {
|
|
1209
|
+
throw new Error("invalid");
|
|
1210
|
+
}
|
|
1211
|
+
body = raw;
|
|
1212
|
+
} catch {
|
|
1213
|
+
return publicProblem(context, { type: "content-invalid-body", title: "Bad Request", status: 400, detail: "position and axisBranchId are required strings" });
|
|
1214
|
+
}
|
|
1215
|
+
const compat = resolveKanbanConfig3(seed);
|
|
1216
|
+
const candidate = compat.candidates.find((c) => c.branchId === body.axisBranchId);
|
|
1217
|
+
if (!compat.compatible || !candidate) {
|
|
1218
|
+
return publicProblem(context, { type: "content-invalid-kanban-axis", title: "Bad Request", status: 400, detail: "axisBranchId is not a valid kanban candidate for this seed" });
|
|
1219
|
+
}
|
|
1220
|
+
const axisBranch = seed.branches.find((b) => b.id === body.axisBranchId);
|
|
1221
|
+
const repository = context.get("repository");
|
|
1222
|
+
let current;
|
|
1223
|
+
try {
|
|
1224
|
+
current = await repository.findById(seed, id);
|
|
1225
|
+
} catch {
|
|
1226
|
+
return publicProblem(context, { type: "content-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.NOT_FOUND });
|
|
1227
|
+
}
|
|
1228
|
+
let patch = null;
|
|
1229
|
+
if (body.axis) {
|
|
1230
|
+
if (body.axis.kind === "scalar") {
|
|
1231
|
+
const rawValue = body.axis.value;
|
|
1232
|
+
const coerced = axisBranch.type === "boolean" && rawValue !== null ? rawValue === "true" : rawValue;
|
|
1233
|
+
patch = { [axisBranch.alias]: coerced };
|
|
1234
|
+
} else {
|
|
1235
|
+
const currentTags = current[axisBranch.alias] ?? [];
|
|
1236
|
+
const { oldValue, newValue } = body.axis;
|
|
1237
|
+
const withoutOld = oldValue !== null ? currentTags.filter((t) => t !== oldValue) : currentTags;
|
|
1238
|
+
const nextTags = newValue !== null && !withoutOld.includes(newValue) ? [...withoutOld, newValue] : withoutOld;
|
|
1239
|
+
patch = { [axisBranch.alias]: nextTags };
|
|
1240
|
+
}
|
|
1241
|
+
const validation = validateAndSanitizeSeedPayload3(seed, patch, {
|
|
1242
|
+
operation: "update",
|
|
1243
|
+
allowNull: true,
|
|
1244
|
+
requireAtLeastOneValidField: true,
|
|
1245
|
+
enforceRequiredFields: false,
|
|
1246
|
+
idGenerator: context.get("idGenerator")
|
|
1247
|
+
});
|
|
1248
|
+
if (validation.details.length > 0) {
|
|
1249
|
+
return publicProblem(context, { type: "content-validation-error", title: "Unprocessable Entity", status: 422, detail: validation.details[0].message });
|
|
1250
|
+
}
|
|
1251
|
+
patch = validation.data;
|
|
1252
|
+
}
|
|
1253
|
+
const jwtPayload = context.get("jwtPayload");
|
|
1254
|
+
await repository.updateWithKanbanPosition(seed, id, patch, body.position, body.axisBranchId, { actor: jwtPayload.sub });
|
|
1255
|
+
return context.json({ success: true });
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// src/features/content/handlers/view-config.ts
|
|
1259
|
+
import { seedViewConfigSchema, validateCardConfigAgainstSeed } from "@beechcms/core";
|
|
1260
|
+
async function getViewConfigHandler(context) {
|
|
1261
|
+
const slug = context.req.param("slug");
|
|
1262
|
+
if (!slug) {
|
|
1263
|
+
return publicProblem(context, { type: "content-invalid-slug", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG });
|
|
1264
|
+
}
|
|
1265
|
+
const seed = context.get("getSeed")(slug);
|
|
1266
|
+
if (!seed) {
|
|
1267
|
+
return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
|
|
1268
|
+
}
|
|
1269
|
+
const config = await context.get("seedLayoutRepository").getViewConfig(slug);
|
|
1270
|
+
return context.json(config ?? {});
|
|
1271
|
+
}
|
|
1272
|
+
async function putViewConfigHandler(context) {
|
|
1273
|
+
const slug = context.req.param("slug");
|
|
1274
|
+
if (!slug) {
|
|
1275
|
+
return publicProblem(context, { type: "content-invalid-slug", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG });
|
|
1276
|
+
}
|
|
1277
|
+
const seed = context.get("getSeed")(slug);
|
|
1278
|
+
if (!seed) {
|
|
1279
|
+
return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
|
|
1280
|
+
}
|
|
1281
|
+
let body;
|
|
1282
|
+
try {
|
|
1283
|
+
body = await context.req.json();
|
|
1284
|
+
} catch {
|
|
1285
|
+
return publicProblem(context, { type: "content-invalid-body", title: "Bad Request", status: 400, detail: "Invalid JSON body" });
|
|
1286
|
+
}
|
|
1287
|
+
const parsed = seedViewConfigSchema.safeParse(body);
|
|
1288
|
+
if (!parsed.success) {
|
|
1289
|
+
return publicProblem(context, { type: "content-invalid-view-config", title: "Unprocessable Entity", status: 422, detail: parsed.error.issues[0]?.message ?? "Invalid view config" });
|
|
1290
|
+
}
|
|
1291
|
+
if (parsed.data.card) {
|
|
1292
|
+
const { cleaned } = validateCardConfigAgainstSeed(parsed.data.card, seed);
|
|
1293
|
+
parsed.data = { ...parsed.data, card: cleaned };
|
|
1294
|
+
}
|
|
1295
|
+
const jwtPayload = context.get("jwtPayload");
|
|
1296
|
+
await context.get("seedLayoutRepository").setViewConfig(slug, parsed.data, jwtPayload.sub);
|
|
1297
|
+
return context.json({ ok: true });
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1242
1300
|
// src/features/content/index.ts
|
|
1243
|
-
var content = new
|
|
1301
|
+
var content = new Hono();
|
|
1302
|
+
content.patch("/:slug/:id/kanban-move", kanbanMoveHandler);
|
|
1303
|
+
content.patch("/:slug/:id/kanban-position", kanbanPositionHandler);
|
|
1304
|
+
content.get("/:slug/view-config", getViewConfigHandler);
|
|
1305
|
+
content.put("/:slug/view-config", putViewConfigHandler);
|
|
1244
1306
|
content.get("/:slug", listHandler);
|
|
1245
1307
|
content.get("/:slug/facets", facetsHandler);
|
|
1246
1308
|
content.get("/:schema_slug/by-slug/:entry_slug", getBySlugHandler);
|
|
@@ -1251,10 +1313,10 @@ content.put("/:slug/:id", updateHandler);
|
|
|
1251
1313
|
content.delete("/:slug/:id", deleteHandler);
|
|
1252
1314
|
var content_default = content;
|
|
1253
1315
|
|
|
1254
|
-
// src/widget.ts
|
|
1255
|
-
import { Hono as
|
|
1316
|
+
// src/features/widget/widget.ts
|
|
1317
|
+
import { Hono as Hono2 } from "hono";
|
|
1256
1318
|
import { deserializeFromDb } from "@beechcms/core";
|
|
1257
|
-
var widgetApp = new
|
|
1319
|
+
var widgetApp = new Hono2();
|
|
1258
1320
|
var DEFAULT_LEADERBOARD_LIMIT = 10;
|
|
1259
1321
|
var MAXIMUM_LEADERBOARD_LIMIT = 100;
|
|
1260
1322
|
var DEFAULT_LIST_LIMIT = 25;
|
|
@@ -1445,8 +1507,8 @@ widgetApp.get("/distribution/:seed", async (context) => {
|
|
|
1445
1507
|
});
|
|
1446
1508
|
|
|
1447
1509
|
// src/features/rotate-field/rotate-field.handler.ts
|
|
1448
|
-
import { Hono as
|
|
1449
|
-
import { resolvePolicies as resolvePolicies4, verifyHashField, sha256hex as sha256hex2, validateAndSanitizeSeedPayload as
|
|
1510
|
+
import { Hono as Hono3 } from "hono";
|
|
1511
|
+
import { resolvePolicies as resolvePolicies4, verifyHashField, sha256hex as sha256hex2, validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload4, EntryNotFoundError as EntryNotFoundError4 } from "@beechcms/core";
|
|
1450
1512
|
|
|
1451
1513
|
// src/features/rotate-field/rotate-field.schema.ts
|
|
1452
1514
|
import { z } from "zod";
|
|
@@ -1457,7 +1519,7 @@ var rotateFieldRequestSchema = z.object({
|
|
|
1457
1519
|
});
|
|
1458
1520
|
|
|
1459
1521
|
// src/features/rotate-field/rotate-field.handler.ts
|
|
1460
|
-
var rotateFieldApp = new
|
|
1522
|
+
var rotateFieldApp = new Hono3();
|
|
1461
1523
|
rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
|
|
1462
1524
|
const seedSlug = context.req.param("slug");
|
|
1463
1525
|
const entryId = context.req.param("id");
|
|
@@ -1513,7 +1575,7 @@ rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
|
|
|
1513
1575
|
try {
|
|
1514
1576
|
contentRecord = await context.get("repository").findById(seed, entryId);
|
|
1515
1577
|
} catch (error) {
|
|
1516
|
-
if (error instanceof
|
|
1578
|
+
if (error instanceof EntryNotFoundError4) {
|
|
1517
1579
|
return publicProblem(context, {
|
|
1518
1580
|
type: "content-not-found",
|
|
1519
1581
|
title: "Not Found",
|
|
@@ -1541,7 +1603,7 @@ rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
|
|
|
1541
1603
|
detail: "Current value does not match stored value"
|
|
1542
1604
|
});
|
|
1543
1605
|
}
|
|
1544
|
-
const fieldValidationResult =
|
|
1606
|
+
const fieldValidationResult = validateAndSanitizeSeedPayload4(
|
|
1545
1607
|
seed,
|
|
1546
1608
|
{ [fieldAlias]: nextValue },
|
|
1547
1609
|
{ operation: "update", allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: false }
|
|
@@ -1560,9 +1622,9 @@ rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
|
|
|
1560
1622
|
});
|
|
1561
1623
|
|
|
1562
1624
|
// src/features/password-reset/index.ts
|
|
1563
|
-
import { Hono as
|
|
1625
|
+
import { Hono as Hono4 } from "hono";
|
|
1564
1626
|
|
|
1565
|
-
// src/
|
|
1627
|
+
// src/shared/email/providers/resend.ts
|
|
1566
1628
|
var RESEND_API_URL = "https://api.resend.com/emails";
|
|
1567
1629
|
var ResendEmailProvider = class {
|
|
1568
1630
|
apiKey;
|
|
@@ -1598,7 +1660,7 @@ var ResendEmailProvider = class {
|
|
|
1598
1660
|
}
|
|
1599
1661
|
};
|
|
1600
1662
|
|
|
1601
|
-
// src/
|
|
1663
|
+
// src/shared/email/providers/smtp.ts
|
|
1602
1664
|
function parseAddress(raw) {
|
|
1603
1665
|
const match = raw.match(/^\s*(.+?)\s*<([^>]+)>\s*$/);
|
|
1604
1666
|
if (match) return { Name: match[1], Email: match[2] };
|
|
@@ -1628,7 +1690,7 @@ var SmtpEmailProvider = class {
|
|
|
1628
1690
|
}
|
|
1629
1691
|
};
|
|
1630
1692
|
|
|
1631
|
-
// src/
|
|
1693
|
+
// src/shared/email/templates/shell.ts
|
|
1632
1694
|
function buildEmailShell(locale, slots) {
|
|
1633
1695
|
const ctaBlock = slots.cta ? `<a href="${slots.cta.href}"
|
|
1634
1696
|
style="display:inline-block;background:#111;color:#fff;padding:12px 24px;
|
|
@@ -1656,7 +1718,7 @@ function buildEmailShell(locale, slots) {
|
|
|
1656
1718
|
</html>`;
|
|
1657
1719
|
}
|
|
1658
1720
|
|
|
1659
|
-
// src/
|
|
1721
|
+
// src/shared/email/templates/password-reset.ts
|
|
1660
1722
|
var COPY = {
|
|
1661
1723
|
en: {
|
|
1662
1724
|
subject: "Reset your Beech CMS password",
|
|
@@ -1686,7 +1748,7 @@ function buildPasswordResetEmail(resetUrl, locale) {
|
|
|
1686
1748
|
};
|
|
1687
1749
|
}
|
|
1688
1750
|
|
|
1689
|
-
// src/
|
|
1751
|
+
// src/shared/email/templates/password-changed.ts
|
|
1690
1752
|
var COPY2 = {
|
|
1691
1753
|
en: {
|
|
1692
1754
|
subject: "Your Beech CMS password has been changed",
|
|
@@ -1716,7 +1778,7 @@ function buildPasswordChangedEmail(locale) {
|
|
|
1716
1778
|
};
|
|
1717
1779
|
}
|
|
1718
1780
|
|
|
1719
|
-
// src/
|
|
1781
|
+
// src/shared/email/templates/automation-mail.ts
|
|
1720
1782
|
function buildAutomationEmail(params) {
|
|
1721
1783
|
return {
|
|
1722
1784
|
to: params.to,
|
|
@@ -1729,7 +1791,7 @@ function stripHtml(input) {
|
|
|
1729
1791
|
return input.replace(/<[^>]+>/g, "").trim();
|
|
1730
1792
|
}
|
|
1731
1793
|
|
|
1732
|
-
// src/
|
|
1794
|
+
// src/shared/email/email.service.ts
|
|
1733
1795
|
var DEFAULT_FROM = "Beech CMS <onboarding@resend.dev>";
|
|
1734
1796
|
function createProvider(env) {
|
|
1735
1797
|
if (env.provider === "smtp") {
|
|
@@ -1771,7 +1833,7 @@ async function sendPasswordChangedEmail(params) {
|
|
|
1771
1833
|
async function sendAutomationMail(params) {
|
|
1772
1834
|
const provider = createProvider({
|
|
1773
1835
|
provider: params.provider,
|
|
1774
|
-
apiKey: params.apiKey ??
|
|
1836
|
+
apiKey: params.apiKey ?? "",
|
|
1775
1837
|
smtpBaseUrl: params.smtpBaseUrl,
|
|
1776
1838
|
isDev: false
|
|
1777
1839
|
});
|
|
@@ -1784,7 +1846,7 @@ async function sendAutomationMail(params) {
|
|
|
1784
1846
|
});
|
|
1785
1847
|
}
|
|
1786
1848
|
|
|
1787
|
-
// src/
|
|
1849
|
+
// src/shared/email/email.types.ts
|
|
1788
1850
|
var SUPPORTED_EMAIL_LOCALES = ["en", "it"];
|
|
1789
1851
|
function resolveEmailLocale(raw) {
|
|
1790
1852
|
if (typeof raw === "string" && SUPPORTED_EMAIL_LOCALES.includes(raw)) {
|
|
@@ -1817,7 +1879,11 @@ async function requestPasswordReset(context) {
|
|
|
1817
1879
|
const clientIpAddress = getClientIp(req);
|
|
1818
1880
|
const forgotPasswordRateLimit = await context.get("rateLimiters").getLimiter("forgotPassword").checkLimit(clientIpAddress);
|
|
1819
1881
|
if (!forgotPasswordRateLimit.isAllowed) {
|
|
1820
|
-
|
|
1882
|
+
const headers = {};
|
|
1883
|
+
if (forgotPasswordRateLimit.retryAfterSeconds !== void 0) {
|
|
1884
|
+
headers["Retry-After"] = String(forgotPasswordRateLimit.retryAfterSeconds);
|
|
1885
|
+
}
|
|
1886
|
+
return context.json({ error: "Too many requests" }, 429, headers);
|
|
1821
1887
|
}
|
|
1822
1888
|
const registeredUser = await context.get("userRepository").findByEmail(normalizedEmail);
|
|
1823
1889
|
if (!registeredUser) {
|
|
@@ -1859,6 +1925,7 @@ async function requestPasswordReset(context) {
|
|
|
1859
1925
|
import { sha256hex as sha256hex4 } from "@beechcms/core";
|
|
1860
1926
|
var MIN_PASSWORD_LENGTH2 = 8;
|
|
1861
1927
|
var MAX_PASSWORD_LENGTH2 = 128;
|
|
1928
|
+
var MAX_PASSWORD_BYTES2 = 72;
|
|
1862
1929
|
async function resetPassword(context) {
|
|
1863
1930
|
const { env, req, executionCtx } = context;
|
|
1864
1931
|
const useSmtp = env.EMAIL_PROVIDER === "smtp";
|
|
@@ -1868,7 +1935,11 @@ async function resetPassword(context) {
|
|
|
1868
1935
|
const clientIpAddress = getClientIp(req);
|
|
1869
1936
|
const resetPasswordRateLimit = await context.get("rateLimiters").getLimiter("resetPassword").checkLimit(clientIpAddress);
|
|
1870
1937
|
if (!resetPasswordRateLimit.isAllowed) {
|
|
1871
|
-
|
|
1938
|
+
const headers = {};
|
|
1939
|
+
if (resetPasswordRateLimit.retryAfterSeconds !== void 0) {
|
|
1940
|
+
headers["Retry-After"] = String(resetPasswordRateLimit.retryAfterSeconds);
|
|
1941
|
+
}
|
|
1942
|
+
return context.json({ error: "Too many requests" }, 429, headers);
|
|
1872
1943
|
}
|
|
1873
1944
|
let payload;
|
|
1874
1945
|
try {
|
|
@@ -1887,6 +1958,11 @@ async function resetPassword(context) {
|
|
|
1887
1958
|
error: `Password must be between ${MIN_PASSWORD_LENGTH2} and ${MAX_PASSWORD_LENGTH2} characters`
|
|
1888
1959
|
}, 400);
|
|
1889
1960
|
}
|
|
1961
|
+
if (new TextEncoder().encode(newPassword).length > MAX_PASSWORD_BYTES2) {
|
|
1962
|
+
return context.json({
|
|
1963
|
+
error: `Password must not exceed ${MAX_PASSWORD_BYTES2} bytes when UTF-8 encoded`
|
|
1964
|
+
}, 400);
|
|
1965
|
+
}
|
|
1890
1966
|
const emailLocale = resolveEmailLocale(payload.locale);
|
|
1891
1967
|
const tokenHash = await sha256hex4(resetToken);
|
|
1892
1968
|
const nowTimestamp = Math.floor(Date.now() / 1e3);
|
|
@@ -1925,7 +2001,7 @@ async function resetPassword(context) {
|
|
|
1925
2001
|
}
|
|
1926
2002
|
|
|
1927
2003
|
// src/features/password-reset/index.ts
|
|
1928
|
-
var passwordResetApp = new
|
|
2004
|
+
var passwordResetApp = new Hono4();
|
|
1929
2005
|
passwordResetApp.get("/auth/features", (context) => {
|
|
1930
2006
|
return context.json({ passwordReset: context.env.EMAIL_PROVIDER === "smtp" || Boolean(context.env.RESEND_API_KEY) });
|
|
1931
2007
|
});
|
|
@@ -1933,8 +2009,8 @@ passwordResetApp.post("/auth/forgot-password", requestPasswordReset);
|
|
|
1933
2009
|
passwordResetApp.post("/auth/reset-password", resetPassword);
|
|
1934
2010
|
|
|
1935
2011
|
// src/features/setup/index.ts
|
|
1936
|
-
import { Hono as
|
|
1937
|
-
var setupApp = new
|
|
2012
|
+
import { Hono as Hono5 } from "hono";
|
|
2013
|
+
var setupApp = new Hono5();
|
|
1938
2014
|
setupApp.get("/auth/setup", async (context) => {
|
|
1939
2015
|
const userCount = await context.get("userRepository").countAll();
|
|
1940
2016
|
const needsSetup = userCount === 0;
|
|
@@ -1996,6 +2072,14 @@ setupApp.post("/auth/setup", async (context) => {
|
|
|
1996
2072
|
detail: "Password must be between 8 and 128 characters long."
|
|
1997
2073
|
});
|
|
1998
2074
|
}
|
|
2075
|
+
if (new TextEncoder().encode(password).length > 72) {
|
|
2076
|
+
return publicProblem(context, {
|
|
2077
|
+
type: "validation-error",
|
|
2078
|
+
title: "Invalid password",
|
|
2079
|
+
status: 422,
|
|
2080
|
+
detail: "Password must not exceed 72 bytes when UTF-8 encoded."
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
1999
2083
|
if (!settings || typeof settings !== "object") {
|
|
2000
2084
|
return publicProblem(context, {
|
|
2001
2085
|
type: "validation-error",
|
|
@@ -2185,16 +2269,16 @@ setupApp.post("/auth/setup", async (context) => {
|
|
|
2185
2269
|
});
|
|
2186
2270
|
|
|
2187
2271
|
// src/features/draft/draft.handler.ts
|
|
2188
|
-
import { Hono as
|
|
2272
|
+
import { Hono as Hono6 } from "hono";
|
|
2189
2273
|
import {
|
|
2190
|
-
validateAndSanitizeSeedPayload as
|
|
2274
|
+
validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload5,
|
|
2191
2275
|
resolvePolicies as resolvePolicies5,
|
|
2192
2276
|
RelationTargetNotFoundError
|
|
2193
2277
|
} from "@beechcms/core";
|
|
2194
2278
|
|
|
2195
2279
|
// src/features/draft/draft.middleware.ts
|
|
2196
2280
|
import { createMiddleware } from "hono/factory";
|
|
2197
|
-
import { EntryNotFoundError as
|
|
2281
|
+
import { EntryNotFoundError as EntryNotFoundError5 } from "@beechcms/core";
|
|
2198
2282
|
var draftGuard = createMiddleware(async (context, next) => {
|
|
2199
2283
|
const slug = context.req.param("slug");
|
|
2200
2284
|
const id = context.req.param("id");
|
|
@@ -2227,7 +2311,7 @@ var draftGuard = createMiddleware(async (context, next) => {
|
|
|
2227
2311
|
try {
|
|
2228
2312
|
await repository.findById(seed, id);
|
|
2229
2313
|
} catch (error) {
|
|
2230
|
-
if (error instanceof
|
|
2314
|
+
if (error instanceof EntryNotFoundError5) {
|
|
2231
2315
|
return publicProblem(context, {
|
|
2232
2316
|
type: "content-not-found",
|
|
2233
2317
|
title: "Not Found",
|
|
@@ -2241,7 +2325,7 @@ var draftGuard = createMiddleware(async (context, next) => {
|
|
|
2241
2325
|
});
|
|
2242
2326
|
|
|
2243
2327
|
// src/features/draft/draft.handler.ts
|
|
2244
|
-
var draftApp = new
|
|
2328
|
+
var draftApp = new Hono6();
|
|
2245
2329
|
draftApp.get("/drafts", async (context) => {
|
|
2246
2330
|
const seeds = context.get("seedRegistry").draftEnabled();
|
|
2247
2331
|
const repository = context.get("repository");
|
|
@@ -2293,7 +2377,7 @@ draftApp.put("/:slug/:id/draft", draftGuard, async (context) => {
|
|
|
2293
2377
|
detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(", ")}`
|
|
2294
2378
|
});
|
|
2295
2379
|
}
|
|
2296
|
-
const validation =
|
|
2380
|
+
const validation = validateAndSanitizeSeedPayload5(seed, body, {
|
|
2297
2381
|
operation: "update",
|
|
2298
2382
|
allowNull: true,
|
|
2299
2383
|
requireAtLeastOneValidField: true,
|
|
@@ -2381,12 +2465,13 @@ draftApp.delete("/:slug/:id/draft", draftGuard, async (context) => {
|
|
|
2381
2465
|
});
|
|
2382
2466
|
|
|
2383
2467
|
// src/features/settings/settings.handler.ts
|
|
2384
|
-
import { Hono as
|
|
2468
|
+
import { Hono as Hono7 } from "hono";
|
|
2385
2469
|
import { sha256hex as sha256hex5 } from "@beechcms/core";
|
|
2386
|
-
var settingsApp = new
|
|
2470
|
+
var settingsApp = new Hono7();
|
|
2387
2471
|
var EMAIL_VALIDATION_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
2388
2472
|
var MIN_PASSWORD_LENGTH3 = 8;
|
|
2389
2473
|
var MAX_PASSWORD_LENGTH3 = 128;
|
|
2474
|
+
var MAX_PASSWORD_BYTES3 = 72;
|
|
2390
2475
|
var SESSION_LIST_LIMIT = 20;
|
|
2391
2476
|
var ACTIVITY_LOG_LIMIT = 30;
|
|
2392
2477
|
settingsApp.get("/", async (context) => {
|
|
@@ -2549,6 +2634,9 @@ settingsApp.put("/password", async (context) => {
|
|
|
2549
2634
|
if (newPassword.length > MAX_PASSWORD_LENGTH3) {
|
|
2550
2635
|
return context.json({ error: "Password is too long" }, 400);
|
|
2551
2636
|
}
|
|
2637
|
+
if (new TextEncoder().encode(newPassword).length > MAX_PASSWORD_BYTES3) {
|
|
2638
|
+
return context.json({ error: `Password must not exceed ${MAX_PASSWORD_BYTES3} bytes when UTF-8 encoded` }, 400);
|
|
2639
|
+
}
|
|
2552
2640
|
const userRecord = await context.get("userRepository").findById(userId);
|
|
2553
2641
|
if (!userRecord) {
|
|
2554
2642
|
return context.json({ error: "User not found" }, 404);
|
|
@@ -2659,7 +2747,7 @@ settingsApp.put("/notifications", async (context) => {
|
|
|
2659
2747
|
});
|
|
2660
2748
|
|
|
2661
2749
|
// src/features/schema/schema.handler.ts
|
|
2662
|
-
import { Hono as
|
|
2750
|
+
import { Hono as Hono8 } from "hono";
|
|
2663
2751
|
import { canEditLayout, formLayoutSchema, validateLayoutAgainstSeed } from "@beechcms/core";
|
|
2664
2752
|
function requireLayoutEditPermission(context) {
|
|
2665
2753
|
const role = context.get("jwtPayload")?.role;
|
|
@@ -2688,7 +2776,7 @@ function requireSeedBySlug(context, slug) {
|
|
|
2688
2776
|
}
|
|
2689
2777
|
return { seed, error: null };
|
|
2690
2778
|
}
|
|
2691
|
-
var schemaApp = new
|
|
2779
|
+
var schemaApp = new Hono8();
|
|
2692
2780
|
schemaApp.get("/", async (context) => {
|
|
2693
2781
|
const registry = context.get("seedRegistry");
|
|
2694
2782
|
const layouts = await context.get("seedLayoutRepository").getAllAsMap();
|
|
@@ -2750,7 +2838,7 @@ schemaApp.delete("/:slug/layout", async (context) => {
|
|
|
2750
2838
|
});
|
|
2751
2839
|
|
|
2752
2840
|
// src/features/dashboard-layout/dashboard-layout.handler.ts
|
|
2753
|
-
import { Hono as
|
|
2841
|
+
import { Hono as Hono9 } from "hono";
|
|
2754
2842
|
import {
|
|
2755
2843
|
canEditDashboard,
|
|
2756
2844
|
dashboardLayoutSchema,
|
|
@@ -2810,7 +2898,7 @@ async function putLayout(context, scope) {
|
|
|
2810
2898
|
await context.get("dashboardLayoutRepository").upsert(scope, result.cleaned, userId);
|
|
2811
2899
|
return context.json({ ok: true, layout: result.cleaned, warnings: result.warnings });
|
|
2812
2900
|
}
|
|
2813
|
-
var dashboardLayoutApp = new
|
|
2901
|
+
var dashboardLayoutApp = new Hono9();
|
|
2814
2902
|
dashboardLayoutApp.get("/", async (context) => {
|
|
2815
2903
|
const role = context.get("jwtPayload")?.role;
|
|
2816
2904
|
const chain = resolveDashboardScopeChain(role);
|
|
@@ -2885,7 +2973,7 @@ dashboardLayoutApp.delete("/:scope", async (context) => {
|
|
|
2885
2973
|
});
|
|
2886
2974
|
|
|
2887
2975
|
// src/features/seeds/seeds.handler.ts
|
|
2888
|
-
import { Hono as
|
|
2976
|
+
import { Hono as Hono10 } from "hono";
|
|
2889
2977
|
import {
|
|
2890
2978
|
nextBranchId,
|
|
2891
2979
|
validateSeedDefinitions,
|
|
@@ -3028,16 +3116,17 @@ async function deleteSeedMediaObjects(context, slug, seed, schemaMutator) {
|
|
|
3028
3116
|
try {
|
|
3029
3117
|
const dbCols = await schemaMutator.getColumns(`content_${slug}`);
|
|
3030
3118
|
if (!dbCols) return;
|
|
3031
|
-
const
|
|
3032
|
-
if (
|
|
3033
|
-
const
|
|
3034
|
-
const rows =
|
|
3119
|
+
const validCols = fileBranches.map((b) => b.alias).filter((a) => dbCols.has(a));
|
|
3120
|
+
if (validCols.length === 0) return;
|
|
3121
|
+
const sql = `SELECT ${validCols.join(", ")} FROM content_${slug}`;
|
|
3122
|
+
const { results: rows } = await context.env.DB.prepare(sql).all();
|
|
3123
|
+
const cdnUrl = context.env.MEDIA_CDN_URL;
|
|
3035
3124
|
const r2Keys = [];
|
|
3036
3125
|
for (const row of rows) {
|
|
3037
3126
|
for (const b of fileBranches) {
|
|
3038
3127
|
const val = row[b.alias];
|
|
3039
3128
|
if (!val) continue;
|
|
3040
|
-
const keys = extractMediaKeysFromData(seed, { [b.alias]: val });
|
|
3129
|
+
const keys = extractMediaKeysFromData(seed, { [b.alias]: val }, cdnUrl);
|
|
3041
3130
|
r2Keys.push(...keys);
|
|
3042
3131
|
}
|
|
3043
3132
|
}
|
|
@@ -3048,7 +3137,7 @@ async function deleteSeedMediaObjects(context, slug, seed, schemaMutator) {
|
|
|
3048
3137
|
} catch {
|
|
3049
3138
|
}
|
|
3050
3139
|
}
|
|
3051
|
-
var seedsApp = new
|
|
3140
|
+
var seedsApp = new Hono10();
|
|
3052
3141
|
seedsApp.use("*", async (context, next) => {
|
|
3053
3142
|
const denied = requireAdmin(context);
|
|
3054
3143
|
if (denied) return denied;
|
|
@@ -3323,8 +3412,8 @@ seedsApp.patch("/:slug/branches/:branchId/retype", async (context) => {
|
|
|
3323
3412
|
});
|
|
3324
3413
|
|
|
3325
3414
|
// src/features/notifications/notifications.handler.ts
|
|
3326
|
-
import { Hono as
|
|
3327
|
-
var notificationsApp = new
|
|
3415
|
+
import { Hono as Hono11 } from "hono";
|
|
3416
|
+
var notificationsApp = new Hono11();
|
|
3328
3417
|
var NOTIFICATION_LIST_LIMIT = 50;
|
|
3329
3418
|
notificationsApp.get("/notifications", async (context) => {
|
|
3330
3419
|
try {
|
|
@@ -3384,7 +3473,7 @@ notificationsApp.post("/notifications/mark-all-read", async (context) => {
|
|
|
3384
3473
|
}
|
|
3385
3474
|
});
|
|
3386
3475
|
|
|
3387
|
-
// src/features/automations/template-grammar.ts
|
|
3476
|
+
// src/features/automations/evaluator/template-grammar.ts
|
|
3388
3477
|
import { AUTOMATION_RESERVED_WORDS } from "@beechcms/core";
|
|
3389
3478
|
var SLUG_RE2 = /^[a-zA-Z0-9_-]+$/;
|
|
3390
3479
|
var AGGREGATE_OPS = /* @__PURE__ */ new Set(["count", "sum", "avg", "min", "max", "pluck"]);
|
|
@@ -3517,7 +3606,7 @@ function parseTemplateKey(raw) {
|
|
|
3517
3606
|
return { kind: "var_access", name: nameToken, steps, conditions };
|
|
3518
3607
|
}
|
|
3519
3608
|
|
|
3520
|
-
// src/features/automations/automation-runner.utils.ts
|
|
3609
|
+
// src/features/automations/engine/automation-runner.utils.ts
|
|
3521
3610
|
function interpolate(template, context, defaultValue = "", onMissing) {
|
|
3522
3611
|
if (!template) return "";
|
|
3523
3612
|
const replacer = (_, key) => {
|
|
@@ -3551,7 +3640,7 @@ function resolvePath(obj, path) {
|
|
|
3551
3640
|
return current;
|
|
3552
3641
|
}
|
|
3553
3642
|
|
|
3554
|
-
// src/features/automations/context-resolver.ts
|
|
3643
|
+
// src/features/automations/evaluator/context-resolver.ts
|
|
3555
3644
|
var MAX_PLUCK = 100;
|
|
3556
3645
|
function applyAggregate(op, field, rows, onMissing) {
|
|
3557
3646
|
switch (op) {
|
|
@@ -3666,7 +3755,7 @@ async function resolveAutomationContext(_automation, triggerEntry, batchEntries)
|
|
|
3666
3755
|
return { lookup, triggerEntry };
|
|
3667
3756
|
}
|
|
3668
3757
|
|
|
3669
|
-
// src/features/automations/
|
|
3758
|
+
// src/features/automations/executors/webhook.executor.ts
|
|
3670
3759
|
import { signWebhookBody } from "@beechcms/core/webhook-crypto";
|
|
3671
3760
|
var WEBHOOK_TIMEOUT_MS = 8e3;
|
|
3672
3761
|
var warnedNoSecret = false;
|
|
@@ -3702,7 +3791,7 @@ async function executeWebhook(action, context, env) {
|
|
|
3702
3791
|
}
|
|
3703
3792
|
}
|
|
3704
3793
|
|
|
3705
|
-
// src/features/automations/
|
|
3794
|
+
// src/features/automations/executors/send-mail.executor.ts
|
|
3706
3795
|
async function executeSendMail(action, context, env) {
|
|
3707
3796
|
const missingFields = [];
|
|
3708
3797
|
const logMissing = (field) => missingFields.push(field);
|
|
@@ -3735,7 +3824,7 @@ async function executeSendMail(action, context, env) {
|
|
|
3735
3824
|
}
|
|
3736
3825
|
}
|
|
3737
3826
|
|
|
3738
|
-
// src/features/automations/
|
|
3827
|
+
// src/features/automations/executors/edit-field.executor.ts
|
|
3739
3828
|
async function executeEditField(action, entry, context, repository, seed) {
|
|
3740
3829
|
const id = entry.id;
|
|
3741
3830
|
if (typeof id !== "string") {
|
|
@@ -3745,7 +3834,7 @@ async function executeEditField(action, entry, context, repository, seed) {
|
|
|
3745
3834
|
await repository.update(seed, id, { [action.field]: resolved });
|
|
3746
3835
|
}
|
|
3747
3836
|
|
|
3748
|
-
// src/features/automations/
|
|
3837
|
+
// src/features/automations/executors/create-entry.executor.ts
|
|
3749
3838
|
async function executeCreateEntry(action, entry, repository, getSeed, idGenerator) {
|
|
3750
3839
|
const targetSeed = getSeed(action.seed_slug);
|
|
3751
3840
|
if (!targetSeed) throw new Error(`create_entry: unknown seed ${action.seed_slug}`);
|
|
@@ -3757,7 +3846,7 @@ async function executeCreateEntry(action, entry, repository, getSeed, idGenerato
|
|
|
3757
3846
|
await repository.create(targetSeed, newId, newId, "draft", payload);
|
|
3758
3847
|
}
|
|
3759
3848
|
|
|
3760
|
-
// src/features/automations/filter-translation.ts
|
|
3849
|
+
// src/features/automations/filters/filter-translation.ts
|
|
3761
3850
|
var SYSTEM_COLUMNS = /* @__PURE__ */ new Set(["id", "slug", "status", "created_at", "updated_at"]);
|
|
3762
3851
|
function mapBranchTypeToFilterType(type) {
|
|
3763
3852
|
switch (type) {
|
|
@@ -3795,7 +3884,7 @@ function conditionToFilterGroup(c, seed) {
|
|
|
3795
3884
|
};
|
|
3796
3885
|
}
|
|
3797
3886
|
|
|
3798
|
-
// src/features/automations/
|
|
3887
|
+
// src/features/automations/executors/set-variable.executor.ts
|
|
3799
3888
|
async function executeSetVariable(action, ctx) {
|
|
3800
3889
|
const seedSlug = action.seed_slug ?? ctx.seed.slug;
|
|
3801
3890
|
const targetSeed = ctx.getSeed(seedSlug);
|
|
@@ -3908,7 +3997,7 @@ function calculateGeneralStats(seed, items) {
|
|
|
3908
3997
|
return { count: items.length, sum, avg, min, max, pluck };
|
|
3909
3998
|
}
|
|
3910
3999
|
|
|
3911
|
-
// src/features/automations/
|
|
4000
|
+
// src/features/automations/executors/index.ts
|
|
3912
4001
|
async function executeAction(action, ctx) {
|
|
3913
4002
|
switch (action.type) {
|
|
3914
4003
|
case "set_variable":
|
|
@@ -3928,7 +4017,7 @@ async function executeAction(action, ctx) {
|
|
|
3928
4017
|
}
|
|
3929
4018
|
}
|
|
3930
4019
|
|
|
3931
|
-
// src/features/automations/when-evaluator.ts
|
|
4020
|
+
// src/features/automations/filters/when-evaluator.ts
|
|
3932
4021
|
function evaluateWhen(node, context) {
|
|
3933
4022
|
if (!node) return true;
|
|
3934
4023
|
if (node.kind === "predicate") return evalPredicate(node, context);
|
|
@@ -4008,7 +4097,7 @@ function coerceEqual(a, b) {
|
|
|
4008
4097
|
return a === b;
|
|
4009
4098
|
}
|
|
4010
4099
|
|
|
4011
|
-
// src/features/automations/var-access-resolver.ts
|
|
4100
|
+
// src/features/automations/evaluator/var-access-resolver.ts
|
|
4012
4101
|
function toNumeric(v) {
|
|
4013
4102
|
const n = Number(v);
|
|
4014
4103
|
if (Number.isFinite(n)) return n;
|
|
@@ -4111,7 +4200,7 @@ function resolveVarAccess(parsed, variables, onMissing) {
|
|
|
4111
4200
|
return current;
|
|
4112
4201
|
}
|
|
4113
4202
|
|
|
4114
|
-
// src/features/automations/automation-runner.ts
|
|
4203
|
+
// src/features/automations/engine/automation-runner.ts
|
|
4115
4204
|
function withVariables(base, variables) {
|
|
4116
4205
|
return {
|
|
4117
4206
|
triggerEntry: base.triggerEntry,
|
|
@@ -4164,10 +4253,10 @@ var AutomationRunner = class {
|
|
|
4164
4253
|
}
|
|
4165
4254
|
};
|
|
4166
4255
|
|
|
4167
|
-
// src/features/automations/automations.handler.ts
|
|
4168
|
-
import { Hono as
|
|
4256
|
+
// src/features/automations/api/automations.handler.ts
|
|
4257
|
+
import { Hono as Hono12 } from "hono";
|
|
4169
4258
|
|
|
4170
|
-
// src/features/automations/automations.schema.ts
|
|
4259
|
+
// src/features/automations/api/automations.schema.ts
|
|
4171
4260
|
import { z as z2 } from "zod";
|
|
4172
4261
|
import { isPrivateHost } from "@beechcms/core";
|
|
4173
4262
|
var whenOperandSchema = z2.union([
|
|
@@ -4298,8 +4387,8 @@ var toggleAutomationSchema = z2.object({
|
|
|
4298
4387
|
enabled: z2.boolean()
|
|
4299
4388
|
});
|
|
4300
4389
|
|
|
4301
|
-
// src/features/automations/automations.handler.ts
|
|
4302
|
-
var automationsApp = new
|
|
4390
|
+
// src/features/automations/api/automations.handler.ts
|
|
4391
|
+
var automationsApp = new Hono12();
|
|
4303
4392
|
automationsApp.get("/", async (context) => {
|
|
4304
4393
|
const seedSlug = context.req.query("seed");
|
|
4305
4394
|
if (!seedSlug) {
|
|
@@ -4445,7 +4534,7 @@ automationsApp.delete("/:id", async (context) => {
|
|
|
4445
4534
|
});
|
|
4446
4535
|
|
|
4447
4536
|
// src/features/stats/stats.handler.ts
|
|
4448
|
-
import { Hono as
|
|
4537
|
+
import { Hono as Hono13 } from "hono";
|
|
4449
4538
|
import { SystemClock } from "@beechcms/core";
|
|
4450
4539
|
var DATABASE_ERROR = "Database error";
|
|
4451
4540
|
var INTERNAL_SERVER_ERROR = "Internal Server Error";
|
|
@@ -4467,7 +4556,7 @@ function getMimeType(extension) {
|
|
|
4467
4556
|
const type = extension === "jpg" ? "jpeg" : extension || "jpeg";
|
|
4468
4557
|
return `image/${type}`;
|
|
4469
4558
|
}
|
|
4470
|
-
var statsApp = new
|
|
4559
|
+
var statsApp = new Hono13();
|
|
4471
4560
|
statsApp.get("/stats/media-library", async (context) => {
|
|
4472
4561
|
try {
|
|
4473
4562
|
const mediaRepository = context.get("mediaRepository");
|
|
@@ -4763,7 +4852,7 @@ statsApp.post("/stats/storage/sync", async (context) => {
|
|
|
4763
4852
|
});
|
|
4764
4853
|
|
|
4765
4854
|
// src/features/backrefs/backrefs.handler.ts
|
|
4766
|
-
import { Hono as
|
|
4855
|
+
import { Hono as Hono14 } from "hono";
|
|
4767
4856
|
import { resolvePolicies as resolvePolicies6 } from "@beechcms/core";
|
|
4768
4857
|
|
|
4769
4858
|
// src/features/backrefs/d1-backref.repository.ts
|
|
@@ -4815,7 +4904,7 @@ var D1BackrefRepository = class {
|
|
|
4815
4904
|
var PREVIEW_LIMIT = 3;
|
|
4816
4905
|
var DEFAULT_PAGE_LIMIT = 20;
|
|
4817
4906
|
var MAX_PAGE_LIMIT = 100;
|
|
4818
|
-
var backrefsApp = new
|
|
4907
|
+
var backrefsApp = new Hono14();
|
|
4819
4908
|
backrefsApp.get("/:targetSlug/:targetId/backrefs", async (c) => {
|
|
4820
4909
|
const targetSlug = c.req.param("targetSlug");
|
|
4821
4910
|
const targetId = c.req.param("targetId");
|
|
@@ -4931,55 +5020,228 @@ function filterVisibility(items, sourceSeed) {
|
|
|
4931
5020
|
displayName: item.displayName ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : null
|
|
4932
5021
|
}));
|
|
4933
5022
|
}
|
|
4934
|
-
return items;
|
|
4935
|
-
}
|
|
4936
|
-
|
|
4937
|
-
// src/features/webhooks/index.ts
|
|
4938
|
-
import { Hono as
|
|
4939
|
-
import { Receiver } from "@upstash/qstash";
|
|
4940
|
-
var webhooksApp = new
|
|
4941
|
-
webhooksApp.post("/qstash", async (context) => {
|
|
4942
|
-
const currentSigningKey = context.env.QSTASH_CURRENT_SIGNING_KEY;
|
|
4943
|
-
const nextSigningKey = context.env.QSTASH_NEXT_SIGNING_KEY;
|
|
4944
|
-
if (!currentSigningKey || !nextSigningKey) {
|
|
4945
|
-
console.error("QStash webhook called but signing keys are not configured");
|
|
4946
|
-
return context.text("Configuration error", 500);
|
|
5023
|
+
return items;
|
|
5024
|
+
}
|
|
5025
|
+
|
|
5026
|
+
// src/features/webhooks/index.ts
|
|
5027
|
+
import { Hono as Hono15 } from "hono";
|
|
5028
|
+
import { Receiver } from "@upstash/qstash";
|
|
5029
|
+
var webhooksApp = new Hono15();
|
|
5030
|
+
webhooksApp.post("/qstash", async (context) => {
|
|
5031
|
+
const currentSigningKey = context.env.QSTASH_CURRENT_SIGNING_KEY;
|
|
5032
|
+
const nextSigningKey = context.env.QSTASH_NEXT_SIGNING_KEY;
|
|
5033
|
+
if (!currentSigningKey || !nextSigningKey) {
|
|
5034
|
+
console.error("QStash webhook called but signing keys are not configured");
|
|
5035
|
+
return context.text("Configuration error", 500);
|
|
5036
|
+
}
|
|
5037
|
+
const receiver = new Receiver({
|
|
5038
|
+
currentSigningKey,
|
|
5039
|
+
nextSigningKey
|
|
5040
|
+
});
|
|
5041
|
+
const body = await context.req.text();
|
|
5042
|
+
const signature = context.req.header("Upstash-Signature");
|
|
5043
|
+
if (!signature) {
|
|
5044
|
+
return context.text("Missing signature", 401);
|
|
5045
|
+
}
|
|
5046
|
+
try {
|
|
5047
|
+
const isValid = await receiver.verify({
|
|
5048
|
+
signature,
|
|
5049
|
+
body
|
|
5050
|
+
});
|
|
5051
|
+
if (!isValid) {
|
|
5052
|
+
return context.text("Invalid signature", 401);
|
|
5053
|
+
}
|
|
5054
|
+
} catch (err) {
|
|
5055
|
+
console.error("QStash signature verification failed", err);
|
|
5056
|
+
return context.text("Invalid signature", 401);
|
|
5057
|
+
}
|
|
5058
|
+
try {
|
|
5059
|
+
const input = JSON.parse(body);
|
|
5060
|
+
const notificationRepository = context.get("notificationRepository");
|
|
5061
|
+
await notificationRepository.create({
|
|
5062
|
+
title: input.title,
|
|
5063
|
+
message: input.message,
|
|
5064
|
+
type: input.type ?? "info"
|
|
5065
|
+
});
|
|
5066
|
+
return context.text("OK", 200);
|
|
5067
|
+
} catch (error) {
|
|
5068
|
+
console.error("Failed to process QStash webhook payload", error);
|
|
5069
|
+
return context.text("Internal server error", 500);
|
|
5070
|
+
}
|
|
5071
|
+
});
|
|
5072
|
+
|
|
5073
|
+
// src/features/upload/index.ts
|
|
5074
|
+
import { Hono as Hono16 } from "hono";
|
|
5075
|
+
import { isMimeAccepted } from "@beechcms/core";
|
|
5076
|
+
var DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
5077
|
+
var ABSOLUTE_MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
|
|
5078
|
+
var PRESIGN_TTL_SECONDS = 900;
|
|
5079
|
+
function resolveMaxUploadBytes(env) {
|
|
5080
|
+
const raw = env.MAX_UPLOAD_BYTES;
|
|
5081
|
+
if (!raw) return DEFAULT_MAX_UPLOAD_BYTES;
|
|
5082
|
+
const parsed = Number.parseInt(raw, 10);
|
|
5083
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_UPLOAD_BYTES;
|
|
5084
|
+
return Math.min(parsed, ABSOLUTE_MAX_UPLOAD_BYTES);
|
|
5085
|
+
}
|
|
5086
|
+
function sanitizeFilename(name) {
|
|
5087
|
+
const base = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 100);
|
|
5088
|
+
return base || "file";
|
|
5089
|
+
}
|
|
5090
|
+
function generateObjectKey(originalName) {
|
|
5091
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
5092
|
+
return `${timestamp}-${sanitizeFilename(originalName)}`;
|
|
5093
|
+
}
|
|
5094
|
+
var FORCE_DOWNLOAD_MIME_PREFIXES = [
|
|
5095
|
+
"image/svg",
|
|
5096
|
+
"text/",
|
|
5097
|
+
"application/xml",
|
|
5098
|
+
"application/xhtml",
|
|
5099
|
+
"application/javascript",
|
|
5100
|
+
"application/x-javascript"
|
|
5101
|
+
];
|
|
5102
|
+
function isActiveMimeType(mime) {
|
|
5103
|
+
const lower = mime.toLowerCase();
|
|
5104
|
+
return FORCE_DOWNLOAD_MIME_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
|
5105
|
+
}
|
|
5106
|
+
var uploadRoutes = new Hono16();
|
|
5107
|
+
uploadRoutes.post("/upload/presign", async (c) => {
|
|
5108
|
+
let body;
|
|
5109
|
+
try {
|
|
5110
|
+
body = await c.req.json();
|
|
5111
|
+
} catch {
|
|
5112
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
5113
|
+
}
|
|
5114
|
+
const { filename, mimeType, sizeBytes } = body;
|
|
5115
|
+
if (typeof filename !== "string" || !filename.trim()) return c.json({ error: "filename is required" }, 400);
|
|
5116
|
+
if (typeof mimeType !== "string" || !mimeType.trim()) return c.json({ error: "mimeType is required" }, 400);
|
|
5117
|
+
if (typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || sizeBytes <= 0) {
|
|
5118
|
+
return c.json({ error: "sizeBytes must be a positive number" }, 400);
|
|
5119
|
+
}
|
|
5120
|
+
const maxBytes = resolveMaxUploadBytes(c.env);
|
|
5121
|
+
if (sizeBytes > maxBytes) return c.json({ error: `File too large. Max ${maxBytes} bytes` }, 400);
|
|
5122
|
+
if (!isMimeAccepted(mimeType, "any")) return c.json({ error: "File type not allowed" }, 400);
|
|
5123
|
+
const key = generateObjectKey(filename);
|
|
5124
|
+
const uploadUrl = await c.var.bucket.presignPut(key, {
|
|
5125
|
+
expiresIn: PRESIGN_TTL_SECONDS,
|
|
5126
|
+
contentType: mimeType,
|
|
5127
|
+
contentLength: sizeBytes
|
|
5128
|
+
});
|
|
5129
|
+
return c.json({ uploadUrl, key, expiresIn: PRESIGN_TTL_SECONDS }, 200);
|
|
5130
|
+
});
|
|
5131
|
+
uploadRoutes.post("/upload/confirm", async (c) => {
|
|
5132
|
+
const { bucket, mediaRepository: mediaRepo, systemStatsRepository: statsRepo } = c.var;
|
|
5133
|
+
let body;
|
|
5134
|
+
try {
|
|
5135
|
+
body = await c.req.json();
|
|
5136
|
+
} catch {
|
|
5137
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
5138
|
+
}
|
|
5139
|
+
const { key } = body;
|
|
5140
|
+
if (typeof key !== "string" || !key.trim()) return c.json({ error: "key is required" }, 400);
|
|
5141
|
+
const sanitizedKey = decodeURIComponent(key).replace(/\.\.[/\\]/g, "").replace(/[^a-zA-Z0-9._\-/]/g, "");
|
|
5142
|
+
if (!sanitizedKey || !/^\d+-[a-zA-Z0-9._-]+$/.test(sanitizedKey)) {
|
|
5143
|
+
return c.json({ error: "Invalid key format" }, 400);
|
|
5144
|
+
}
|
|
5145
|
+
const existing = await mediaRepo.getByKey(sanitizedKey);
|
|
5146
|
+
if (existing) return c.json({ url: bucket.getUrl(sanitizedKey) }, 200);
|
|
5147
|
+
const head = await bucket.head(sanitizedKey);
|
|
5148
|
+
if (!head) return c.json({ error: "Object not found in storage" }, 404);
|
|
5149
|
+
const size = head.size ?? 0;
|
|
5150
|
+
const maxBytes = resolveMaxUploadBytes(c.env);
|
|
5151
|
+
if (size > maxBytes) {
|
|
5152
|
+
return c.json({ error: `File too large. Max ${maxBytes} bytes` }, 400);
|
|
4947
5153
|
}
|
|
4948
|
-
const
|
|
4949
|
-
|
|
4950
|
-
|
|
5154
|
+
const uploadedBy = c.var.jwtPayload?.sub ?? "";
|
|
5155
|
+
const mime = head.contentType ?? "application/octet-stream";
|
|
5156
|
+
const filename = sanitizedKey.replace(/^\d+-/, "") || sanitizedKey;
|
|
5157
|
+
await statsRepo.incrementStorage(size);
|
|
5158
|
+
await mediaRepo.trackUpload({
|
|
5159
|
+
key: sanitizedKey,
|
|
5160
|
+
filename,
|
|
5161
|
+
mime_type: mime,
|
|
5162
|
+
size_bytes: size,
|
|
5163
|
+
uploaded_by: uploadedBy
|
|
4951
5164
|
});
|
|
4952
|
-
const
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
5165
|
+
const jwtPayload = c.get("jwtPayload");
|
|
5166
|
+
if (jwtPayload) {
|
|
5167
|
+
c.get("activityLogger").log({
|
|
5168
|
+
action: "upload",
|
|
5169
|
+
entityType: "media",
|
|
5170
|
+
entityId: sanitizedKey,
|
|
5171
|
+
details: { name: filename, size, type: mime },
|
|
5172
|
+
actor: {
|
|
5173
|
+
id: jwtPayload.sub,
|
|
5174
|
+
email: jwtPayload.email ?? "unknown",
|
|
5175
|
+
name: [jwtPayload.name, jwtPayload.surname].filter(Boolean).join(" ") || null
|
|
5176
|
+
}
|
|
5177
|
+
});
|
|
5178
|
+
}
|
|
5179
|
+
return c.json({ url: bucket.getUrl(sanitizedKey) }, 200);
|
|
5180
|
+
});
|
|
5181
|
+
uploadRoutes.get("/upload/download-url/:key", async (c) => {
|
|
5182
|
+
let key = c.req.param("key");
|
|
5183
|
+
if (!key) return c.json({ error: "Missing key" }, 400);
|
|
5184
|
+
key = decodeURIComponent(key);
|
|
5185
|
+
key = key.replace(/\.\.[/\\]/g, "");
|
|
5186
|
+
key = key.replace(/[^a-zA-Z0-9._\-/]/g, "");
|
|
5187
|
+
if (!key) return c.json({ error: "Invalid key" }, 400);
|
|
5188
|
+
const media = await c.var.mediaRepository.getByKey(key).catch(() => null);
|
|
5189
|
+
if (!media) {
|
|
5190
|
+
return c.json({ error: "Media not found" }, 404);
|
|
5191
|
+
}
|
|
5192
|
+
const jwtPayload = c.var.jwtPayload;
|
|
5193
|
+
const userId = jwtPayload?.sub;
|
|
5194
|
+
const isAdmin = jwtPayload?.role === "admin";
|
|
5195
|
+
if (!isAdmin && userId !== media.uploaded_by) {
|
|
5196
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
4956
5197
|
}
|
|
5198
|
+
const head = await c.var.bucket.head(key);
|
|
5199
|
+
if (!head) return c.json({ error: "Object not found" }, 404);
|
|
5200
|
+
const downloadUrl = await c.var.bucket.presignGet(key, { expiresIn: PRESIGN_TTL_SECONDS });
|
|
5201
|
+
return c.json({ downloadUrl, expiresIn: PRESIGN_TTL_SECONDS }, 200);
|
|
5202
|
+
});
|
|
5203
|
+
uploadRoutes.delete("/upload/:key", async (c) => {
|
|
5204
|
+
let key = c.req.param("key");
|
|
5205
|
+
if (!key) return c.json({ error: "Missing key" }, 400);
|
|
5206
|
+
key = key.replace(/\.\.[/\\]/g, "");
|
|
5207
|
+
key = key.replace(/[^a-zA-Z0-9._\-/]/g, "");
|
|
5208
|
+
if (!key) return c.json({ error: "Invalid key" }, 400);
|
|
5209
|
+
const media = await c.var.mediaRepository.getByKey(key).catch(() => null);
|
|
5210
|
+
if (!media) {
|
|
5211
|
+
return c.json({ error: "Media not found" }, 404);
|
|
5212
|
+
}
|
|
5213
|
+
const jwtPayload = c.var.jwtPayload;
|
|
5214
|
+
const userId = jwtPayload?.sub;
|
|
5215
|
+
const isAdmin = jwtPayload?.role === "admin";
|
|
5216
|
+
if (!isAdmin && userId !== media.uploaded_by) {
|
|
5217
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
5218
|
+
}
|
|
5219
|
+
await deleteR2Objects(c, [key]);
|
|
5220
|
+
return c.json({ success: true }, 200);
|
|
5221
|
+
});
|
|
5222
|
+
async function serveMediaHandler(c) {
|
|
5223
|
+
const key = c.req.param("key");
|
|
5224
|
+
if (!key) return new Response("Missing key", { status: 400 });
|
|
4957
5225
|
try {
|
|
4958
|
-
const
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
if (
|
|
4963
|
-
|
|
5226
|
+
const object = await c.var.bucket.get(key);
|
|
5227
|
+
if (!object) return new Response("Not found", { status: 404 });
|
|
5228
|
+
const headers = new Headers();
|
|
5229
|
+
const originalMime = object.contentType ?? "application/octet-stream";
|
|
5230
|
+
if (isActiveMimeType(originalMime)) {
|
|
5231
|
+
headers.set("Content-Type", "application/octet-stream");
|
|
5232
|
+
headers.set("Content-Disposition", "attachment");
|
|
5233
|
+
} else {
|
|
5234
|
+
headers.set("Content-Type", originalMime);
|
|
4964
5235
|
}
|
|
5236
|
+
headers.set("Content-Security-Policy", "default-src 'none'; sandbox");
|
|
5237
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
5238
|
+
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
5239
|
+
return new Response(object.body, { status: 200, headers });
|
|
4965
5240
|
} catch (err) {
|
|
4966
|
-
console.error(
|
|
4967
|
-
return
|
|
4968
|
-
}
|
|
4969
|
-
try {
|
|
4970
|
-
const input = JSON.parse(body);
|
|
4971
|
-
const notificationRepository = context.get("notificationRepository");
|
|
4972
|
-
await notificationRepository.create({
|
|
4973
|
-
title: input.title,
|
|
4974
|
-
message: input.message,
|
|
4975
|
-
type: input.type ?? "info"
|
|
4976
|
-
});
|
|
4977
|
-
return context.text("OK", 200);
|
|
4978
|
-
} catch (error) {
|
|
4979
|
-
console.error("Failed to process QStash webhook payload", error);
|
|
4980
|
-
return context.text("Internal server error", 500);
|
|
5241
|
+
console.error(`[serveMediaHandler] Error serving file ${key}:`, err);
|
|
5242
|
+
return new Response("Internal error", { status: 500 });
|
|
4981
5243
|
}
|
|
4982
|
-
}
|
|
5244
|
+
}
|
|
4983
5245
|
|
|
4984
5246
|
// src/public/public-errors.ts
|
|
4985
5247
|
var PUBLIC_ERRORS = {
|
|
@@ -5036,15 +5298,28 @@ function publicRateLimitMiddleware() {
|
|
|
5036
5298
|
return async (c, next) => {
|
|
5037
5299
|
const readMethod = isReadMethod2(c.req.method);
|
|
5038
5300
|
const limiterName = readMethod ? "publicApiRead" : "publicApiWrite";
|
|
5039
|
-
const
|
|
5301
|
+
const path = c.req.path;
|
|
5302
|
+
let seed = "no-seed";
|
|
5303
|
+
if (path.startsWith("/api/v1/public/")) {
|
|
5304
|
+
const remaining = path.slice("/api/v1/public/".length);
|
|
5305
|
+
const firstSegment = remaining.split("/")[0];
|
|
5306
|
+
if (firstSegment && firstSegment !== "health" && firstSegment !== "schema" && firstSegment !== "schema.html") {
|
|
5307
|
+
seed = firstSegment;
|
|
5308
|
+
}
|
|
5309
|
+
}
|
|
5040
5310
|
const key = `${getClientIp(c.req)}:${seed}:${limiterName}`;
|
|
5041
5311
|
const result = await c.get("rateLimiters").getLimiter(limiterName).checkLimit(key);
|
|
5042
5312
|
if (!result.isAllowed) {
|
|
5313
|
+
const headers = {};
|
|
5314
|
+
if (result.retryAfterSeconds !== void 0) {
|
|
5315
|
+
headers["Retry-After"] = String(result.retryAfterSeconds);
|
|
5316
|
+
}
|
|
5043
5317
|
return publicProblem(c, {
|
|
5044
5318
|
type: "rate-limit-exceeded",
|
|
5045
5319
|
title: "Too Many Requests",
|
|
5046
5320
|
status: 429,
|
|
5047
|
-
detail: "Too many requests"
|
|
5321
|
+
detail: "Too many requests",
|
|
5322
|
+
headers
|
|
5048
5323
|
});
|
|
5049
5324
|
}
|
|
5050
5325
|
await next();
|
|
@@ -5101,7 +5376,7 @@ function withCachedResponse(edgeCache, cacheKey, response) {
|
|
|
5101
5376
|
}
|
|
5102
5377
|
|
|
5103
5378
|
// src/public/read-single.ts
|
|
5104
|
-
import { EntryNotFoundError as
|
|
5379
|
+
import { EntryNotFoundError as EntryNotFoundError6 } from "@beechcms/core";
|
|
5105
5380
|
|
|
5106
5381
|
// src/public/entry-projection.ts
|
|
5107
5382
|
import { resolvePolicies as resolvePolicies7 } from "@beechcms/core";
|
|
@@ -5164,7 +5439,7 @@ async function readSingleEntry(input) {
|
|
|
5164
5439
|
meta: buildPublicSingleMeta(seedSlug)
|
|
5165
5440
|
};
|
|
5166
5441
|
} catch (error) {
|
|
5167
|
-
if (error instanceof
|
|
5442
|
+
if (error instanceof EntryNotFoundError6) {
|
|
5168
5443
|
return { ok: false, detail: `Entry '${label}' not found for content type '${seedSlug}'.` };
|
|
5169
5444
|
}
|
|
5170
5445
|
throw error;
|
|
@@ -5291,6 +5566,7 @@ async function readListEntries(input) {
|
|
|
5291
5566
|
const sortDir = (cleanStr(query.orderDir) ?? "desc").toLowerCase() === "asc" ? "ASC" : "DESC";
|
|
5292
5567
|
const { items, total } = await repository.findMany(seed, {
|
|
5293
5568
|
filters: engineFilters,
|
|
5569
|
+
filterLogic: parsedFilter?.logic,
|
|
5294
5570
|
search: search || void 0,
|
|
5295
5571
|
status: publishedOnly ? "published" : null,
|
|
5296
5572
|
pagination: {
|
|
@@ -5363,10 +5639,10 @@ import { isValidContentStatus as isValidContentStatus3, SlugConflictError as Slu
|
|
|
5363
5639
|
import { slugify as slugify3, generateEntrySlug } from "@beechcms/core";
|
|
5364
5640
|
|
|
5365
5641
|
// src/public/sanitize.ts
|
|
5366
|
-
import { validateAndSanitizeSeedPayload as
|
|
5642
|
+
import { validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload6 } from "@beechcms/core";
|
|
5367
5643
|
function sanitizePublicPayload(seed, payload, options = {}) {
|
|
5368
5644
|
const operation = options.operation ?? "create";
|
|
5369
|
-
const result =
|
|
5645
|
+
const result = validateAndSanitizeSeedPayload6(seed, payload, {
|
|
5370
5646
|
allowNull: options.allowNull ?? false,
|
|
5371
5647
|
operation,
|
|
5372
5648
|
requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
|
|
@@ -5498,7 +5774,7 @@ async function publicAddHandler(context) {
|
|
|
5498
5774
|
}
|
|
5499
5775
|
|
|
5500
5776
|
// src/public/public-edit.ts
|
|
5501
|
-
import { isValidContentStatus as isValidContentStatus4, resolvePolicies as resolvePolicies8, EntryNotFoundError as
|
|
5777
|
+
import { isValidContentStatus as isValidContentStatus4, resolvePolicies as resolvePolicies8, EntryNotFoundError as EntryNotFoundError7 } from "@beechcms/core";
|
|
5502
5778
|
var UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
5503
5779
|
function errorMessage(context, error) {
|
|
5504
5780
|
if (context.env.ENV !== "production" && error instanceof Error) return error.message;
|
|
@@ -5606,7 +5882,7 @@ async function publicEditHandler(context) {
|
|
|
5606
5882
|
});
|
|
5607
5883
|
return context.json({ success: true, id, slug: slugResult.value.nextSlug }, 200);
|
|
5608
5884
|
} catch (error) {
|
|
5609
|
-
if (error instanceof
|
|
5885
|
+
if (error instanceof EntryNotFoundError7) {
|
|
5610
5886
|
return publicProblem(context, { type: "entry-not-found", title: "Not Found", status: 404, detail: `Entry '${id}' not found for content type '${seedSlug}'.` });
|
|
5611
5887
|
}
|
|
5612
5888
|
console.error("Public edit error:", error);
|
|
@@ -5678,10 +5954,10 @@ publicApp.post("/:seed/add", publicAddHandler);
|
|
|
5678
5954
|
publicApp.put("/:seed/edit/:id", publicEditHandler);
|
|
5679
5955
|
var publicRoutes = publicApp;
|
|
5680
5956
|
|
|
5681
|
-
// src/search.ts
|
|
5957
|
+
// src/features/search/search.ts
|
|
5682
5958
|
import { Hono as Hono18 } from "hono";
|
|
5683
5959
|
|
|
5684
|
-
// src/search-utils.ts
|
|
5960
|
+
// src/features/search/search-utils.ts
|
|
5685
5961
|
function encodeCursor(rank, entryId) {
|
|
5686
5962
|
return btoa(`${rank}:${entryId}`);
|
|
5687
5963
|
}
|
|
@@ -5786,7 +6062,7 @@ function mapSearchResultRow(row) {
|
|
|
5786
6062
|
};
|
|
5787
6063
|
}
|
|
5788
6064
|
|
|
5789
|
-
// src/search.ts
|
|
6065
|
+
// src/features/search/search.ts
|
|
5790
6066
|
var searchRouter = new Hono18();
|
|
5791
6067
|
searchRouter.use("*", authMiddleware());
|
|
5792
6068
|
searchRouter.get("/", async (c) => {
|
|
@@ -5827,19 +6103,19 @@ searchRouter.get("/", async (c) => {
|
|
|
5827
6103
|
// src/middleware/repository.middleware.ts
|
|
5828
6104
|
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
5829
6105
|
|
|
5830
|
-
// src/shared/content.repository.d1.ts
|
|
6106
|
+
// src/shared/db/repositories/content.repository.d1.ts
|
|
5831
6107
|
import {
|
|
5832
|
-
EntryNotFoundError as
|
|
6108
|
+
EntryNotFoundError as EntryNotFoundError8,
|
|
5833
6109
|
RepositoryError as RepositoryError2,
|
|
5834
|
-
SlugConflictError as
|
|
6110
|
+
SlugConflictError as SlugConflictError4,
|
|
5835
6111
|
RelationTargetNotFoundError as RelationTargetNotFoundError2,
|
|
5836
6112
|
buildSelectQuery,
|
|
5837
6113
|
deserializeFromDb as deserializeFromDb2,
|
|
5838
6114
|
serializeForDb
|
|
5839
6115
|
} from "@beechcms/core";
|
|
5840
6116
|
|
|
5841
|
-
// src/shared/base.repository.d1.ts
|
|
5842
|
-
import { RepositoryError } from "@beechcms/core";
|
|
6117
|
+
// src/shared/db/repositories/base.repository.d1.ts
|
|
6118
|
+
import { RepositoryError, SlugConflictError as SlugConflictError3 } from "@beechcms/core";
|
|
5843
6119
|
var BaseD1Repository = class {
|
|
5844
6120
|
constructor(database) {
|
|
5845
6121
|
this.database = database;
|
|
@@ -5857,11 +6133,14 @@ var BaseD1Repository = class {
|
|
|
5857
6133
|
*/
|
|
5858
6134
|
mapError(error, context) {
|
|
5859
6135
|
const message = error?.message || "Unknown database error";
|
|
6136
|
+
if (message.includes("UNIQUE constraint failed") && message.includes("slug")) {
|
|
6137
|
+
return new SlugConflictError3(`${context}: ${message}`);
|
|
6138
|
+
}
|
|
5860
6139
|
return new RepositoryError(`${context}: ${message}`, error);
|
|
5861
6140
|
}
|
|
5862
6141
|
};
|
|
5863
6142
|
|
|
5864
|
-
// src/shared/content.repository.d1.ts
|
|
6143
|
+
// src/shared/db/repositories/content.repository.d1.ts
|
|
5865
6144
|
function multiRelBranches(seed) {
|
|
5866
6145
|
return seed.branches.filter((b) => b.type === "relation" && b.multiple === true);
|
|
5867
6146
|
}
|
|
@@ -5880,6 +6159,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
5880
6159
|
this.hooks = hooks;
|
|
5881
6160
|
}
|
|
5882
6161
|
hooks;
|
|
6162
|
+
/** Assembles the {@link HookContext} passed to every lifecycle hook invocation. */
|
|
5883
6163
|
hookCtx(seed, actor) {
|
|
5884
6164
|
return { seed, repository: this, actor, db: this.database };
|
|
5885
6165
|
}
|
|
@@ -5901,10 +6181,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
5901
6181
|
data[branch.alias] = deserializeFromDb2(branch, row[branch.alias]);
|
|
5902
6182
|
}
|
|
5903
6183
|
}
|
|
6184
|
+
if (row.position !== void 0) data.position = row.position;
|
|
5904
6185
|
return data;
|
|
5905
6186
|
}
|
|
5906
6187
|
/**
|
|
5907
6188
|
* Fetches multi-relation arrays for a single entry and attaches them to `data`.
|
|
6189
|
+
*
|
|
6190
|
+
* @param seed The seed schema definition.
|
|
6191
|
+
* @param entryId Id of the entry to fetch relation targets for.
|
|
6192
|
+
* @param data Mutated in place: one array property per multi-relation branch alias.
|
|
5908
6193
|
*/
|
|
5909
6194
|
async attachMultiRelations(seed, entryId, data) {
|
|
5910
6195
|
const branches = multiRelBranches(seed);
|
|
@@ -5918,81 +6203,125 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
5918
6203
|
}
|
|
5919
6204
|
}
|
|
5920
6205
|
/**
|
|
5921
|
-
*
|
|
5922
|
-
*
|
|
6206
|
+
* Builds one junction-table statement per multi-relation branch, selecting the rows whose
|
|
6207
|
+
* `parent_id` falls within the page defined by `idQuery` (the page selection re-projected to
|
|
6208
|
+
* ids only). Using a subquery instead of a bound `IN (?, ...)` id list keeps the statements
|
|
6209
|
+
* eligible for the same batch as the page query and avoids D1's bound-parameter limit.
|
|
6210
|
+
*
|
|
6211
|
+
* The subquery is wrapped in `SELECT id FROM (...)` because `kanbanOrder` projections carry an
|
|
6212
|
+
* extra `kp.position` column, and `IN` requires a single-column subselect.
|
|
5923
6213
|
*/
|
|
5924
|
-
|
|
5925
|
-
|
|
5926
|
-
if (branches.length === 0 || entries.length === 0) return;
|
|
5927
|
-
const ids = entries.map((e) => e.id);
|
|
5928
|
-
const placeholders = ids.map(() => "?").join(", ");
|
|
5929
|
-
const stmts = branches.map(
|
|
6214
|
+
multiRelStatements(seed, branches, idQuery) {
|
|
6215
|
+
return branches.map(
|
|
5930
6216
|
(b) => this.database.prepare(
|
|
5931
|
-
`SELECT parent_id, target_id FROM ${jTable(seed.slug, b.alias)} WHERE parent_id IN (${
|
|
5932
|
-
).bind(...
|
|
6217
|
+
`SELECT parent_id, target_id FROM ${jTable(seed.slug, b.alias)} WHERE parent_id IN (SELECT id FROM (${idQuery.sql})) ORDER BY position ASC`
|
|
6218
|
+
).bind(...idQuery.bindings)
|
|
5933
6219
|
);
|
|
5934
|
-
|
|
6220
|
+
}
|
|
6221
|
+
/**
|
|
6222
|
+
* Groups junction rows by parent and attaches them to the entries: one array property per
|
|
6223
|
+
* multi-relation branch alias, per entry. `relResults[i]` must correspond to `branches[i]`.
|
|
6224
|
+
*
|
|
6225
|
+
* @param entries Mutated in place.
|
|
6226
|
+
*/
|
|
6227
|
+
attachMultiRelationRows(branches, relResults, entries) {
|
|
5935
6228
|
for (let i = 0; i < branches.length; i++) {
|
|
5936
6229
|
const branch = branches[i];
|
|
5937
6230
|
const byParent = /* @__PURE__ */ new Map();
|
|
5938
|
-
for (const row of
|
|
6231
|
+
for (const row of relResults[i].results ?? []) {
|
|
5939
6232
|
const r = row;
|
|
5940
6233
|
const pid = r.parent_id;
|
|
5941
|
-
|
|
5942
|
-
|
|
6234
|
+
let targets = byParent.get(pid);
|
|
6235
|
+
if (!targets) {
|
|
6236
|
+
targets = [];
|
|
6237
|
+
byParent.set(pid, targets);
|
|
6238
|
+
}
|
|
6239
|
+
targets.push(r.target_id);
|
|
5943
6240
|
}
|
|
5944
6241
|
for (const entry of entries) {
|
|
5945
6242
|
entry[branch.alias] = byParent.get(entry.id) ?? [];
|
|
5946
6243
|
}
|
|
5947
6244
|
}
|
|
5948
6245
|
}
|
|
6246
|
+
/**
|
|
6247
|
+
* Finds multiple entries for a given seed table based on the provided query options.
|
|
6248
|
+
* Executes a single batch (one D1 round-trip) containing:
|
|
6249
|
+
* 1. A selection query to retrieve the items for the current page (with columns, limits, offsets, and ordering).
|
|
6250
|
+
* 2. A count query (using `isCount: true`) to retrieve the total number of matched items matching the same filters.
|
|
6251
|
+
* 3. One junction-table query per multi-relation branch, scoped to the page via an id subquery.
|
|
6252
|
+
*
|
|
6253
|
+
* @param seed The seed schema definition.
|
|
6254
|
+
* @param options Filtering, search, ordering, and pagination options.
|
|
6255
|
+
* @returns An object containing the list of serialized entries and the total count of matched items.
|
|
6256
|
+
* @throws RepositoryError if the database batch execution fails.
|
|
6257
|
+
*/
|
|
5949
6258
|
async findMany(seed, options) {
|
|
5950
6259
|
try {
|
|
5951
6260
|
const { sql, bindings } = buildSelectQuery(seed, options);
|
|
5952
|
-
const
|
|
5953
|
-
|
|
5954
|
-
|
|
6261
|
+
const { sql: countSql, bindings: countBindings } = buildSelectQuery(seed, {
|
|
6262
|
+
...options,
|
|
6263
|
+
isCount: true
|
|
6264
|
+
});
|
|
6265
|
+
const branches = multiRelBranches(seed);
|
|
6266
|
+
const relStmts = branches.length > 0 ? this.multiRelStatements(seed, branches, buildSelectQuery(seed, { ...options, fields: ["id"] })) : [];
|
|
6267
|
+
const [batchResults, totalCountResult, ...relResults] = await this.database.batch([
|
|
5955
6268
|
this.database.prepare(sql).bind(...bindings),
|
|
5956
|
-
this.database.prepare(countSql).bind(...countBindings)
|
|
6269
|
+
this.database.prepare(countSql).bind(...countBindings),
|
|
6270
|
+
...relStmts
|
|
5957
6271
|
]);
|
|
5958
6272
|
const contentEntries = (batchResults.results || []).map((row) => this.rowToData(seed, row));
|
|
5959
6273
|
const totalEntriesCount = totalCountResult.results?.[0]?.total || 0;
|
|
5960
|
-
|
|
6274
|
+
this.attachMultiRelationRows(branches, relResults, contentEntries);
|
|
5961
6275
|
return { items: contentEntries, total: totalEntriesCount };
|
|
5962
6276
|
} catch (error) {
|
|
5963
6277
|
throw this.mapError(error, `findMany(${seed.slug})`);
|
|
5964
6278
|
}
|
|
5965
6279
|
}
|
|
6280
|
+
/**
|
|
6281
|
+
* Fetches a single entry by id, including its multi-relation arrays.
|
|
6282
|
+
* @throws EntryNotFoundError if no row with `id` exists in the seed's table.
|
|
6283
|
+
* @throws RepositoryError on any other database failure.
|
|
6284
|
+
*/
|
|
5966
6285
|
async findById(seed, id) {
|
|
5967
6286
|
try {
|
|
5968
6287
|
const tableName = this.getTableName(seed.slug);
|
|
5969
6288
|
const entryRow = await this.database.prepare(`SELECT * FROM ${tableName} WHERE id = ? LIMIT 1`).bind(id).first();
|
|
5970
6289
|
if (!entryRow) {
|
|
5971
|
-
throw new
|
|
6290
|
+
throw new EntryNotFoundError8(`Entry ${id} not found in ${seed.slug}`);
|
|
5972
6291
|
}
|
|
5973
6292
|
const data = this.rowToData(seed, entryRow);
|
|
5974
6293
|
await this.attachMultiRelations(seed, id, data);
|
|
5975
6294
|
return data;
|
|
5976
6295
|
} catch (error) {
|
|
5977
|
-
if (error instanceof
|
|
6296
|
+
if (error instanceof EntryNotFoundError8) throw error;
|
|
5978
6297
|
throw this.mapError(error, `findById(${seed.slug}, ${id})`);
|
|
5979
6298
|
}
|
|
5980
6299
|
}
|
|
6300
|
+
/**
|
|
6301
|
+
* Fetches a single entry by its unique `slug`, including its multi-relation arrays.
|
|
6302
|
+
* @throws EntryNotFoundError if no row with `slug` exists in the seed's table.
|
|
6303
|
+
* @throws RepositoryError on any other database failure.
|
|
6304
|
+
*/
|
|
5981
6305
|
async findBySlug(seed, slug) {
|
|
5982
6306
|
try {
|
|
5983
6307
|
const tableName = this.getTableName(seed.slug);
|
|
5984
6308
|
const entryRow = await this.database.prepare(`SELECT * FROM ${tableName} WHERE slug = ? LIMIT 1`).bind(slug).first();
|
|
5985
6309
|
if (!entryRow) {
|
|
5986
|
-
throw new
|
|
6310
|
+
throw new EntryNotFoundError8(`Entry with slug "${slug}" not found in ${seed.slug}`);
|
|
5987
6311
|
}
|
|
5988
6312
|
const data = this.rowToData(seed, entryRow);
|
|
5989
6313
|
await this.attachMultiRelations(seed, entryRow.id, data);
|
|
5990
6314
|
return data;
|
|
5991
6315
|
} catch (error) {
|
|
5992
|
-
if (error instanceof
|
|
6316
|
+
if (error instanceof EntryNotFoundError8) throw error;
|
|
5993
6317
|
throw this.mapError(error, `findBySlug(${seed.slug}, ${slug})`);
|
|
5994
6318
|
}
|
|
5995
6319
|
}
|
|
6320
|
+
/**
|
|
6321
|
+
* Computes aggregate facets for a seed: entry counts per `status`, and the distinct set
|
|
6322
|
+
* of tag values present across all entries for each `tags`-typed branch (via `json_each`
|
|
6323
|
+
* over the JSON-serialized tag column).
|
|
6324
|
+
*/
|
|
5996
6325
|
async getFacets(seed) {
|
|
5997
6326
|
try {
|
|
5998
6327
|
const tableName = this.getTableName(seed.slug);
|
|
@@ -6012,6 +6341,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6012
6341
|
throw this.mapError(error, `getFacets(${seed.slug})`);
|
|
6013
6342
|
}
|
|
6014
6343
|
}
|
|
6344
|
+
/**
|
|
6345
|
+
* Checks whether `slug` is already taken in the seed's table.
|
|
6346
|
+
* @param excludeId When provided (e.g. during an update), excludes that entry's own row from the check.
|
|
6347
|
+
*/
|
|
6015
6348
|
async existsSlug(seed, slug, excludeId) {
|
|
6016
6349
|
try {
|
|
6017
6350
|
const tableName = this.getTableName(seed.slug);
|
|
@@ -6027,6 +6360,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6027
6360
|
throw this.mapError(error, `existsSlug(${seed.slug}, ${slug})`);
|
|
6028
6361
|
}
|
|
6029
6362
|
}
|
|
6363
|
+
/**
|
|
6364
|
+
* Builds the `INSERT` statement for an entry's main table row. Multi-relation branch
|
|
6365
|
+
* values are skipped (they're written as separate junction-table inserts by the caller).
|
|
6366
|
+
*/
|
|
6030
6367
|
buildCreateMainStmt(seed, id, slug, status, data) {
|
|
6031
6368
|
const tableName = this.getTableName(seed.slug);
|
|
6032
6369
|
const columnNames = ["id", "slug", "status"];
|
|
@@ -6044,6 +6381,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6044
6381
|
const mainSql = `INSERT INTO ${tableName} (${columnNames.join(", ")}) VALUES (${placeholders.join(", ")})`;
|
|
6045
6382
|
return this.database.prepare(mainSql).bind(...queryBindings);
|
|
6046
6383
|
}
|
|
6384
|
+
/**
|
|
6385
|
+
* Builds the `UPDATE` statement for an entry's main table row from whichever branch
|
|
6386
|
+
* aliases are present in `data` (partial update — absent keys are left untouched).
|
|
6387
|
+
* Multi-relation branches present in `data` are returned as `junctionUpdates` instead
|
|
6388
|
+
* of being included in the SQL, since they require separate delete+insert statements.
|
|
6389
|
+
*
|
|
6390
|
+
* @returns `stmt: null` when there is nothing to update on the main row (no scalar
|
|
6391
|
+
* fields and no `status` change) even if junction updates are still needed.
|
|
6392
|
+
*/
|
|
6047
6393
|
buildUpdateMainStmt(seed, id, data, status) {
|
|
6048
6394
|
const tableName = this.getTableName(seed.slug);
|
|
6049
6395
|
const updateClauses = [];
|
|
@@ -6054,6 +6400,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6054
6400
|
updateClauses.push("status = ?");
|
|
6055
6401
|
queryBindings.push(status);
|
|
6056
6402
|
}
|
|
6403
|
+
if (data.slug) {
|
|
6404
|
+
updateClauses.push("slug = ?");
|
|
6405
|
+
queryBindings.push(data.slug);
|
|
6406
|
+
}
|
|
6057
6407
|
for (const branch of seed.branches) {
|
|
6058
6408
|
if (mRelAliases.has(branch.alias)) continue;
|
|
6059
6409
|
if (Object.hasOwn(data, branch.alias)) {
|
|
@@ -6073,18 +6423,28 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6073
6423
|
junctionUpdates
|
|
6074
6424
|
};
|
|
6075
6425
|
}
|
|
6426
|
+
/** Builds one `INSERT` per target id into a live multi-relation junction table, preserving array order via `position`. */
|
|
6076
6427
|
buildJunctionInserts(seedSlug, parentId, branchAlias, targetIds) {
|
|
6077
6428
|
const jt = jTable(seedSlug, branchAlias);
|
|
6078
6429
|
return targetIds.map(
|
|
6079
6430
|
(targetId, i) => this.database.prepare(`INSERT INTO ${jt} (parent_id, target_id, position) VALUES (?, ?, ?)`).bind(parentId, targetId, i)
|
|
6080
6431
|
);
|
|
6081
6432
|
}
|
|
6433
|
+
/** Builds one `INSERT` per target id into a draft multi-relation junction table, preserving array order via `position`. */
|
|
6082
6434
|
buildDraftJunctionInserts(seedSlug, entryId, branchAlias, targetIds) {
|
|
6083
6435
|
const jdt = jDraftTable(seedSlug, branchAlias);
|
|
6084
6436
|
return targetIds.map(
|
|
6085
6437
|
(targetId, i) => this.database.prepare(`INSERT INTO ${jdt} (entry_id, target_id, position) VALUES (?, ?, ?)`).bind(entryId, targetId, i)
|
|
6086
6438
|
);
|
|
6087
6439
|
}
|
|
6440
|
+
/**
|
|
6441
|
+
* Validates that every relation target referenced by a draft still exists before publish,
|
|
6442
|
+
* for both single-relation columns and multi-relation draft junction rows.
|
|
6443
|
+
*
|
|
6444
|
+
* @throws RelationTargetNotFoundError if any referenced target id is missing from its target seed's table.
|
|
6445
|
+
* @returns The multi-relation branches and their resolved (validated) draft target ids,
|
|
6446
|
+
* so the caller can reuse them without re-querying the draft junction tables.
|
|
6447
|
+
*/
|
|
6088
6448
|
async validatePublishDraftRelations(seed, draftRow, entryId) {
|
|
6089
6449
|
for (const branch of singleRelBranches(seed)) {
|
|
6090
6450
|
const value = draftRow[branch.alias];
|
|
@@ -6118,6 +6478,12 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6118
6478
|
}
|
|
6119
6479
|
return { mRelBranches, mRelDraftIds };
|
|
6120
6480
|
}
|
|
6481
|
+
/**
|
|
6482
|
+
* Builds the junction-table statements for one array-typed {@link BulkFieldUpdate}:
|
|
6483
|
+
* `array_replace` (delete-all + reinsert), `array_add` (append via `INSERT OR IGNORE`
|
|
6484
|
+
* with position computed from current max), or `array_remove` (targeted delete).
|
|
6485
|
+
* Returns an empty array for a no-op (e.g. `array_remove` with an empty value list).
|
|
6486
|
+
*/
|
|
6121
6487
|
getBulkArrayUpdateStmts(seedSlug, id, alias, update) {
|
|
6122
6488
|
const jt = jTable(seedSlug, alias);
|
|
6123
6489
|
const stmts = [];
|
|
@@ -6143,16 +6509,23 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6143
6509
|
}
|
|
6144
6510
|
return stmts;
|
|
6145
6511
|
}
|
|
6146
|
-
|
|
6512
|
+
/**
|
|
6513
|
+
* Applies a bulk field update to a single entry as one batch: a main-table `UPDATE` for
|
|
6514
|
+
* `set`-kind fields plus any junction statements for array-kind fields.
|
|
6515
|
+
* @throws Error('not-found') if the main `UPDATE` affects zero rows (id doesn't exist).
|
|
6516
|
+
*/
|
|
6517
|
+
async processBulkUpdateSingle(seed, id, fields, tableName) {
|
|
6147
6518
|
const stmts = [];
|
|
6148
6519
|
const setClauses = ["updated_at = (unixepoch())"];
|
|
6149
6520
|
const setBindings = [];
|
|
6150
6521
|
for (const [alias, update] of Object.entries(fields)) {
|
|
6151
6522
|
if (update.kind === "set") {
|
|
6152
|
-
|
|
6153
|
-
|
|
6523
|
+
const branch = seed.branches.find((b) => b.alias === alias);
|
|
6524
|
+
const serializedValue = branch ? serializeForDb(branch, update.value) : update.value;
|
|
6525
|
+
setClauses.push(`${alias} = ?`);
|
|
6526
|
+
setBindings.push(serializedValue);
|
|
6154
6527
|
} else {
|
|
6155
|
-
stmts.push(...this.getBulkArrayUpdateStmts(
|
|
6528
|
+
stmts.push(...this.getBulkArrayUpdateStmts(seed.slug, id, alias, update));
|
|
6156
6529
|
}
|
|
6157
6530
|
}
|
|
6158
6531
|
stmts.unshift(
|
|
@@ -6163,12 +6536,17 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6163
6536
|
throw new Error("not-found");
|
|
6164
6537
|
}
|
|
6165
6538
|
}
|
|
6166
|
-
|
|
6539
|
+
/**
|
|
6540
|
+
* Sequentially applies a bulk field update to each id in `chunk`, isolating per-id
|
|
6541
|
+
* failures (not-found, FK violation, or other error) into the `failed` list rather than
|
|
6542
|
+
* aborting the whole chunk.
|
|
6543
|
+
*/
|
|
6544
|
+
async processBulkUpdateChunk(seed, chunk, fields, tableName) {
|
|
6167
6545
|
let updated = 0;
|
|
6168
6546
|
const failed = [];
|
|
6169
6547
|
for (const id of chunk) {
|
|
6170
6548
|
try {
|
|
6171
|
-
await this.processBulkUpdateSingle(
|
|
6549
|
+
await this.processBulkUpdateSingle(seed, id, fields, tableName);
|
|
6172
6550
|
updated++;
|
|
6173
6551
|
} catch (err) {
|
|
6174
6552
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -6182,6 +6560,14 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6182
6560
|
}
|
|
6183
6561
|
return { updated, failed };
|
|
6184
6562
|
}
|
|
6563
|
+
/**
|
|
6564
|
+
* Creates a new entry: runs `beforeCreate` (which may transform the payload), inserts the
|
|
6565
|
+
* main row plus any multi-relation junction rows as a single batch, then runs `afterCreate`.
|
|
6566
|
+
*
|
|
6567
|
+
* @throws SlugConflictError if `slug` already exists for this seed.
|
|
6568
|
+
* @throws RepositoryError on any other database failure.
|
|
6569
|
+
* @remarks `afterCreate` runs after the batch has committed, so it cannot roll back the write.
|
|
6570
|
+
*/
|
|
6185
6571
|
async create(seed, id, slug, status, data, options) {
|
|
6186
6572
|
let payload = data;
|
|
6187
6573
|
if (this.hooks?.beforeCreate) {
|
|
@@ -6190,7 +6576,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6190
6576
|
}
|
|
6191
6577
|
try {
|
|
6192
6578
|
if (await this.existsSlug(seed, slug)) {
|
|
6193
|
-
throw new
|
|
6579
|
+
throw new SlugConflictError4(`Slug "${slug}" already exists for ${seed.slug}`);
|
|
6194
6580
|
}
|
|
6195
6581
|
const batchStmts = [
|
|
6196
6582
|
this.buildCreateMainStmt(seed, id, slug, status, payload)
|
|
@@ -6206,7 +6592,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6206
6592
|
await this.database.batch(batchStmts);
|
|
6207
6593
|
}
|
|
6208
6594
|
} catch (error) {
|
|
6209
|
-
if (error instanceof
|
|
6595
|
+
if (error instanceof SlugConflictError4) throw error;
|
|
6210
6596
|
throw this.mapError(error, `create(${seed.slug})`);
|
|
6211
6597
|
}
|
|
6212
6598
|
if (this.hooks?.afterCreate) {
|
|
@@ -6214,6 +6600,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6214
6600
|
await this.hooks.afterCreate(entry, this.hookCtx(seed, options?.actor));
|
|
6215
6601
|
}
|
|
6216
6602
|
}
|
|
6603
|
+
/**
|
|
6604
|
+
* Partially updates an existing entry: runs `beforeUpdate`, applies main-row and
|
|
6605
|
+
* junction-table changes as a batch, then runs `afterUpdate`. A no-op payload (no scalar
|
|
6606
|
+
* fields, no multi-relation fields, no status change) returns without touching the database.
|
|
6607
|
+
*
|
|
6608
|
+
* @throws EntryNotFoundError if the main-row `UPDATE` affects zero rows.
|
|
6609
|
+
* @throws RepositoryError on any other database failure.
|
|
6610
|
+
* @remarks `afterUpdate` runs after the batch has committed, so it cannot roll back the write.
|
|
6611
|
+
*/
|
|
6217
6612
|
async update(seed, id, data, status, options) {
|
|
6218
6613
|
let payload = data;
|
|
6219
6614
|
if (this.hooks?.beforeUpdate) {
|
|
@@ -6234,16 +6629,16 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6234
6629
|
if (batchStmts.length === 1) {
|
|
6235
6630
|
const updateResult = await batchStmts[0].run();
|
|
6236
6631
|
if (updateResult.meta.changes === 0) {
|
|
6237
|
-
throw new
|
|
6632
|
+
throw new EntryNotFoundError8(`Entry ${id} not found in ${seed.slug}`);
|
|
6238
6633
|
}
|
|
6239
6634
|
} else if (batchStmts.length > 1) {
|
|
6240
6635
|
const results = await this.database.batch(batchStmts);
|
|
6241
6636
|
if (stmt && (results[0].meta?.changes ?? 0) === 0) {
|
|
6242
|
-
throw new
|
|
6637
|
+
throw new EntryNotFoundError8(`Entry ${id} not found in ${seed.slug}`);
|
|
6243
6638
|
}
|
|
6244
6639
|
}
|
|
6245
6640
|
} catch (error) {
|
|
6246
|
-
if (error instanceof
|
|
6641
|
+
if (error instanceof EntryNotFoundError8) throw error;
|
|
6247
6642
|
throw this.mapError(error, `update(${seed.slug}, ${id})`);
|
|
6248
6643
|
}
|
|
6249
6644
|
if (this.hooks?.afterUpdate) {
|
|
@@ -6251,6 +6646,14 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6251
6646
|
await this.hooks.afterUpdate(entry, this.hookCtx(seed, options?.actor));
|
|
6252
6647
|
}
|
|
6253
6648
|
}
|
|
6649
|
+
/**
|
|
6650
|
+
* Deletes an entry: runs `beforeDelete`, reads the row (for the return value), deletes it,
|
|
6651
|
+
* then runs `afterDelete`. Relies on database-level `ON DELETE CASCADE` for junction rows.
|
|
6652
|
+
*
|
|
6653
|
+
* @returns The deserialized row as it existed immediately before deletion.
|
|
6654
|
+
* @throws EntryNotFoundError if no row with `id` exists.
|
|
6655
|
+
* @remarks `afterDelete` runs after the delete has committed, so it cannot roll back the write.
|
|
6656
|
+
*/
|
|
6254
6657
|
async delete(seed, id, options) {
|
|
6255
6658
|
if (this.hooks?.beforeDelete) {
|
|
6256
6659
|
await this.hooks.beforeDelete(id, this.hookCtx(seed, options?.actor));
|
|
@@ -6260,12 +6663,12 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6260
6663
|
const tableName = this.getTableName(seed.slug);
|
|
6261
6664
|
const entryRow = await this.database.prepare(`SELECT * FROM ${tableName} WHERE id = ?`).bind(id).first();
|
|
6262
6665
|
if (!entryRow) {
|
|
6263
|
-
throw new
|
|
6666
|
+
throw new EntryNotFoundError8(`Entry ${id} not found in ${seed.slug}`);
|
|
6264
6667
|
}
|
|
6265
6668
|
await this.database.prepare(`DELETE FROM ${tableName} WHERE id = ?`).bind(id).run();
|
|
6266
6669
|
row = this.rowToData(seed, entryRow);
|
|
6267
6670
|
} catch (error) {
|
|
6268
|
-
if (error instanceof
|
|
6671
|
+
if (error instanceof EntryNotFoundError8) throw error;
|
|
6269
6672
|
throw this.mapError(error, `delete(${seed.slug}, ${id})`);
|
|
6270
6673
|
}
|
|
6271
6674
|
if (this.hooks?.afterDelete) {
|
|
@@ -6273,6 +6676,14 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6273
6676
|
}
|
|
6274
6677
|
return { row };
|
|
6275
6678
|
}
|
|
6679
|
+
/**
|
|
6680
|
+
* Atomically increments/decrements a numeric field in a single conditional `UPDATE`
|
|
6681
|
+
* (guarded by optional `min`/`max` bounds evaluated against the new value), avoiding
|
|
6682
|
+
* read-modify-write races.
|
|
6683
|
+
*
|
|
6684
|
+
* @throws RepositoryError if `fieldName` isn't a `number`-typed branch on `seed`, or if
|
|
6685
|
+
* the conditional `UPDATE` affects zero rows (entry not found, or bound exceeded).
|
|
6686
|
+
*/
|
|
6276
6687
|
async mutateField(seed, id, fieldName, operation, options) {
|
|
6277
6688
|
try {
|
|
6278
6689
|
const branch = seed.branches.find((b) => b.alias === fieldName);
|
|
@@ -6302,6 +6713,14 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6302
6713
|
throw this.mapError(error, `mutateField(${seed.slug}, ${id}, ${fieldName})`);
|
|
6303
6714
|
}
|
|
6304
6715
|
}
|
|
6716
|
+
/**
|
|
6717
|
+
* Executes a heterogeneous list of {@link BatchWrite} operations (create/update/mutateField)
|
|
6718
|
+
* as a single atomic `database.batch` call. Does not invoke lifecycle hooks — callers needing
|
|
6719
|
+
* hook side effects should use {@link create}/{@link update}/{@link mutateField} instead.
|
|
6720
|
+
*
|
|
6721
|
+
* @throws RepositoryError if any operation references a non-numeric field for `mutateField`,
|
|
6722
|
+
* or on any other database failure.
|
|
6723
|
+
*/
|
|
6305
6724
|
async runBatch(operations) {
|
|
6306
6725
|
if (operations.length === 0) return;
|
|
6307
6726
|
try {
|
|
@@ -6350,6 +6769,11 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6350
6769
|
throw this.mapError(error, "runBatch");
|
|
6351
6770
|
}
|
|
6352
6771
|
}
|
|
6772
|
+
/**
|
|
6773
|
+
* Upserts a draft row for `entryId` (`INSERT ... ON CONFLICT DO UPDATE` keyed on `entry_id`)
|
|
6774
|
+
* plus a full delete+reinsert of each multi-relation branch's draft junction rows.
|
|
6775
|
+
* @throws RepositoryError if `seed.allowDrafts` is false, or on any database failure.
|
|
6776
|
+
*/
|
|
6353
6777
|
async saveDraft(seed, entryId, data) {
|
|
6354
6778
|
try {
|
|
6355
6779
|
if (!seed.allowDrafts) {
|
|
@@ -6358,6 +6782,21 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6358
6782
|
const draftTableName = this.getTableName(seed.slug, true);
|
|
6359
6783
|
const mRelBranches = multiRelBranches(seed);
|
|
6360
6784
|
const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
|
|
6785
|
+
let existingTouched = [];
|
|
6786
|
+
try {
|
|
6787
|
+
const existing = await this.database.prepare(`SELECT _touched_fields FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId).first();
|
|
6788
|
+
if (existing && existing._touched_fields) {
|
|
6789
|
+
existingTouched = JSON.parse(existing._touched_fields);
|
|
6790
|
+
}
|
|
6791
|
+
} catch {
|
|
6792
|
+
}
|
|
6793
|
+
const currentTouched = new Set(existingTouched);
|
|
6794
|
+
for (const branch of seed.branches) {
|
|
6795
|
+
if (Object.hasOwn(data, branch.alias)) {
|
|
6796
|
+
currentTouched.add(branch.alias);
|
|
6797
|
+
}
|
|
6798
|
+
}
|
|
6799
|
+
const updatedTouchedFields = Array.from(currentTouched);
|
|
6361
6800
|
const columnNames = ["entry_id"];
|
|
6362
6801
|
const placeholders = ["?"];
|
|
6363
6802
|
const queryBindings = [entryId];
|
|
@@ -6372,6 +6811,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6372
6811
|
updateClauses.push(`${branch.alias} = EXCLUDED.${branch.alias}`);
|
|
6373
6812
|
}
|
|
6374
6813
|
}
|
|
6814
|
+
columnNames.push("_touched_fields");
|
|
6815
|
+
placeholders.push("?");
|
|
6816
|
+
queryBindings.push(JSON.stringify(updatedTouchedFields));
|
|
6817
|
+
updateClauses.push(`_touched_fields = EXCLUDED._touched_fields`);
|
|
6375
6818
|
updateClauses.push("updated_at = (unixepoch())");
|
|
6376
6819
|
const sql = `
|
|
6377
6820
|
INSERT INTO ${draftTableName} (${columnNames.join(", ")})
|
|
@@ -6403,6 +6846,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6403
6846
|
throw this.mapError(error, `saveDraft(${seed.slug}, ${entryId})`);
|
|
6404
6847
|
}
|
|
6405
6848
|
}
|
|
6849
|
+
/**
|
|
6850
|
+
* Fetches the draft row for `entryId`, if any, merged with its multi-relation draft arrays.
|
|
6851
|
+
* @returns `null` if `seed.allowDrafts` is false or no draft row exists.
|
|
6852
|
+
*/
|
|
6406
6853
|
async getDraft(seed, entryId) {
|
|
6407
6854
|
try {
|
|
6408
6855
|
if (!seed.allowDrafts) return null;
|
|
@@ -6411,10 +6858,20 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6411
6858
|
if (!draftRow) return null;
|
|
6412
6859
|
const mRelBranches = multiRelBranches(seed);
|
|
6413
6860
|
const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
|
|
6861
|
+
let touchedFields = [];
|
|
6862
|
+
if (draftRow && typeof draftRow["_touched_fields"] === "string") {
|
|
6863
|
+
try {
|
|
6864
|
+
touchedFields = JSON.parse(draftRow["_touched_fields"]);
|
|
6865
|
+
} catch {
|
|
6866
|
+
}
|
|
6867
|
+
}
|
|
6868
|
+
const touchedSet = new Set(touchedFields);
|
|
6414
6869
|
const draftData = {};
|
|
6415
6870
|
for (const branch of seed.branches) {
|
|
6416
6871
|
if (mRelAliases.has(branch.alias)) continue;
|
|
6417
|
-
if (
|
|
6872
|
+
if (touchedSet.has(branch.alias)) {
|
|
6873
|
+
draftData[branch.alias] = draftRow[branch.alias] !== null ? deserializeFromDb2(branch, draftRow[branch.alias]) : null;
|
|
6874
|
+
} else if (draftRow[branch.alias] !== null) {
|
|
6418
6875
|
draftData[branch.alias] = deserializeFromDb2(branch, draftRow[branch.alias]);
|
|
6419
6876
|
}
|
|
6420
6877
|
}
|
|
@@ -6424,9 +6881,12 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6424
6881
|
);
|
|
6425
6882
|
const results = await this.database.batch(stmts);
|
|
6426
6883
|
for (let i = 0; i < mRelBranches.length; i++) {
|
|
6884
|
+
const alias = mRelBranches[i].alias;
|
|
6427
6885
|
const ids = (results[i].results ?? []).map((r) => r.target_id);
|
|
6428
|
-
if (
|
|
6429
|
-
draftData[
|
|
6886
|
+
if (touchedSet.has(alias)) {
|
|
6887
|
+
draftData[alias] = ids;
|
|
6888
|
+
} else if (ids.length > 0) {
|
|
6889
|
+
draftData[alias] = ids;
|
|
6430
6890
|
}
|
|
6431
6891
|
}
|
|
6432
6892
|
}
|
|
@@ -6435,6 +6895,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6435
6895
|
throw this.mapError(error, `getDraft(${seed.slug}, ${entryId})`);
|
|
6436
6896
|
}
|
|
6437
6897
|
}
|
|
6898
|
+
/** Checks whether a draft row exists for `entryId`. Always `false` if `seed.allowDrafts` is false. */
|
|
6438
6899
|
async hasDraft(seed, entryId) {
|
|
6439
6900
|
try {
|
|
6440
6901
|
if (!seed.allowDrafts) return false;
|
|
@@ -6445,6 +6906,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6445
6906
|
throw this.mapError(error, `hasDraft(${seed.slug}, ${entryId})`);
|
|
6446
6907
|
}
|
|
6447
6908
|
}
|
|
6909
|
+
/**
|
|
6910
|
+
* Promotes a draft to the live entry: validates all relation targets referenced by the
|
|
6911
|
+
* draft still exist, copies the draft's scalar fields into the live row, replaces the
|
|
6912
|
+
* live junction rows with the draft's, and deletes the draft — all as one batch.
|
|
6913
|
+
* A no-op if `seed.allowDrafts` is false.
|
|
6914
|
+
*
|
|
6915
|
+
* @throws EntryNotFoundError if no draft row exists for `entryId`.
|
|
6916
|
+
* @throws RelationTargetNotFoundError if any relation the draft references no longer exists.
|
|
6917
|
+
*/
|
|
6448
6918
|
async publishDraft(seed, entryId) {
|
|
6449
6919
|
try {
|
|
6450
6920
|
if (!seed.allowDrafts) return;
|
|
@@ -6452,56 +6922,85 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6452
6922
|
const liveTableName = this.getTableName(seed.slug);
|
|
6453
6923
|
const draftRow = await this.database.prepare(`SELECT * FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId).first();
|
|
6454
6924
|
if (!draftRow) {
|
|
6455
|
-
throw new
|
|
6925
|
+
throw new EntryNotFoundError8(`No draft found for ${entryId} in ${seed.slug}`);
|
|
6456
6926
|
}
|
|
6927
|
+
let touchedFields = [];
|
|
6928
|
+
if (draftRow && typeof draftRow["_touched_fields"] === "string") {
|
|
6929
|
+
try {
|
|
6930
|
+
touchedFields = JSON.parse(draftRow["_touched_fields"]);
|
|
6931
|
+
} catch {
|
|
6932
|
+
}
|
|
6933
|
+
}
|
|
6934
|
+
const touchedSet = new Set(touchedFields);
|
|
6457
6935
|
const { mRelBranches, mRelDraftIds } = await this.validatePublishDraftRelations(seed, draftRow, entryId);
|
|
6458
6936
|
const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
|
|
6459
6937
|
const updateClauses = [];
|
|
6460
6938
|
const queryBindings = [];
|
|
6461
6939
|
for (const branch of seed.branches) {
|
|
6462
6940
|
if (mRelAliases.has(branch.alias)) continue;
|
|
6463
|
-
if (
|
|
6941
|
+
if (touchedSet.has(branch.alias)) {
|
|
6942
|
+
updateClauses.push(`${branch.alias} = ?`);
|
|
6943
|
+
queryBindings.push(draftRow[branch.alias]);
|
|
6944
|
+
} else if (draftRow[branch.alias] !== null) {
|
|
6464
6945
|
updateClauses.push(`${branch.alias} = ?`);
|
|
6465
6946
|
queryBindings.push(draftRow[branch.alias]);
|
|
6466
6947
|
}
|
|
6467
6948
|
}
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6949
|
+
let updateSql = "";
|
|
6950
|
+
if (updateClauses.length > 0) {
|
|
6951
|
+
updateClauses.push("updated_at = (unixepoch())");
|
|
6952
|
+
updateSql = `UPDATE ${liveTableName} SET ${updateClauses.join(", ")} WHERE id = ?`;
|
|
6953
|
+
queryBindings.push(entryId);
|
|
6954
|
+
} else {
|
|
6955
|
+
updateSql = `UPDATE ${liveTableName} SET updated_at = (unixepoch()) WHERE id = ?`;
|
|
6956
|
+
queryBindings.push(entryId);
|
|
6957
|
+
}
|
|
6471
6958
|
const batchStmts = [
|
|
6472
6959
|
this.database.prepare(updateSql).bind(...queryBindings),
|
|
6473
6960
|
this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId)
|
|
6474
6961
|
];
|
|
6475
6962
|
for (const branch of mRelBranches) {
|
|
6476
|
-
const lt = jTable(seed.slug, branch.alias);
|
|
6477
6963
|
const jdt = jDraftTable(seed.slug, branch.alias);
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6964
|
+
if (touchedSet.has(branch.alias)) {
|
|
6965
|
+
const lt = jTable(seed.slug, branch.alias);
|
|
6966
|
+
const targetIds = mRelDraftIds.get(branch.alias) ?? [];
|
|
6967
|
+
batchStmts.push(
|
|
6968
|
+
this.database.prepare(`DELETE FROM ${lt} WHERE parent_id = ?`).bind(entryId),
|
|
6969
|
+
this.database.prepare(`DELETE FROM ${jdt} WHERE entry_id = ?`).bind(entryId),
|
|
6970
|
+
...this.buildJunctionInserts(seed.slug, entryId, branch.alias, targetIds)
|
|
6971
|
+
);
|
|
6972
|
+
} else {
|
|
6973
|
+
batchStmts.push(
|
|
6974
|
+
this.database.prepare(`DELETE FROM ${jdt} WHERE entry_id = ?`).bind(entryId)
|
|
6975
|
+
);
|
|
6976
|
+
}
|
|
6484
6977
|
}
|
|
6485
6978
|
await this.database.batch(batchStmts);
|
|
6486
6979
|
} catch (error) {
|
|
6487
|
-
if (error instanceof
|
|
6980
|
+
if (error instanceof EntryNotFoundError8) throw error;
|
|
6488
6981
|
if (error instanceof RelationTargetNotFoundError2) throw error;
|
|
6489
6982
|
throw this.mapError(error, `publishDraft(${seed.slug}, ${entryId})`);
|
|
6490
6983
|
}
|
|
6491
6984
|
}
|
|
6492
|
-
|
|
6985
|
+
/**
|
|
6986
|
+
* Applies the same set of field updates across many entries, chunked at 50 ids per batch
|
|
6987
|
+
* to bound per-call payload size. Per-id failures are collected rather than aborting the
|
|
6988
|
+
* whole operation — see {@link processBulkUpdateSingle} for failure classification.
|
|
6989
|
+
*/
|
|
6990
|
+
async bulkUpdate(seed, ids, fields) {
|
|
6493
6991
|
const CHUNK = 50;
|
|
6494
|
-
const tableName = this.getTableName(
|
|
6992
|
+
const tableName = this.getTableName(seed.slug);
|
|
6495
6993
|
let updated = 0;
|
|
6496
6994
|
const failed = [];
|
|
6497
6995
|
for (let offset = 0; offset < ids.length; offset += CHUNK) {
|
|
6498
6996
|
const chunk = ids.slice(offset, offset + CHUNK);
|
|
6499
|
-
const res = await this.processBulkUpdateChunk(
|
|
6997
|
+
const res = await this.processBulkUpdateChunk(seed, chunk, fields, tableName);
|
|
6500
6998
|
updated += res.updated;
|
|
6501
6999
|
failed.push(...res.failed);
|
|
6502
7000
|
}
|
|
6503
7001
|
return { updated, failed };
|
|
6504
7002
|
}
|
|
7003
|
+
/** Deletes the draft row for `entryId`, if any. A no-op if `seed.allowDrafts` is false. */
|
|
6505
7004
|
async deleteDraft(seed, entryId) {
|
|
6506
7005
|
try {
|
|
6507
7006
|
if (!seed.allowDrafts) return;
|
|
@@ -6511,6 +7010,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6511
7010
|
throw this.mapError(error, `deleteDraft(${seed.slug}, ${entryId})`);
|
|
6512
7011
|
}
|
|
6513
7012
|
}
|
|
7013
|
+
/**
|
|
7014
|
+
* Finds every pending draft across the given seeds, `UNION ALL`-ing one query per seed
|
|
7015
|
+
* so results span heterogeneous `content_{slug}_drafts` tables, then left-joins each
|
|
7016
|
+
* against `activity_logs` to attribute the most recent "draft saved" action.
|
|
7017
|
+
*
|
|
7018
|
+
* @param seeds Seeds to search; drafts are looked up per-seed since draft tables aren't unified.
|
|
7019
|
+
* @returns Draft summaries ordered by most-recently-updated first. `title` falls back to the
|
|
7020
|
+
* draft's own `id` if the display-name column is blank on both the draft and its live counterpart.
|
|
7021
|
+
*/
|
|
6514
7022
|
async findPendingDrafts(seeds) {
|
|
6515
7023
|
try {
|
|
6516
7024
|
if (seeds.length === 0) return [];
|
|
@@ -6520,6 +7028,8 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6520
7028
|
const draftTable = `content_${seed.slug}_drafts`;
|
|
6521
7029
|
const liveTable = `content_${seed.slug}`;
|
|
6522
7030
|
const titleCol = seed.displayNameAlias;
|
|
7031
|
+
const isScalar = seed.branches.some((b) => b.alias === titleCol && !(b.type === "relation" && b.multiple === true));
|
|
7032
|
+
const titleExpr = isScalar ? `COALESCE(d.${titleCol}, l.${titleCol})` : `COALESCE(l.slug, d.entry_id)`;
|
|
6523
7033
|
const seedLabel = seed.labelPlural ?? seed.label;
|
|
6524
7034
|
unionSelects.push(`
|
|
6525
7035
|
SELECT
|
|
@@ -6527,7 +7037,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6527
7037
|
? AS seed_label,
|
|
6528
7038
|
d.entry_id AS id,
|
|
6529
7039
|
d.updated_at AS updated_at,
|
|
6530
|
-
|
|
7040
|
+
${titleExpr} AS title
|
|
6531
7041
|
FROM ${draftTable} d
|
|
6532
7042
|
LEFT JOIN ${liveTable} l ON l.id = d.entry_id
|
|
6533
7043
|
`);
|
|
@@ -6574,9 +7084,48 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6574
7084
|
throw this.mapError(error, "findPendingDrafts");
|
|
6575
7085
|
}
|
|
6576
7086
|
}
|
|
7087
|
+
/**
|
|
7088
|
+
* Updates an entry's field values (optional) and its kanban board position atomically in one
|
|
7089
|
+
* batch: an optional main-row/junction update via {@link buildUpdateMainStmt}, plus an upsert
|
|
7090
|
+
* into `kanban_positions` keyed on `(seed_slug, entry_id, axis_branch_id)`.
|
|
7091
|
+
*
|
|
7092
|
+
* @param patch Field changes to apply alongside the drag/drop, or `null` if only position changed.
|
|
7093
|
+
* @param position Opaque fractional-index (or similar) position value used for ordering within the axis bucket.
|
|
7094
|
+
* @param axisBranchId Identifies which branch/column axis this position is relative to (e.g. status column).
|
|
7095
|
+
* @remarks Unlike {@link create}/{@link update}, this method does not invoke `beforeUpdate`/`afterUpdate`
|
|
7096
|
+
* lifecycle hooks — the `_ctx.actor` parameter is currently unused. Kanban drag-and-drop writes
|
|
7097
|
+
* therefore bypass hook-based side effects (e.g. audit logging) that `update()` would trigger.
|
|
7098
|
+
*/
|
|
7099
|
+
async updateWithKanbanPosition(seed, id, patch, position, axisBranchId, _ctx) {
|
|
7100
|
+
try {
|
|
7101
|
+
const stmts = [];
|
|
7102
|
+
if (patch) {
|
|
7103
|
+
const { stmt, junctionUpdates } = this.buildUpdateMainStmt(seed, id, patch);
|
|
7104
|
+
if (stmt) stmts.push(stmt);
|
|
7105
|
+
for (const branch of junctionUpdates) {
|
|
7106
|
+
const jt = jTable(seed.slug, branch.alias);
|
|
7107
|
+
const value = patch[branch.alias] ?? [];
|
|
7108
|
+
stmts.push(this.database.prepare(`DELETE FROM ${jt} WHERE parent_id = ?`).bind(id));
|
|
7109
|
+
stmts.push(...this.buildJunctionInserts(seed.slug, id, branch.alias, value));
|
|
7110
|
+
}
|
|
7111
|
+
}
|
|
7112
|
+
stmts.push(
|
|
7113
|
+
this.database.prepare(`
|
|
7114
|
+
INSERT INTO kanban_positions (seed_slug, entry_id, axis_branch_id, position, updated_at)
|
|
7115
|
+
VALUES (?, ?, ?, ?, unixepoch())
|
|
7116
|
+
ON CONFLICT(seed_slug, entry_id, axis_branch_id)
|
|
7117
|
+
DO UPDATE SET position = excluded.position, updated_at = excluded.updated_at
|
|
7118
|
+
`).bind(seed.slug, id, axisBranchId, position)
|
|
7119
|
+
);
|
|
7120
|
+
await this.database.batch(stmts);
|
|
7121
|
+
return { success: true };
|
|
7122
|
+
} catch (error) {
|
|
7123
|
+
throw this.mapError(error, `updateWithKanbanPosition(${seed.slug}, ${id})`);
|
|
7124
|
+
}
|
|
7125
|
+
}
|
|
6577
7126
|
};
|
|
6578
7127
|
|
|
6579
|
-
// src/shared/idempotency.repository.d1.ts
|
|
7128
|
+
// src/shared/db/repositories/idempotency.repository.d1.ts
|
|
6580
7129
|
var D1IdempotencyRepository = class extends BaseD1Repository {
|
|
6581
7130
|
/**
|
|
6582
7131
|
* Looks up an idempotency record by its key.
|
|
@@ -6627,7 +7176,7 @@ var D1IdempotencyRepository = class extends BaseD1Repository {
|
|
|
6627
7176
|
}
|
|
6628
7177
|
};
|
|
6629
7178
|
|
|
6630
|
-
// src/shared/media.repository.d1.ts
|
|
7179
|
+
// src/shared/db/repositories/media.repository.d1.ts
|
|
6631
7180
|
var D1MediaRepository = class {
|
|
6632
7181
|
constructor(database) {
|
|
6633
7182
|
this.database = database;
|
|
@@ -6683,7 +7232,7 @@ var D1MediaRepository = class {
|
|
|
6683
7232
|
}
|
|
6684
7233
|
};
|
|
6685
7234
|
|
|
6686
|
-
// src/shared/system-stats.repository.d1.ts
|
|
7235
|
+
// src/shared/db/repositories/system-stats.repository.d1.ts
|
|
6687
7236
|
var D1SystemStatsRepository = class {
|
|
6688
7237
|
constructor(database) {
|
|
6689
7238
|
this.database = database;
|
|
@@ -6722,7 +7271,7 @@ var D1SystemStatsRepository = class {
|
|
|
6722
7271
|
}
|
|
6723
7272
|
};
|
|
6724
7273
|
|
|
6725
|
-
// src/shared/d1-user.repository.ts
|
|
7274
|
+
// src/shared/db/repositories/d1-user.repository.ts
|
|
6726
7275
|
function rowToRecord(row) {
|
|
6727
7276
|
return {
|
|
6728
7277
|
id: row.id,
|
|
@@ -6789,7 +7338,7 @@ var D1UserRepository = class {
|
|
|
6789
7338
|
}
|
|
6790
7339
|
};
|
|
6791
7340
|
|
|
6792
|
-
// src/shared/d1-session.repository.ts
|
|
7341
|
+
// src/shared/db/repositories/d1-session.repository.ts
|
|
6793
7342
|
function rowToRecord2(row) {
|
|
6794
7343
|
return {
|
|
6795
7344
|
id: row.id,
|
|
@@ -6848,7 +7397,7 @@ var D1SessionRepository = class {
|
|
|
6848
7397
|
}
|
|
6849
7398
|
};
|
|
6850
7399
|
|
|
6851
|
-
// src/shared/d1-password-reset-token.repository.ts
|
|
7400
|
+
// src/shared/db/repositories/d1-password-reset-token.repository.ts
|
|
6852
7401
|
var D1PasswordResetTokenRepository = class {
|
|
6853
7402
|
constructor(db, idGenerator) {
|
|
6854
7403
|
this.db = db;
|
|
@@ -6878,7 +7427,7 @@ var D1PasswordResetTokenRepository = class {
|
|
|
6878
7427
|
}
|
|
6879
7428
|
};
|
|
6880
7429
|
|
|
6881
|
-
// src/shared/d1-activity-log.repository.ts
|
|
7430
|
+
// src/shared/db/repositories/d1-activity-log.repository.ts
|
|
6882
7431
|
var D1ActivityLogRepository = class {
|
|
6883
7432
|
constructor(database) {
|
|
6884
7433
|
this.database = database;
|
|
@@ -6941,7 +7490,7 @@ function parseDetails(raw) {
|
|
|
6941
7490
|
}
|
|
6942
7491
|
}
|
|
6943
7492
|
|
|
6944
|
-
// src/shared/d1-notification.repository.ts
|
|
7493
|
+
// src/shared/db/repositories/d1-notification.repository.ts
|
|
6945
7494
|
var D1NotificationRepository = class {
|
|
6946
7495
|
constructor(database, clock, idGenerator) {
|
|
6947
7496
|
this.database = database;
|
|
@@ -7005,7 +7554,7 @@ function mapRowToRecord2(row) {
|
|
|
7005
7554
|
};
|
|
7006
7555
|
}
|
|
7007
7556
|
|
|
7008
|
-
// src/shared/d1-widget.repository.ts
|
|
7557
|
+
// src/shared/db/repositories/d1-widget.repository.ts
|
|
7009
7558
|
import {
|
|
7010
7559
|
isDateRange
|
|
7011
7560
|
} from "@beechcms/core";
|
|
@@ -7079,9 +7628,10 @@ var D1WidgetRepository = class {
|
|
|
7079
7628
|
const bindings = [];
|
|
7080
7629
|
const trimmedSearch = options.search?.trim() ?? "";
|
|
7081
7630
|
if (trimmedSearch.length > 0) {
|
|
7631
|
+
const escapedSearch = trimmedSearch.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
7082
7632
|
const displayColumn = this.resolveColumnExpression(seed, seed.displayNameAlias);
|
|
7083
|
-
conditions.push(`${displayColumn} LIKE
|
|
7084
|
-
bindings.push(`%${
|
|
7633
|
+
conditions.push(`${displayColumn} LIKE ? ESCAPE '\\'`);
|
|
7634
|
+
bindings.push(`%${escapedSearch}%`);
|
|
7085
7635
|
}
|
|
7086
7636
|
for (const filter of options.filters ?? []) {
|
|
7087
7637
|
this.appendFilterCondition(seed, filter, conditions, bindings);
|
|
@@ -7282,7 +7832,7 @@ var D1WidgetRepository = class {
|
|
|
7282
7832
|
}
|
|
7283
7833
|
};
|
|
7284
7834
|
|
|
7285
|
-
// src/shared/d1-search.repository.ts
|
|
7835
|
+
// src/shared/db/repositories/d1-search.repository.ts
|
|
7286
7836
|
var EMPTY_QUERY_ERROR = "EMPTY_QUERY";
|
|
7287
7837
|
var D1SearchRepository = class {
|
|
7288
7838
|
constructor(database) {
|
|
@@ -7342,7 +7892,7 @@ function mapFtsRowToResultRow(row) {
|
|
|
7342
7892
|
};
|
|
7343
7893
|
}
|
|
7344
7894
|
|
|
7345
|
-
// src/shared/d1-analytics.repository.ts
|
|
7895
|
+
// src/shared/db/repositories/d1-analytics.repository.ts
|
|
7346
7896
|
var SECONDS_PER_DAY2 = 86400;
|
|
7347
7897
|
var D1AnalyticsRepository = class {
|
|
7348
7898
|
constructor(database, clock) {
|
|
@@ -7394,7 +7944,7 @@ var D1AnalyticsRepository = class {
|
|
|
7394
7944
|
}
|
|
7395
7945
|
};
|
|
7396
7946
|
|
|
7397
|
-
// src/shared/d1-content-scan.repository.ts
|
|
7947
|
+
// src/shared/db/repositories/d1-content-scan.repository.ts
|
|
7398
7948
|
var D1ContentScanRepository = class {
|
|
7399
7949
|
constructor(db) {
|
|
7400
7950
|
this.db = db;
|
|
@@ -7423,7 +7973,7 @@ var D1ContentScanRepository = class {
|
|
|
7423
7973
|
}
|
|
7424
7974
|
};
|
|
7425
7975
|
|
|
7426
|
-
// src/shared/site-settings.repository.d1.ts
|
|
7976
|
+
// src/shared/db/repositories/site-settings.repository.d1.ts
|
|
7427
7977
|
var DEFAULTS = {
|
|
7428
7978
|
siteTitle: "Beech CMS",
|
|
7429
7979
|
defaultLanguage: "en",
|
|
@@ -7463,7 +8013,7 @@ var D1SiteSettingsRepository = class {
|
|
|
7463
8013
|
}
|
|
7464
8014
|
};
|
|
7465
8015
|
|
|
7466
|
-
// src/shared/demo-data-sql.ts
|
|
8016
|
+
// src/shared/db/migrations/demo-data-sql.ts
|
|
7467
8017
|
var DEMO_DATA_SQL = `
|
|
7468
8018
|
INSERT OR IGNORE INTO content_clienti (id, slug, status, name, email, company, tier, account_status, mrr, created_at, updated_at)
|
|
7469
8019
|
VALUES
|
|
@@ -8442,7 +8992,7 @@ VALUES
|
|
|
8442
8992
|
|
|
8443
8993
|
`;
|
|
8444
8994
|
|
|
8445
|
-
// src/shared/demo-data.repository.d1.ts
|
|
8995
|
+
// src/shared/db/repositories/demo-data.repository.d1.ts
|
|
8446
8996
|
var D1DemoDataRepository = class {
|
|
8447
8997
|
constructor(db) {
|
|
8448
8998
|
this.db = db;
|
|
@@ -8454,7 +9004,7 @@ var D1DemoDataRepository = class {
|
|
|
8454
9004
|
}
|
|
8455
9005
|
};
|
|
8456
9006
|
|
|
8457
|
-
// src/shared/d1-setup-checklist.repository.ts
|
|
9007
|
+
// src/shared/db/repositories/d1-setup-checklist.repository.ts
|
|
8458
9008
|
var D1SetupChecklistRepository = class {
|
|
8459
9009
|
constructor(db) {
|
|
8460
9010
|
this.db = db;
|
|
@@ -8474,7 +9024,8 @@ var D1SetupChecklistRepository = class {
|
|
|
8474
9024
|
}
|
|
8475
9025
|
};
|
|
8476
9026
|
|
|
8477
|
-
// src/shared/seed-layout.repository.d1.ts
|
|
9027
|
+
// src/shared/db/repositories/seed-layout.repository.d1.ts
|
|
9028
|
+
import { seedViewConfigSchema as seedViewConfigSchema2 } from "@beechcms/core";
|
|
8478
9029
|
var D1SeedLayoutRepository = class {
|
|
8479
9030
|
constructor(db) {
|
|
8480
9031
|
this.db = db;
|
|
@@ -8516,9 +9067,25 @@ var D1SeedLayoutRepository = class {
|
|
|
8516
9067
|
async remove(slug) {
|
|
8517
9068
|
await this.db.prepare("DELETE FROM seed_layouts WHERE slug = ?").bind(slug).run();
|
|
8518
9069
|
}
|
|
9070
|
+
async getViewConfig(slug) {
|
|
9071
|
+
const row = await this.db.prepare("SELECT view_config FROM seed_layouts WHERE slug = ?").bind(slug).first();
|
|
9072
|
+
if (!row?.view_config) return null;
|
|
9073
|
+
return seedViewConfigSchema2.parse(JSON.parse(row.view_config));
|
|
9074
|
+
}
|
|
9075
|
+
async setViewConfig(slug, config, updatedBy) {
|
|
9076
|
+
const json = JSON.stringify(config);
|
|
9077
|
+
await this.db.prepare(`
|
|
9078
|
+
INSERT INTO seed_layouts (slug, layout, view_config, updated_at, updated_by)
|
|
9079
|
+
VALUES (?, '{}', ?, unixepoch(), ?)
|
|
9080
|
+
ON CONFLICT(slug) DO UPDATE SET
|
|
9081
|
+
view_config = excluded.view_config,
|
|
9082
|
+
updated_at = excluded.updated_at,
|
|
9083
|
+
updated_by = excluded.updated_by
|
|
9084
|
+
`).bind(slug, json, updatedBy).run();
|
|
9085
|
+
}
|
|
8519
9086
|
};
|
|
8520
9087
|
|
|
8521
|
-
// src/shared/dashboard-layout.repository.d1.ts
|
|
9088
|
+
// src/shared/db/repositories/dashboard-layout.repository.d1.ts
|
|
8522
9089
|
var D1DashboardLayoutRepository = class {
|
|
8523
9090
|
constructor(db) {
|
|
8524
9091
|
this.db = db;
|
|
@@ -8559,7 +9126,7 @@ var D1DashboardLayoutRepository = class {
|
|
|
8559
9126
|
}
|
|
8560
9127
|
};
|
|
8561
9128
|
|
|
8562
|
-
// src/shared/seed.repository.d1.ts
|
|
9129
|
+
// src/shared/db/repositories/seed.repository.d1.ts
|
|
8563
9130
|
var D1SeedRepository = class {
|
|
8564
9131
|
constructor(db) {
|
|
8565
9132
|
this.db = db;
|
|
@@ -8634,7 +9201,7 @@ var D1SeedRepository = class {
|
|
|
8634
9201
|
}
|
|
8635
9202
|
};
|
|
8636
9203
|
|
|
8637
|
-
// src/shared/schema-mutator.d1.ts
|
|
9204
|
+
// src/shared/db/migrations/schema-mutator.d1.ts
|
|
8638
9205
|
var D1SchemaMutator = class _D1SchemaMutator {
|
|
8639
9206
|
constructor(db) {
|
|
8640
9207
|
this.db = db;
|
|
@@ -8677,13 +9244,59 @@ var D1SchemaMutator = class _D1SchemaMutator {
|
|
|
8677
9244
|
if (statements.length === 0) return;
|
|
8678
9245
|
await this.db.batch(statements.map((s) => this.db.prepare(s)));
|
|
8679
9246
|
}
|
|
9247
|
+
async fetchRows(table, columns) {
|
|
9248
|
+
_D1SchemaMutator.assertIdentifier(table);
|
|
9249
|
+
for (const col of columns) _D1SchemaMutator.assertIdentifier(col);
|
|
9250
|
+
const colList = columns.join(", ");
|
|
9251
|
+
const rs = await this.db.prepare(`SELECT ${colList} FROM ${table}`).all();
|
|
9252
|
+
return rs.results ?? [];
|
|
9253
|
+
}
|
|
9254
|
+
};
|
|
9255
|
+
|
|
9256
|
+
// src/shared/db/repositories/kanban-position.repository.d1.ts
|
|
9257
|
+
var D1KanbanPositionRepository = class {
|
|
9258
|
+
constructor(db) {
|
|
9259
|
+
this.db = db;
|
|
9260
|
+
}
|
|
9261
|
+
db;
|
|
9262
|
+
async getColumn(seedSlug, axisBranchId) {
|
|
9263
|
+
const rs = await this.db.prepare("SELECT entry_id, position FROM kanban_positions WHERE seed_slug = ? AND axis_branch_id = ?").bind(seedSlug, axisBranchId).all();
|
|
9264
|
+
const map = /* @__PURE__ */ new Map();
|
|
9265
|
+
for (const row of rs.results ?? []) {
|
|
9266
|
+
map.set(row.entry_id, row.position);
|
|
9267
|
+
}
|
|
9268
|
+
return map;
|
|
9269
|
+
}
|
|
9270
|
+
async setPosition(seedSlug, entryId, axisBranchId, position) {
|
|
9271
|
+
await this.db.prepare(`
|
|
9272
|
+
INSERT INTO kanban_positions (seed_slug, entry_id, axis_branch_id, position, updated_at)
|
|
9273
|
+
VALUES (?, ?, ?, ?, unixepoch())
|
|
9274
|
+
ON CONFLICT(seed_slug, entry_id, axis_branch_id)
|
|
9275
|
+
DO UPDATE SET position = excluded.position, updated_at = excluded.updated_at
|
|
9276
|
+
`).bind(seedSlug, entryId, axisBranchId, position).run();
|
|
9277
|
+
}
|
|
9278
|
+
async remove(seedSlug, entryId, axisBranchId) {
|
|
9279
|
+
await this.db.prepare("DELETE FROM kanban_positions WHERE seed_slug = ? AND entry_id = ? AND axis_branch_id = ?").bind(seedSlug, entryId, axisBranchId).run();
|
|
9280
|
+
}
|
|
9281
|
+
async rebalance(seedSlug, axisBranchId, ordered) {
|
|
9282
|
+
if (ordered.length === 0) return;
|
|
9283
|
+
const stmts = ordered.map(
|
|
9284
|
+
(r) => this.db.prepare(`
|
|
9285
|
+
INSERT INTO kanban_positions (seed_slug, entry_id, axis_branch_id, position, updated_at)
|
|
9286
|
+
VALUES (?, ?, ?, ?, unixepoch())
|
|
9287
|
+
ON CONFLICT(seed_slug, entry_id, axis_branch_id)
|
|
9288
|
+
DO UPDATE SET position = excluded.position, updated_at = excluded.updated_at
|
|
9289
|
+
`).bind(seedSlug, r.entryId, axisBranchId, r.position)
|
|
9290
|
+
);
|
|
9291
|
+
await this.db.batch(stmts);
|
|
9292
|
+
}
|
|
8680
9293
|
};
|
|
8681
9294
|
|
|
8682
9295
|
// src/middleware/repository.middleware.ts
|
|
8683
9296
|
import { SystemClock as SystemClock2, SystemIdGenerator } from "@beechcms/core";
|
|
8684
9297
|
import { NoOpScheduler } from "@beechcms/core";
|
|
8685
9298
|
|
|
8686
|
-
// src/shared/automations.repository.d1.ts
|
|
9299
|
+
// src/shared/db/repositories/automations.repository.d1.ts
|
|
8687
9300
|
var D1AutomationRepository = class {
|
|
8688
9301
|
constructor(db) {
|
|
8689
9302
|
this.db = db;
|
|
@@ -8769,7 +9382,7 @@ function rowToAutomation(row) {
|
|
|
8769
9382
|
};
|
|
8770
9383
|
}
|
|
8771
9384
|
|
|
8772
|
-
// src/shared/execution-context-scheduler.ts
|
|
9385
|
+
// src/shared/services/scheduler/execution-context-scheduler.ts
|
|
8773
9386
|
var ExecutionContextScheduler = class {
|
|
8774
9387
|
constructor(ctx) {
|
|
8775
9388
|
this.ctx = ctx;
|
|
@@ -8828,6 +9441,7 @@ var repositoryMiddleware = (overrides) => {
|
|
|
8828
9441
|
context.set("seedRepository", overrides?.seedRepository ?? new D1SeedRepository(database));
|
|
8829
9442
|
context.set("schemaMutator", overrides?.schemaMutator ?? new D1SchemaMutator(database));
|
|
8830
9443
|
context.set("dashboardLayoutRepository", overrides?.dashboardLayoutRepository ?? new D1DashboardLayoutRepository(database));
|
|
9444
|
+
context.set("kanbanPositionRepository", overrides?.kanbanPositionRepository ?? new D1KanbanPositionRepository(database));
|
|
8831
9445
|
await next();
|
|
8832
9446
|
});
|
|
8833
9447
|
};
|
|
@@ -8835,7 +9449,7 @@ var repositoryMiddleware = (overrides) => {
|
|
|
8835
9449
|
// src/middleware/seed-registry.middleware.ts
|
|
8836
9450
|
import { createMiddleware as createMiddleware3 } from "hono/factory";
|
|
8837
9451
|
|
|
8838
|
-
// src/shared/seed-registry-cache.ts
|
|
9452
|
+
// src/shared/services/cache/seed-registry-cache.ts
|
|
8839
9453
|
import { SeedRegistry, buildBackrefMap } from "@beechcms/core";
|
|
8840
9454
|
var cache = null;
|
|
8841
9455
|
var TTL_MS = 5e3;
|
|
@@ -8895,30 +9509,12 @@ var S3Bucket = class {
|
|
|
8895
9509
|
const command = new PutObjectCommand({
|
|
8896
9510
|
Bucket: this.bucketName,
|
|
8897
9511
|
Key: key,
|
|
8898
|
-
Body: body instanceof ReadableStream ?
|
|
9512
|
+
Body: body instanceof ReadableStream ? body : new Uint8Array(body),
|
|
8899
9513
|
ContentType: options?.contentType,
|
|
8900
9514
|
Metadata: options?.metadata
|
|
8901
9515
|
});
|
|
8902
9516
|
await this.client.send(command);
|
|
8903
9517
|
}
|
|
8904
|
-
async streamToUint8Array(stream) {
|
|
8905
|
-
const reader = stream.getReader();
|
|
8906
|
-
const chunks = [];
|
|
8907
|
-
let totalLength = 0;
|
|
8908
|
-
while (true) {
|
|
8909
|
-
const { done, value } = await reader.read();
|
|
8910
|
-
if (done) break;
|
|
8911
|
-
chunks.push(value);
|
|
8912
|
-
totalLength += value.length;
|
|
8913
|
-
}
|
|
8914
|
-
const result = new Uint8Array(totalLength);
|
|
8915
|
-
let offset = 0;
|
|
8916
|
-
for (const chunk of chunks) {
|
|
8917
|
-
result.set(chunk, offset);
|
|
8918
|
-
offset += chunk.length;
|
|
8919
|
-
}
|
|
8920
|
-
return result;
|
|
8921
|
-
}
|
|
8922
9518
|
async get(key) {
|
|
8923
9519
|
try {
|
|
8924
9520
|
const command = new GetObjectCommand({
|
|
@@ -8967,10 +9563,11 @@ var S3Bucket = class {
|
|
|
8967
9563
|
}
|
|
8968
9564
|
}
|
|
8969
9565
|
getUrl(key) {
|
|
9566
|
+
const encodedKey = key.split("/").map(encodeURIComponent).join("/");
|
|
8970
9567
|
if (this.cdnUrl) {
|
|
8971
|
-
return `${this.cdnUrl}/${
|
|
9568
|
+
return `${this.cdnUrl}/${encodedKey}`;
|
|
8972
9569
|
}
|
|
8973
|
-
return `${this.baseUrl}/api/media/${
|
|
9570
|
+
return `${this.baseUrl}/api/media/${encodedKey}`;
|
|
8974
9571
|
}
|
|
8975
9572
|
async getTotalSize() {
|
|
8976
9573
|
let total = 0;
|
|
@@ -8992,7 +9589,8 @@ var S3Bucket = class {
|
|
|
8992
9589
|
const command = new PutObjectCommand({
|
|
8993
9590
|
Bucket: this.bucketName,
|
|
8994
9591
|
Key: key,
|
|
8995
|
-
ContentType: options.contentType
|
|
9592
|
+
ContentType: options.contentType,
|
|
9593
|
+
ContentLength: options.contentLength
|
|
8996
9594
|
});
|
|
8997
9595
|
return getSignedUrl(this.client, command, { expiresIn: options.expiresIn });
|
|
8998
9596
|
}
|
|
@@ -9022,9 +9620,21 @@ var S3Bucket = class {
|
|
|
9022
9620
|
};
|
|
9023
9621
|
|
|
9024
9622
|
// src/shared/storage/factory.ts
|
|
9623
|
+
import { HTTPException as HTTPException2 } from "hono/http-exception";
|
|
9025
9624
|
var NullBucket = class {
|
|
9026
9625
|
fail() {
|
|
9027
|
-
throw new
|
|
9626
|
+
throw new HTTPException2(503, {
|
|
9627
|
+
res: new Response(
|
|
9628
|
+
JSON.stringify({
|
|
9629
|
+
error: "storage_not_configured",
|
|
9630
|
+
message: "Storage is not configured. Please set R2 credentials or run MinIO."
|
|
9631
|
+
}),
|
|
9632
|
+
{
|
|
9633
|
+
status: 503,
|
|
9634
|
+
headers: { "Content-Type": "application/json" }
|
|
9635
|
+
}
|
|
9636
|
+
)
|
|
9637
|
+
});
|
|
9028
9638
|
}
|
|
9029
9639
|
put() {
|
|
9030
9640
|
return this.fail();
|
|
@@ -9081,7 +9691,7 @@ var storageMiddleware = (overrides) => {
|
|
|
9081
9691
|
});
|
|
9082
9692
|
};
|
|
9083
9693
|
|
|
9084
|
-
// src/shared/in-memory-seed.repository.ts
|
|
9694
|
+
// src/shared/db/repositories/in-memory-seed.repository.ts
|
|
9085
9695
|
var InMemorySeedRepository = class {
|
|
9086
9696
|
seeds;
|
|
9087
9697
|
version = 1;
|
|
@@ -9127,7 +9737,7 @@ var InMemorySeedRepository = class {
|
|
|
9127
9737
|
import { createMiddleware as createMiddleware5 } from "hono/factory";
|
|
9128
9738
|
import { SystemClock as SystemClock3 } from "@beechcms/core";
|
|
9129
9739
|
|
|
9130
|
-
// src/auth/
|
|
9740
|
+
// src/auth/providers/hash.provider.ts
|
|
9131
9741
|
import bcrypt from "bcryptjs";
|
|
9132
9742
|
var BCRYPT_SALT_ROUNDS = 10;
|
|
9133
9743
|
var BcryptHashProvider = class {
|
|
@@ -9143,7 +9753,7 @@ var BcryptHashProvider = class {
|
|
|
9143
9753
|
}
|
|
9144
9754
|
};
|
|
9145
9755
|
|
|
9146
|
-
// src/auth/
|
|
9756
|
+
// src/auth/providers/jwt-token.service.ts
|
|
9147
9757
|
import { SignJWT, jwtVerify } from "jose";
|
|
9148
9758
|
var DEFAULT_TOKEN_TTL_SECONDS = 900;
|
|
9149
9759
|
var DEFAULT_ALGORITHM = "HS256";
|
|
@@ -9175,8 +9785,12 @@ var JoseTokenService = class {
|
|
|
9175
9785
|
const { payload } = await jwtVerify(token, this.secretBytes, {
|
|
9176
9786
|
algorithms: [algorithm],
|
|
9177
9787
|
issuer: this.config.issuer,
|
|
9178
|
-
audience: this.config.audience
|
|
9788
|
+
audience: this.config.audience,
|
|
9789
|
+
currentDate: new Date(this.clock.nowSeconds() * 1e3)
|
|
9179
9790
|
});
|
|
9791
|
+
if (typeof payload.sub !== "string" || payload.sub === "") {
|
|
9792
|
+
return null;
|
|
9793
|
+
}
|
|
9180
9794
|
return payload;
|
|
9181
9795
|
} catch {
|
|
9182
9796
|
return null;
|
|
@@ -9214,13 +9828,23 @@ import { createMiddleware as createMiddleware6 } from "hono/factory";
|
|
|
9214
9828
|
|
|
9215
9829
|
// src/rate-limit/cloudflare-rate-limiter.ts
|
|
9216
9830
|
var CloudflareRateLimiter = class {
|
|
9217
|
-
constructor(binding) {
|
|
9831
|
+
constructor(binding, options = {}) {
|
|
9218
9832
|
this.binding = binding;
|
|
9833
|
+
this.options = options;
|
|
9219
9834
|
}
|
|
9220
9835
|
binding;
|
|
9836
|
+
options;
|
|
9221
9837
|
async checkLimit(key) {
|
|
9222
|
-
|
|
9223
|
-
|
|
9838
|
+
try {
|
|
9839
|
+
const res = await this.binding.limit({ key });
|
|
9840
|
+
return {
|
|
9841
|
+
isAllowed: res.success,
|
|
9842
|
+
retryAfterSeconds: res.retryAfterSeconds ?? res.retryAfter
|
|
9843
|
+
};
|
|
9844
|
+
} catch (error) {
|
|
9845
|
+
console.warn(`Rate limiter binding error for key "${key}":`, error);
|
|
9846
|
+
return { isAllowed: !this.options.failClosed };
|
|
9847
|
+
}
|
|
9224
9848
|
}
|
|
9225
9849
|
};
|
|
9226
9850
|
|
|
@@ -9235,13 +9859,20 @@ var NoOpRateLimiter = class {
|
|
|
9235
9859
|
var NO_OP = new NoOpRateLimiter();
|
|
9236
9860
|
function buildDefaultRegistry(env) {
|
|
9237
9861
|
const isDev = env.ENV === "development" && !(typeof globalThis.process !== "undefined" && globalThis.process.env?.VITEST);
|
|
9862
|
+
const isProd = env.ENV === "production";
|
|
9863
|
+
const getLimiter = (bindingName, binding, options) => {
|
|
9864
|
+
if (isProd && !binding) {
|
|
9865
|
+
throw new Error(`Missing rate limiter binding: ${bindingName} is required in production environment.`);
|
|
9866
|
+
}
|
|
9867
|
+
return !isDev && binding ? new CloudflareRateLimiter(binding, options) : NO_OP;
|
|
9868
|
+
};
|
|
9238
9869
|
const limiters = {
|
|
9239
|
-
login:
|
|
9240
|
-
tokenRefresh:
|
|
9241
|
-
forgotPassword:
|
|
9242
|
-
resetPassword:
|
|
9243
|
-
publicApiRead:
|
|
9244
|
-
publicApiWrite:
|
|
9870
|
+
login: getLimiter("LOGIN_RATE_LIMITER", env.LOGIN_RATE_LIMITER, { failClosed: true }),
|
|
9871
|
+
tokenRefresh: getLimiter("REFRESH_RATE_LIMITER", env.REFRESH_RATE_LIMITER, { failClosed: true }),
|
|
9872
|
+
forgotPassword: getLimiter("FORGOT_PASSWORD_RATE_LIMITER", env.FORGOT_PASSWORD_RATE_LIMITER, { failClosed: true }),
|
|
9873
|
+
resetPassword: getLimiter("RESET_PASSWORD_RATE_LIMITER", env.RESET_PASSWORD_RATE_LIMITER, { failClosed: true }),
|
|
9874
|
+
publicApiRead: getLimiter("PUBLIC_READ_RATE_LIMITER", env.PUBLIC_READ_RATE_LIMITER, { failClosed: false }),
|
|
9875
|
+
publicApiWrite: getLimiter("PUBLIC_WRITE_RATE_LIMITER", env.PUBLIC_WRITE_RATE_LIMITER, { failClosed: false })
|
|
9245
9876
|
};
|
|
9246
9877
|
return { getLimiter: (name) => limiters[name] };
|
|
9247
9878
|
}
|
|
@@ -9257,7 +9888,7 @@ var rateLimiterMiddleware = (overrides) => {
|
|
|
9257
9888
|
import { createMiddleware as createMiddleware7 } from "hono/factory";
|
|
9258
9889
|
import { SystemClock as SystemClock4, SystemIdGenerator as SystemIdGenerator2 } from "@beechcms/core";
|
|
9259
9890
|
|
|
9260
|
-
// src/shared/d1-activity-logger.ts
|
|
9891
|
+
// src/shared/services/activity-log/d1-activity-logger.ts
|
|
9261
9892
|
var ACTIVITY_LOG_INSERT_SQL = `INSERT INTO activity_logs
|
|
9262
9893
|
(id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
|
|
9263
9894
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
|
@@ -9302,7 +9933,7 @@ var D1ActivityLogger = class {
|
|
|
9302
9933
|
}
|
|
9303
9934
|
};
|
|
9304
9935
|
|
|
9305
|
-
// src/shared/background-notification-service.ts
|
|
9936
|
+
// src/shared/services/notification/background-notification-service.ts
|
|
9306
9937
|
var DEFAULT_NOTIFICATION_TYPE = "info";
|
|
9307
9938
|
var BackgroundNotificationService = class {
|
|
9308
9939
|
constructor(notificationRepository, scheduleBackgroundTask) {
|
|
@@ -9332,7 +9963,7 @@ var BackgroundNotificationService = class {
|
|
|
9332
9963
|
}
|
|
9333
9964
|
};
|
|
9334
9965
|
|
|
9335
|
-
// src/shared/qstash-notification-service.ts
|
|
9966
|
+
// src/shared/services/notification/qstash-notification-service.ts
|
|
9336
9967
|
import { Client } from "@upstash/qstash";
|
|
9337
9968
|
var QStashNotificationService = class {
|
|
9338
9969
|
constructor(token, appUrl, scheduleBackgroundTask, qstashUrl) {
|
|
@@ -9405,7 +10036,7 @@ var observabilityMiddleware = (overrides) => {
|
|
|
9405
10036
|
import { createMiddleware as createMiddleware8 } from "hono/factory";
|
|
9406
10037
|
import { SystemClock as SystemClock5, SystemIdGenerator as SystemIdGenerator3 } from "@beechcms/core";
|
|
9407
10038
|
|
|
9408
|
-
// src/shared/cloudflare-queue-service.ts
|
|
10039
|
+
// src/shared/services/queue/cloudflare-queue-service.ts
|
|
9409
10040
|
var CloudflareQueueService = class {
|
|
9410
10041
|
constructor(queue) {
|
|
9411
10042
|
this.queue = queue;
|
|
@@ -9420,7 +10051,7 @@ var CloudflareQueueService = class {
|
|
|
9420
10051
|
}
|
|
9421
10052
|
};
|
|
9422
10053
|
|
|
9423
|
-
// src/shared/in-memory-queue-service.ts
|
|
10054
|
+
// src/shared/services/queue/in-memory-queue-service.ts
|
|
9424
10055
|
var InMemoryQueueService = class {
|
|
9425
10056
|
constructor(jobs, context, scheduleBackgroundTask) {
|
|
9426
10057
|
this.jobs = jobs;
|
|
@@ -9547,15 +10178,15 @@ function createBeechApp(config) {
|
|
|
9547
10178
|
try {
|
|
9548
10179
|
const originUrl = new URL(origin);
|
|
9549
10180
|
const requestUrl = new URL(context.req.url);
|
|
9550
|
-
if (originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
|
|
10181
|
+
if (originUrl.protocol === requestUrl.protocol && originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
|
|
9551
10182
|
return origin;
|
|
9552
10183
|
}
|
|
9553
10184
|
} catch {
|
|
9554
10185
|
}
|
|
9555
10186
|
return null;
|
|
9556
10187
|
},
|
|
9557
|
-
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
9558
|
-
allowHeaders: ["Content-Type", "Authorization", "X-API-Key"],
|
|
10188
|
+
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
10189
|
+
allowHeaders: ["Content-Type", "Authorization", "X-API-Key", "Idempotency-Key"],
|
|
9559
10190
|
credentials: true
|
|
9560
10191
|
})(context, next);
|
|
9561
10192
|
});
|
|
@@ -9601,7 +10232,13 @@ function createBeechApp(config) {
|
|
|
9601
10232
|
if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400);
|
|
9602
10233
|
const clientIp = getClientIp(context.req);
|
|
9603
10234
|
const loginRateLimit = await context.get("rateLimiters").getLimiter("login").checkLimit(clientIp);
|
|
9604
|
-
if (!loginRateLimit.isAllowed)
|
|
10235
|
+
if (!loginRateLimit.isAllowed) {
|
|
10236
|
+
const headers = {};
|
|
10237
|
+
if (loginRateLimit.retryAfterSeconds !== void 0) {
|
|
10238
|
+
headers["Retry-After"] = String(loginRateLimit.retryAfterSeconds);
|
|
10239
|
+
}
|
|
10240
|
+
return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429, headers);
|
|
10241
|
+
}
|
|
9605
10242
|
const user = await context.get("userRepository").findByEmail(email);
|
|
9606
10243
|
const hashToCompare = user?.passwordHash ?? DUMMY_PASSWORD_HASH;
|
|
9607
10244
|
const isValid = await verifyPassword(password, hashToCompare, context.get("hashProvider"));
|
|
@@ -9626,7 +10263,13 @@ function createBeechApp(config) {
|
|
|
9626
10263
|
try {
|
|
9627
10264
|
const refreshClientIp = getClientIp(context.req);
|
|
9628
10265
|
const refreshRateLimit = await context.get("rateLimiters").getLimiter("tokenRefresh").checkLimit(refreshClientIp);
|
|
9629
|
-
if (!refreshRateLimit.isAllowed)
|
|
10266
|
+
if (!refreshRateLimit.isAllowed) {
|
|
10267
|
+
const headers = {};
|
|
10268
|
+
if (refreshRateLimit.retryAfterSeconds !== void 0) {
|
|
10269
|
+
headers["Retry-After"] = String(refreshRateLimit.retryAfterSeconds);
|
|
10270
|
+
}
|
|
10271
|
+
return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429, headers);
|
|
10272
|
+
}
|
|
9630
10273
|
const refreshToken = getCookie(context, "refresh_token");
|
|
9631
10274
|
if (!refreshToken) return context.json({ error: "Refresh token missing" }, 401);
|
|
9632
10275
|
const nowSeconds = SystemClock6.nowSeconds();
|
|
@@ -9647,10 +10290,19 @@ function createBeechApp(config) {
|
|
|
9647
10290
|
tokenHash: newRefreshTokenHash,
|
|
9648
10291
|
expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY3
|
|
9649
10292
|
});
|
|
9650
|
-
|
|
9651
|
-
|
|
9652
|
-
|
|
9653
|
-
|
|
10293
|
+
try {
|
|
10294
|
+
const revoked = await context.get("sessionRepository").revokeByHash(tokenHash, nowSeconds);
|
|
10295
|
+
if (!revoked) {
|
|
10296
|
+
await context.get("sessionRepository").revokeByHash(newRefreshTokenHash, nowSeconds);
|
|
10297
|
+
return context.json({ error: "Invalid refresh token" }, 401);
|
|
10298
|
+
}
|
|
10299
|
+
setCookie(context, "refresh_token", newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)));
|
|
10300
|
+
return context.json({ token: newAccessToken, expiresIn: "15m" }, 200);
|
|
10301
|
+
} catch (error) {
|
|
10302
|
+
await context.get("sessionRepository").revokeByHash(newRefreshTokenHash, nowSeconds).catch(() => {
|
|
10303
|
+
});
|
|
10304
|
+
throw error;
|
|
10305
|
+
}
|
|
9654
10306
|
} catch (error) {
|
|
9655
10307
|
return handleAuthError(context, error, "Refresh");
|
|
9656
10308
|
}
|
|
@@ -9692,7 +10344,7 @@ function createBeechApp(config) {
|
|
|
9692
10344
|
apiPublic.route("/", publicRoutes);
|
|
9693
10345
|
app.route("/api/v1/public", apiPublic);
|
|
9694
10346
|
app.route("/api/webhooks", webhooksApp);
|
|
9695
|
-
app.get("/api/media/:key", (context) => serveMediaHandler(context));
|
|
10347
|
+
app.get("/api/media/:key{.+}", (context) => serveMediaHandler(context));
|
|
9696
10348
|
if (config.customRoutes) {
|
|
9697
10349
|
const publicRouter = new Hono19();
|
|
9698
10350
|
const protectedRouter = new Hono19();
|