@beechcms/api 0.6.0-preview.4 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/chart-FUiy4I7R.js +39 -0
- package/assets/dashboard/assets/index-BlehzIfd.js +644 -0
- package/assets/dashboard/assets/index-Dve7eEqA.css +1 -0
- package/assets/dashboard/assets/{pie-chart-recharts-BMWpjFzf.js → pie-chart-recharts-iaYz4FeR.js} +1 -1
- package/assets/dashboard/assets/timeseries-chart-recharts-C8Lcylom.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 +1057 -418
- 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;
|
|
@@ -38,20 +39,20 @@ function parseLoginBody(body) {
|
|
|
38
39
|
return { email: email.trim(), password };
|
|
39
40
|
}
|
|
40
41
|
function validateLoginInput(email, password) {
|
|
41
|
-
return email.length <= MAX_EMAIL_LENGTH && EMAIL_REGEX.test(email) && password.trim().length >= MIN_PASSWORD_LENGTH && password.length <= MAX_PASSWORD_LENGTH;
|
|
42
|
+
return email.length <= MAX_EMAIL_LENGTH && EMAIL_REGEX.test(email) && password.trim().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"
|
|
@@ -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,156 @@ 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 } 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
|
+
const entry = await context.get("repository").findById(seed, id);
|
|
1179
|
+
if (!entry) {
|
|
1180
|
+
return publicProblem(context, { type: "content-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.NOT_FOUND });
|
|
1181
|
+
}
|
|
1182
|
+
await context.get("kanbanPositionRepository").setPosition(slug, id, axisBranchId, position);
|
|
1183
|
+
return context.json({ success: true });
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/features/content/handlers/kanban-move.ts
|
|
1187
|
+
import {
|
|
1188
|
+
resolveKanbanConfig as resolveKanbanConfig3,
|
|
1189
|
+
validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload3
|
|
1190
|
+
} from "@beechcms/core";
|
|
1191
|
+
async function kanbanMoveHandler(context) {
|
|
1192
|
+
const slug = context.req.param("slug");
|
|
1193
|
+
const id = context.req.param("id");
|
|
1194
|
+
if (!slug || !id) {
|
|
1195
|
+
return publicProblem(context, { type: "content-invalid-slug-or-id", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG_OR_ID });
|
|
1196
|
+
}
|
|
1197
|
+
const seed = context.get("getSeed")(slug);
|
|
1198
|
+
if (!seed) {
|
|
1199
|
+
return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
|
|
1200
|
+
}
|
|
1201
|
+
let body;
|
|
1202
|
+
try {
|
|
1203
|
+
const raw = await context.req.json();
|
|
1204
|
+
if (typeof raw.position !== "string" || !raw.position || typeof raw.axisBranchId !== "string" || !raw.axisBranchId) {
|
|
1205
|
+
throw new Error("invalid");
|
|
1206
|
+
}
|
|
1207
|
+
body = raw;
|
|
1208
|
+
} catch {
|
|
1209
|
+
return publicProblem(context, { type: "content-invalid-body", title: "Bad Request", status: 400, detail: "position and axisBranchId are required strings" });
|
|
1210
|
+
}
|
|
1211
|
+
const compat = resolveKanbanConfig3(seed);
|
|
1212
|
+
const candidate = compat.candidates.find((c) => c.branchId === body.axisBranchId);
|
|
1213
|
+
if (!compat.compatible || !candidate) {
|
|
1214
|
+
return publicProblem(context, { type: "content-invalid-kanban-axis", title: "Bad Request", status: 400, detail: "axisBranchId is not a valid kanban candidate for this seed" });
|
|
1215
|
+
}
|
|
1216
|
+
const axisBranch = seed.branches.find((b) => b.id === body.axisBranchId);
|
|
1217
|
+
const repository = context.get("repository");
|
|
1218
|
+
let current;
|
|
1219
|
+
try {
|
|
1220
|
+
current = await repository.findById(seed, id);
|
|
1221
|
+
} catch {
|
|
1222
|
+
return publicProblem(context, { type: "content-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.NOT_FOUND });
|
|
1223
|
+
}
|
|
1224
|
+
let patch = null;
|
|
1225
|
+
if (body.axis) {
|
|
1226
|
+
if (body.axis.kind === "scalar") {
|
|
1227
|
+
const rawValue = body.axis.value;
|
|
1228
|
+
const coerced = axisBranch.type === "boolean" && rawValue !== null ? rawValue === "true" : rawValue;
|
|
1229
|
+
patch = { [axisBranch.alias]: coerced };
|
|
1230
|
+
} else {
|
|
1231
|
+
const currentTags = current[axisBranch.alias] ?? [];
|
|
1232
|
+
const { oldValue, newValue } = body.axis;
|
|
1233
|
+
const withoutOld = oldValue !== null ? currentTags.filter((t) => t !== oldValue) : currentTags;
|
|
1234
|
+
const nextTags = newValue !== null ? [...withoutOld, newValue] : withoutOld;
|
|
1235
|
+
patch = { [axisBranch.alias]: nextTags };
|
|
1236
|
+
}
|
|
1237
|
+
const validation = validateAndSanitizeSeedPayload3(seed, patch, {
|
|
1238
|
+
operation: "update",
|
|
1239
|
+
allowNull: true,
|
|
1240
|
+
requireAtLeastOneValidField: true,
|
|
1241
|
+
enforceRequiredFields: false,
|
|
1242
|
+
idGenerator: context.get("idGenerator")
|
|
1243
|
+
});
|
|
1244
|
+
if (validation.details.length > 0) {
|
|
1245
|
+
return publicProblem(context, { type: "content-validation-error", title: "Unprocessable Entity", status: 422, detail: validation.details[0].message });
|
|
1246
|
+
}
|
|
1247
|
+
patch = validation.data;
|
|
1248
|
+
}
|
|
1249
|
+
const jwtPayload = context.get("jwtPayload");
|
|
1250
|
+
await repository.updateWithKanbanPosition(seed, id, patch, body.position, body.axisBranchId, { actor: jwtPayload.sub });
|
|
1251
|
+
return context.json({ success: true });
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// src/features/content/handlers/view-config.ts
|
|
1255
|
+
import { seedViewConfigSchema, validateCardConfigAgainstSeed } from "@beechcms/core";
|
|
1256
|
+
async function getViewConfigHandler(context) {
|
|
1257
|
+
const slug = context.req.param("slug");
|
|
1258
|
+
if (!slug) {
|
|
1259
|
+
return publicProblem(context, { type: "content-invalid-slug", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG });
|
|
1260
|
+
}
|
|
1261
|
+
const seed = context.get("getSeed")(slug);
|
|
1262
|
+
if (!seed) {
|
|
1263
|
+
return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
|
|
1264
|
+
}
|
|
1265
|
+
const config = await context.get("seedLayoutRepository").getViewConfig(slug);
|
|
1266
|
+
return context.json(config ?? {});
|
|
1267
|
+
}
|
|
1268
|
+
async function putViewConfigHandler(context) {
|
|
1269
|
+
const slug = context.req.param("slug");
|
|
1270
|
+
if (!slug) {
|
|
1271
|
+
return publicProblem(context, { type: "content-invalid-slug", title: "Bad Request", status: 400, detail: CONTENT_ERRORS.INVALID_SLUG });
|
|
1272
|
+
}
|
|
1273
|
+
const seed = context.get("getSeed")(slug);
|
|
1274
|
+
if (!seed) {
|
|
1275
|
+
return publicProblem(context, { type: "content-seed-not-found", title: "Not Found", status: 404, detail: CONTENT_ERRORS.SEED_NOT_FOUND });
|
|
1276
|
+
}
|
|
1277
|
+
let body;
|
|
1278
|
+
try {
|
|
1279
|
+
body = await context.req.json();
|
|
1280
|
+
} catch {
|
|
1281
|
+
return publicProblem(context, { type: "content-invalid-body", title: "Bad Request", status: 400, detail: "Invalid JSON body" });
|
|
1282
|
+
}
|
|
1283
|
+
const parsed = seedViewConfigSchema.safeParse(body);
|
|
1284
|
+
if (!parsed.success) {
|
|
1285
|
+
return publicProblem(context, { type: "content-invalid-view-config", title: "Unprocessable Entity", status: 422, detail: parsed.error.issues[0]?.message ?? "Invalid view config" });
|
|
1286
|
+
}
|
|
1287
|
+
if (parsed.data.card) {
|
|
1288
|
+
const { cleaned } = validateCardConfigAgainstSeed(parsed.data.card, seed);
|
|
1289
|
+
parsed.data = { ...parsed.data, card: cleaned };
|
|
1290
|
+
}
|
|
1291
|
+
const jwtPayload = context.get("jwtPayload");
|
|
1292
|
+
await context.get("seedLayoutRepository").setViewConfig(slug, parsed.data, jwtPayload.sub);
|
|
1293
|
+
return context.json({ ok: true });
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1242
1296
|
// src/features/content/index.ts
|
|
1243
|
-
var content = new
|
|
1297
|
+
var content = new Hono();
|
|
1298
|
+
content.patch("/:slug/:id/kanban-move", kanbanMoveHandler);
|
|
1299
|
+
content.patch("/:slug/:id/kanban-position", kanbanPositionHandler);
|
|
1300
|
+
content.get("/:slug/view-config", getViewConfigHandler);
|
|
1301
|
+
content.put("/:slug/view-config", putViewConfigHandler);
|
|
1244
1302
|
content.get("/:slug", listHandler);
|
|
1245
1303
|
content.get("/:slug/facets", facetsHandler);
|
|
1246
1304
|
content.get("/:schema_slug/by-slug/:entry_slug", getBySlugHandler);
|
|
@@ -1251,10 +1309,10 @@ content.put("/:slug/:id", updateHandler);
|
|
|
1251
1309
|
content.delete("/:slug/:id", deleteHandler);
|
|
1252
1310
|
var content_default = content;
|
|
1253
1311
|
|
|
1254
|
-
// src/widget.ts
|
|
1255
|
-
import { Hono as
|
|
1312
|
+
// src/features/widget/widget.ts
|
|
1313
|
+
import { Hono as Hono2 } from "hono";
|
|
1256
1314
|
import { deserializeFromDb } from "@beechcms/core";
|
|
1257
|
-
var widgetApp = new
|
|
1315
|
+
var widgetApp = new Hono2();
|
|
1258
1316
|
var DEFAULT_LEADERBOARD_LIMIT = 10;
|
|
1259
1317
|
var MAXIMUM_LEADERBOARD_LIMIT = 100;
|
|
1260
1318
|
var DEFAULT_LIST_LIMIT = 25;
|
|
@@ -1445,8 +1503,8 @@ widgetApp.get("/distribution/:seed", async (context) => {
|
|
|
1445
1503
|
});
|
|
1446
1504
|
|
|
1447
1505
|
// src/features/rotate-field/rotate-field.handler.ts
|
|
1448
|
-
import { Hono as
|
|
1449
|
-
import { resolvePolicies as resolvePolicies4, verifyHashField, sha256hex as sha256hex2, validateAndSanitizeSeedPayload as
|
|
1506
|
+
import { Hono as Hono3 } from "hono";
|
|
1507
|
+
import { resolvePolicies as resolvePolicies4, verifyHashField, sha256hex as sha256hex2, validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload4, EntryNotFoundError as EntryNotFoundError3 } from "@beechcms/core";
|
|
1450
1508
|
|
|
1451
1509
|
// src/features/rotate-field/rotate-field.schema.ts
|
|
1452
1510
|
import { z } from "zod";
|
|
@@ -1457,7 +1515,7 @@ var rotateFieldRequestSchema = z.object({
|
|
|
1457
1515
|
});
|
|
1458
1516
|
|
|
1459
1517
|
// src/features/rotate-field/rotate-field.handler.ts
|
|
1460
|
-
var rotateFieldApp = new
|
|
1518
|
+
var rotateFieldApp = new Hono3();
|
|
1461
1519
|
rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
|
|
1462
1520
|
const seedSlug = context.req.param("slug");
|
|
1463
1521
|
const entryId = context.req.param("id");
|
|
@@ -1541,7 +1599,7 @@ rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
|
|
|
1541
1599
|
detail: "Current value does not match stored value"
|
|
1542
1600
|
});
|
|
1543
1601
|
}
|
|
1544
|
-
const fieldValidationResult =
|
|
1602
|
+
const fieldValidationResult = validateAndSanitizeSeedPayload4(
|
|
1545
1603
|
seed,
|
|
1546
1604
|
{ [fieldAlias]: nextValue },
|
|
1547
1605
|
{ operation: "update", allowNull: false, requireAtLeastOneValidField: true, enforceRequiredFields: false }
|
|
@@ -1560,9 +1618,9 @@ rotateFieldApp.post("/:slug/:id/rotate-field", async (context) => {
|
|
|
1560
1618
|
});
|
|
1561
1619
|
|
|
1562
1620
|
// src/features/password-reset/index.ts
|
|
1563
|
-
import { Hono as
|
|
1621
|
+
import { Hono as Hono4 } from "hono";
|
|
1564
1622
|
|
|
1565
|
-
// src/
|
|
1623
|
+
// src/shared/email/providers/resend.ts
|
|
1566
1624
|
var RESEND_API_URL = "https://api.resend.com/emails";
|
|
1567
1625
|
var ResendEmailProvider = class {
|
|
1568
1626
|
apiKey;
|
|
@@ -1598,7 +1656,7 @@ var ResendEmailProvider = class {
|
|
|
1598
1656
|
}
|
|
1599
1657
|
};
|
|
1600
1658
|
|
|
1601
|
-
// src/
|
|
1659
|
+
// src/shared/email/providers/smtp.ts
|
|
1602
1660
|
function parseAddress(raw) {
|
|
1603
1661
|
const match = raw.match(/^\s*(.+?)\s*<([^>]+)>\s*$/);
|
|
1604
1662
|
if (match) return { Name: match[1], Email: match[2] };
|
|
@@ -1628,7 +1686,7 @@ var SmtpEmailProvider = class {
|
|
|
1628
1686
|
}
|
|
1629
1687
|
};
|
|
1630
1688
|
|
|
1631
|
-
// src/
|
|
1689
|
+
// src/shared/email/templates/shell.ts
|
|
1632
1690
|
function buildEmailShell(locale, slots) {
|
|
1633
1691
|
const ctaBlock = slots.cta ? `<a href="${slots.cta.href}"
|
|
1634
1692
|
style="display:inline-block;background:#111;color:#fff;padding:12px 24px;
|
|
@@ -1656,7 +1714,7 @@ function buildEmailShell(locale, slots) {
|
|
|
1656
1714
|
</html>`;
|
|
1657
1715
|
}
|
|
1658
1716
|
|
|
1659
|
-
// src/
|
|
1717
|
+
// src/shared/email/templates/password-reset.ts
|
|
1660
1718
|
var COPY = {
|
|
1661
1719
|
en: {
|
|
1662
1720
|
subject: "Reset your Beech CMS password",
|
|
@@ -1686,7 +1744,7 @@ function buildPasswordResetEmail(resetUrl, locale) {
|
|
|
1686
1744
|
};
|
|
1687
1745
|
}
|
|
1688
1746
|
|
|
1689
|
-
// src/
|
|
1747
|
+
// src/shared/email/templates/password-changed.ts
|
|
1690
1748
|
var COPY2 = {
|
|
1691
1749
|
en: {
|
|
1692
1750
|
subject: "Your Beech CMS password has been changed",
|
|
@@ -1716,7 +1774,7 @@ function buildPasswordChangedEmail(locale) {
|
|
|
1716
1774
|
};
|
|
1717
1775
|
}
|
|
1718
1776
|
|
|
1719
|
-
// src/
|
|
1777
|
+
// src/shared/email/templates/automation-mail.ts
|
|
1720
1778
|
function buildAutomationEmail(params) {
|
|
1721
1779
|
return {
|
|
1722
1780
|
to: params.to,
|
|
@@ -1729,7 +1787,7 @@ function stripHtml(input) {
|
|
|
1729
1787
|
return input.replace(/<[^>]+>/g, "").trim();
|
|
1730
1788
|
}
|
|
1731
1789
|
|
|
1732
|
-
// src/
|
|
1790
|
+
// src/shared/email/email.service.ts
|
|
1733
1791
|
var DEFAULT_FROM = "Beech CMS <onboarding@resend.dev>";
|
|
1734
1792
|
function createProvider(env) {
|
|
1735
1793
|
if (env.provider === "smtp") {
|
|
@@ -1771,7 +1829,7 @@ async function sendPasswordChangedEmail(params) {
|
|
|
1771
1829
|
async function sendAutomationMail(params) {
|
|
1772
1830
|
const provider = createProvider({
|
|
1773
1831
|
provider: params.provider,
|
|
1774
|
-
apiKey: params.apiKey ??
|
|
1832
|
+
apiKey: params.apiKey ?? "",
|
|
1775
1833
|
smtpBaseUrl: params.smtpBaseUrl,
|
|
1776
1834
|
isDev: false
|
|
1777
1835
|
});
|
|
@@ -1784,7 +1842,7 @@ async function sendAutomationMail(params) {
|
|
|
1784
1842
|
});
|
|
1785
1843
|
}
|
|
1786
1844
|
|
|
1787
|
-
// src/
|
|
1845
|
+
// src/shared/email/email.types.ts
|
|
1788
1846
|
var SUPPORTED_EMAIL_LOCALES = ["en", "it"];
|
|
1789
1847
|
function resolveEmailLocale(raw) {
|
|
1790
1848
|
if (typeof raw === "string" && SUPPORTED_EMAIL_LOCALES.includes(raw)) {
|
|
@@ -1817,7 +1875,11 @@ async function requestPasswordReset(context) {
|
|
|
1817
1875
|
const clientIpAddress = getClientIp(req);
|
|
1818
1876
|
const forgotPasswordRateLimit = await context.get("rateLimiters").getLimiter("forgotPassword").checkLimit(clientIpAddress);
|
|
1819
1877
|
if (!forgotPasswordRateLimit.isAllowed) {
|
|
1820
|
-
|
|
1878
|
+
const headers = {};
|
|
1879
|
+
if (forgotPasswordRateLimit.retryAfterSeconds !== void 0) {
|
|
1880
|
+
headers["Retry-After"] = String(forgotPasswordRateLimit.retryAfterSeconds);
|
|
1881
|
+
}
|
|
1882
|
+
return context.json({ error: "Too many requests" }, 429, headers);
|
|
1821
1883
|
}
|
|
1822
1884
|
const registeredUser = await context.get("userRepository").findByEmail(normalizedEmail);
|
|
1823
1885
|
if (!registeredUser) {
|
|
@@ -1859,6 +1921,7 @@ async function requestPasswordReset(context) {
|
|
|
1859
1921
|
import { sha256hex as sha256hex4 } from "@beechcms/core";
|
|
1860
1922
|
var MIN_PASSWORD_LENGTH2 = 8;
|
|
1861
1923
|
var MAX_PASSWORD_LENGTH2 = 128;
|
|
1924
|
+
var MAX_PASSWORD_BYTES2 = 72;
|
|
1862
1925
|
async function resetPassword(context) {
|
|
1863
1926
|
const { env, req, executionCtx } = context;
|
|
1864
1927
|
const useSmtp = env.EMAIL_PROVIDER === "smtp";
|
|
@@ -1868,7 +1931,11 @@ async function resetPassword(context) {
|
|
|
1868
1931
|
const clientIpAddress = getClientIp(req);
|
|
1869
1932
|
const resetPasswordRateLimit = await context.get("rateLimiters").getLimiter("resetPassword").checkLimit(clientIpAddress);
|
|
1870
1933
|
if (!resetPasswordRateLimit.isAllowed) {
|
|
1871
|
-
|
|
1934
|
+
const headers = {};
|
|
1935
|
+
if (resetPasswordRateLimit.retryAfterSeconds !== void 0) {
|
|
1936
|
+
headers["Retry-After"] = String(resetPasswordRateLimit.retryAfterSeconds);
|
|
1937
|
+
}
|
|
1938
|
+
return context.json({ error: "Too many requests" }, 429, headers);
|
|
1872
1939
|
}
|
|
1873
1940
|
let payload;
|
|
1874
1941
|
try {
|
|
@@ -1887,6 +1954,11 @@ async function resetPassword(context) {
|
|
|
1887
1954
|
error: `Password must be between ${MIN_PASSWORD_LENGTH2} and ${MAX_PASSWORD_LENGTH2} characters`
|
|
1888
1955
|
}, 400);
|
|
1889
1956
|
}
|
|
1957
|
+
if (new TextEncoder().encode(newPassword).length > MAX_PASSWORD_BYTES2) {
|
|
1958
|
+
return context.json({
|
|
1959
|
+
error: `Password must not exceed ${MAX_PASSWORD_BYTES2} bytes when UTF-8 encoded`
|
|
1960
|
+
}, 400);
|
|
1961
|
+
}
|
|
1890
1962
|
const emailLocale = resolveEmailLocale(payload.locale);
|
|
1891
1963
|
const tokenHash = await sha256hex4(resetToken);
|
|
1892
1964
|
const nowTimestamp = Math.floor(Date.now() / 1e3);
|
|
@@ -1925,7 +1997,7 @@ async function resetPassword(context) {
|
|
|
1925
1997
|
}
|
|
1926
1998
|
|
|
1927
1999
|
// src/features/password-reset/index.ts
|
|
1928
|
-
var passwordResetApp = new
|
|
2000
|
+
var passwordResetApp = new Hono4();
|
|
1929
2001
|
passwordResetApp.get("/auth/features", (context) => {
|
|
1930
2002
|
return context.json({ passwordReset: context.env.EMAIL_PROVIDER === "smtp" || Boolean(context.env.RESEND_API_KEY) });
|
|
1931
2003
|
});
|
|
@@ -1933,8 +2005,8 @@ passwordResetApp.post("/auth/forgot-password", requestPasswordReset);
|
|
|
1933
2005
|
passwordResetApp.post("/auth/reset-password", resetPassword);
|
|
1934
2006
|
|
|
1935
2007
|
// src/features/setup/index.ts
|
|
1936
|
-
import { Hono as
|
|
1937
|
-
var setupApp = new
|
|
2008
|
+
import { Hono as Hono5 } from "hono";
|
|
2009
|
+
var setupApp = new Hono5();
|
|
1938
2010
|
setupApp.get("/auth/setup", async (context) => {
|
|
1939
2011
|
const userCount = await context.get("userRepository").countAll();
|
|
1940
2012
|
const needsSetup = userCount === 0;
|
|
@@ -1996,6 +2068,14 @@ setupApp.post("/auth/setup", async (context) => {
|
|
|
1996
2068
|
detail: "Password must be between 8 and 128 characters long."
|
|
1997
2069
|
});
|
|
1998
2070
|
}
|
|
2071
|
+
if (new TextEncoder().encode(password).length > 72) {
|
|
2072
|
+
return publicProblem(context, {
|
|
2073
|
+
type: "validation-error",
|
|
2074
|
+
title: "Invalid password",
|
|
2075
|
+
status: 422,
|
|
2076
|
+
detail: "Password must not exceed 72 bytes when UTF-8 encoded."
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
1999
2079
|
if (!settings || typeof settings !== "object") {
|
|
2000
2080
|
return publicProblem(context, {
|
|
2001
2081
|
type: "validation-error",
|
|
@@ -2185,9 +2265,9 @@ setupApp.post("/auth/setup", async (context) => {
|
|
|
2185
2265
|
});
|
|
2186
2266
|
|
|
2187
2267
|
// src/features/draft/draft.handler.ts
|
|
2188
|
-
import { Hono as
|
|
2268
|
+
import { Hono as Hono6 } from "hono";
|
|
2189
2269
|
import {
|
|
2190
|
-
validateAndSanitizeSeedPayload as
|
|
2270
|
+
validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload5,
|
|
2191
2271
|
resolvePolicies as resolvePolicies5,
|
|
2192
2272
|
RelationTargetNotFoundError
|
|
2193
2273
|
} from "@beechcms/core";
|
|
@@ -2241,7 +2321,7 @@ var draftGuard = createMiddleware(async (context, next) => {
|
|
|
2241
2321
|
});
|
|
2242
2322
|
|
|
2243
2323
|
// src/features/draft/draft.handler.ts
|
|
2244
|
-
var draftApp = new
|
|
2324
|
+
var draftApp = new Hono6();
|
|
2245
2325
|
draftApp.get("/drafts", async (context) => {
|
|
2246
2326
|
const seeds = context.get("seedRegistry").draftEnabled();
|
|
2247
2327
|
const repository = context.get("repository");
|
|
@@ -2293,7 +2373,7 @@ draftApp.put("/:slug/:id/draft", draftGuard, async (context) => {
|
|
|
2293
2373
|
detail: `${CONTENT_ERRORS.SENSITIVE_FIELD_EDIT}: ${sensitiveAliases.join(", ")}`
|
|
2294
2374
|
});
|
|
2295
2375
|
}
|
|
2296
|
-
const validation =
|
|
2376
|
+
const validation = validateAndSanitizeSeedPayload5(seed, body, {
|
|
2297
2377
|
operation: "update",
|
|
2298
2378
|
allowNull: true,
|
|
2299
2379
|
requireAtLeastOneValidField: true,
|
|
@@ -2381,12 +2461,13 @@ draftApp.delete("/:slug/:id/draft", draftGuard, async (context) => {
|
|
|
2381
2461
|
});
|
|
2382
2462
|
|
|
2383
2463
|
// src/features/settings/settings.handler.ts
|
|
2384
|
-
import { Hono as
|
|
2464
|
+
import { Hono as Hono7 } from "hono";
|
|
2385
2465
|
import { sha256hex as sha256hex5 } from "@beechcms/core";
|
|
2386
|
-
var settingsApp = new
|
|
2466
|
+
var settingsApp = new Hono7();
|
|
2387
2467
|
var EMAIL_VALIDATION_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
2388
2468
|
var MIN_PASSWORD_LENGTH3 = 8;
|
|
2389
2469
|
var MAX_PASSWORD_LENGTH3 = 128;
|
|
2470
|
+
var MAX_PASSWORD_BYTES3 = 72;
|
|
2390
2471
|
var SESSION_LIST_LIMIT = 20;
|
|
2391
2472
|
var ACTIVITY_LOG_LIMIT = 30;
|
|
2392
2473
|
settingsApp.get("/", async (context) => {
|
|
@@ -2549,6 +2630,9 @@ settingsApp.put("/password", async (context) => {
|
|
|
2549
2630
|
if (newPassword.length > MAX_PASSWORD_LENGTH3) {
|
|
2550
2631
|
return context.json({ error: "Password is too long" }, 400);
|
|
2551
2632
|
}
|
|
2633
|
+
if (new TextEncoder().encode(newPassword).length > MAX_PASSWORD_BYTES3) {
|
|
2634
|
+
return context.json({ error: `Password must not exceed ${MAX_PASSWORD_BYTES3} bytes when UTF-8 encoded` }, 400);
|
|
2635
|
+
}
|
|
2552
2636
|
const userRecord = await context.get("userRepository").findById(userId);
|
|
2553
2637
|
if (!userRecord) {
|
|
2554
2638
|
return context.json({ error: "User not found" }, 404);
|
|
@@ -2659,7 +2743,7 @@ settingsApp.put("/notifications", async (context) => {
|
|
|
2659
2743
|
});
|
|
2660
2744
|
|
|
2661
2745
|
// src/features/schema/schema.handler.ts
|
|
2662
|
-
import { Hono as
|
|
2746
|
+
import { Hono as Hono8 } from "hono";
|
|
2663
2747
|
import { canEditLayout, formLayoutSchema, validateLayoutAgainstSeed } from "@beechcms/core";
|
|
2664
2748
|
function requireLayoutEditPermission(context) {
|
|
2665
2749
|
const role = context.get("jwtPayload")?.role;
|
|
@@ -2688,7 +2772,7 @@ function requireSeedBySlug(context, slug) {
|
|
|
2688
2772
|
}
|
|
2689
2773
|
return { seed, error: null };
|
|
2690
2774
|
}
|
|
2691
|
-
var schemaApp = new
|
|
2775
|
+
var schemaApp = new Hono8();
|
|
2692
2776
|
schemaApp.get("/", async (context) => {
|
|
2693
2777
|
const registry = context.get("seedRegistry");
|
|
2694
2778
|
const layouts = await context.get("seedLayoutRepository").getAllAsMap();
|
|
@@ -2750,7 +2834,7 @@ schemaApp.delete("/:slug/layout", async (context) => {
|
|
|
2750
2834
|
});
|
|
2751
2835
|
|
|
2752
2836
|
// src/features/dashboard-layout/dashboard-layout.handler.ts
|
|
2753
|
-
import { Hono as
|
|
2837
|
+
import { Hono as Hono9 } from "hono";
|
|
2754
2838
|
import {
|
|
2755
2839
|
canEditDashboard,
|
|
2756
2840
|
dashboardLayoutSchema,
|
|
@@ -2810,7 +2894,7 @@ async function putLayout(context, scope) {
|
|
|
2810
2894
|
await context.get("dashboardLayoutRepository").upsert(scope, result.cleaned, userId);
|
|
2811
2895
|
return context.json({ ok: true, layout: result.cleaned, warnings: result.warnings });
|
|
2812
2896
|
}
|
|
2813
|
-
var dashboardLayoutApp = new
|
|
2897
|
+
var dashboardLayoutApp = new Hono9();
|
|
2814
2898
|
dashboardLayoutApp.get("/", async (context) => {
|
|
2815
2899
|
const role = context.get("jwtPayload")?.role;
|
|
2816
2900
|
const chain = resolveDashboardScopeChain(role);
|
|
@@ -2885,7 +2969,7 @@ dashboardLayoutApp.delete("/:scope", async (context) => {
|
|
|
2885
2969
|
});
|
|
2886
2970
|
|
|
2887
2971
|
// src/features/seeds/seeds.handler.ts
|
|
2888
|
-
import { Hono as
|
|
2972
|
+
import { Hono as Hono10 } from "hono";
|
|
2889
2973
|
import {
|
|
2890
2974
|
nextBranchId,
|
|
2891
2975
|
validateSeedDefinitions,
|
|
@@ -3028,16 +3112,17 @@ async function deleteSeedMediaObjects(context, slug, seed, schemaMutator) {
|
|
|
3028
3112
|
try {
|
|
3029
3113
|
const dbCols = await schemaMutator.getColumns(`content_${slug}`);
|
|
3030
3114
|
if (!dbCols) return;
|
|
3031
|
-
const
|
|
3032
|
-
if (
|
|
3033
|
-
const
|
|
3034
|
-
const rows =
|
|
3115
|
+
const validCols = fileBranches.map((b) => b.alias).filter((a) => dbCols.has(a));
|
|
3116
|
+
if (validCols.length === 0) return;
|
|
3117
|
+
const sql = `SELECT ${validCols.join(", ")} FROM content_${slug}`;
|
|
3118
|
+
const { results: rows } = await context.env.DB.prepare(sql).all();
|
|
3119
|
+
const cdnUrl = context.env.MEDIA_CDN_URL;
|
|
3035
3120
|
const r2Keys = [];
|
|
3036
3121
|
for (const row of rows) {
|
|
3037
3122
|
for (const b of fileBranches) {
|
|
3038
3123
|
const val = row[b.alias];
|
|
3039
3124
|
if (!val) continue;
|
|
3040
|
-
const keys = extractMediaKeysFromData(seed, { [b.alias]: val });
|
|
3125
|
+
const keys = extractMediaKeysFromData(seed, { [b.alias]: val }, cdnUrl);
|
|
3041
3126
|
r2Keys.push(...keys);
|
|
3042
3127
|
}
|
|
3043
3128
|
}
|
|
@@ -3048,7 +3133,7 @@ async function deleteSeedMediaObjects(context, slug, seed, schemaMutator) {
|
|
|
3048
3133
|
} catch {
|
|
3049
3134
|
}
|
|
3050
3135
|
}
|
|
3051
|
-
var seedsApp = new
|
|
3136
|
+
var seedsApp = new Hono10();
|
|
3052
3137
|
seedsApp.use("*", async (context, next) => {
|
|
3053
3138
|
const denied = requireAdmin(context);
|
|
3054
3139
|
if (denied) return denied;
|
|
@@ -3323,8 +3408,8 @@ seedsApp.patch("/:slug/branches/:branchId/retype", async (context) => {
|
|
|
3323
3408
|
});
|
|
3324
3409
|
|
|
3325
3410
|
// src/features/notifications/notifications.handler.ts
|
|
3326
|
-
import { Hono as
|
|
3327
|
-
var notificationsApp = new
|
|
3411
|
+
import { Hono as Hono11 } from "hono";
|
|
3412
|
+
var notificationsApp = new Hono11();
|
|
3328
3413
|
var NOTIFICATION_LIST_LIMIT = 50;
|
|
3329
3414
|
notificationsApp.get("/notifications", async (context) => {
|
|
3330
3415
|
try {
|
|
@@ -3384,7 +3469,7 @@ notificationsApp.post("/notifications/mark-all-read", async (context) => {
|
|
|
3384
3469
|
}
|
|
3385
3470
|
});
|
|
3386
3471
|
|
|
3387
|
-
// src/features/automations/template-grammar.ts
|
|
3472
|
+
// src/features/automations/evaluator/template-grammar.ts
|
|
3388
3473
|
import { AUTOMATION_RESERVED_WORDS } from "@beechcms/core";
|
|
3389
3474
|
var SLUG_RE2 = /^[a-zA-Z0-9_-]+$/;
|
|
3390
3475
|
var AGGREGATE_OPS = /* @__PURE__ */ new Set(["count", "sum", "avg", "min", "max", "pluck"]);
|
|
@@ -3517,7 +3602,7 @@ function parseTemplateKey(raw) {
|
|
|
3517
3602
|
return { kind: "var_access", name: nameToken, steps, conditions };
|
|
3518
3603
|
}
|
|
3519
3604
|
|
|
3520
|
-
// src/features/automations/automation-runner.utils.ts
|
|
3605
|
+
// src/features/automations/engine/automation-runner.utils.ts
|
|
3521
3606
|
function interpolate(template, context, defaultValue = "", onMissing) {
|
|
3522
3607
|
if (!template) return "";
|
|
3523
3608
|
const replacer = (_, key) => {
|
|
@@ -3551,7 +3636,7 @@ function resolvePath(obj, path) {
|
|
|
3551
3636
|
return current;
|
|
3552
3637
|
}
|
|
3553
3638
|
|
|
3554
|
-
// src/features/automations/context-resolver.ts
|
|
3639
|
+
// src/features/automations/evaluator/context-resolver.ts
|
|
3555
3640
|
var MAX_PLUCK = 100;
|
|
3556
3641
|
function applyAggregate(op, field, rows, onMissing) {
|
|
3557
3642
|
switch (op) {
|
|
@@ -3666,7 +3751,7 @@ async function resolveAutomationContext(_automation, triggerEntry, batchEntries)
|
|
|
3666
3751
|
return { lookup, triggerEntry };
|
|
3667
3752
|
}
|
|
3668
3753
|
|
|
3669
|
-
// src/features/automations/
|
|
3754
|
+
// src/features/automations/executors/webhook.executor.ts
|
|
3670
3755
|
import { signWebhookBody } from "@beechcms/core/webhook-crypto";
|
|
3671
3756
|
var WEBHOOK_TIMEOUT_MS = 8e3;
|
|
3672
3757
|
var warnedNoSecret = false;
|
|
@@ -3702,7 +3787,7 @@ async function executeWebhook(action, context, env) {
|
|
|
3702
3787
|
}
|
|
3703
3788
|
}
|
|
3704
3789
|
|
|
3705
|
-
// src/features/automations/
|
|
3790
|
+
// src/features/automations/executors/send-mail.executor.ts
|
|
3706
3791
|
async function executeSendMail(action, context, env) {
|
|
3707
3792
|
const missingFields = [];
|
|
3708
3793
|
const logMissing = (field) => missingFields.push(field);
|
|
@@ -3735,7 +3820,7 @@ async function executeSendMail(action, context, env) {
|
|
|
3735
3820
|
}
|
|
3736
3821
|
}
|
|
3737
3822
|
|
|
3738
|
-
// src/features/automations/
|
|
3823
|
+
// src/features/automations/executors/edit-field.executor.ts
|
|
3739
3824
|
async function executeEditField(action, entry, context, repository, seed) {
|
|
3740
3825
|
const id = entry.id;
|
|
3741
3826
|
if (typeof id !== "string") {
|
|
@@ -3745,7 +3830,7 @@ async function executeEditField(action, entry, context, repository, seed) {
|
|
|
3745
3830
|
await repository.update(seed, id, { [action.field]: resolved });
|
|
3746
3831
|
}
|
|
3747
3832
|
|
|
3748
|
-
// src/features/automations/
|
|
3833
|
+
// src/features/automations/executors/create-entry.executor.ts
|
|
3749
3834
|
async function executeCreateEntry(action, entry, repository, getSeed, idGenerator) {
|
|
3750
3835
|
const targetSeed = getSeed(action.seed_slug);
|
|
3751
3836
|
if (!targetSeed) throw new Error(`create_entry: unknown seed ${action.seed_slug}`);
|
|
@@ -3757,7 +3842,7 @@ async function executeCreateEntry(action, entry, repository, getSeed, idGenerato
|
|
|
3757
3842
|
await repository.create(targetSeed, newId, newId, "draft", payload);
|
|
3758
3843
|
}
|
|
3759
3844
|
|
|
3760
|
-
// src/features/automations/filter-translation.ts
|
|
3845
|
+
// src/features/automations/filters/filter-translation.ts
|
|
3761
3846
|
var SYSTEM_COLUMNS = /* @__PURE__ */ new Set(["id", "slug", "status", "created_at", "updated_at"]);
|
|
3762
3847
|
function mapBranchTypeToFilterType(type) {
|
|
3763
3848
|
switch (type) {
|
|
@@ -3795,7 +3880,7 @@ function conditionToFilterGroup(c, seed) {
|
|
|
3795
3880
|
};
|
|
3796
3881
|
}
|
|
3797
3882
|
|
|
3798
|
-
// src/features/automations/
|
|
3883
|
+
// src/features/automations/executors/set-variable.executor.ts
|
|
3799
3884
|
async function executeSetVariable(action, ctx) {
|
|
3800
3885
|
const seedSlug = action.seed_slug ?? ctx.seed.slug;
|
|
3801
3886
|
const targetSeed = ctx.getSeed(seedSlug);
|
|
@@ -3908,7 +3993,7 @@ function calculateGeneralStats(seed, items) {
|
|
|
3908
3993
|
return { count: items.length, sum, avg, min, max, pluck };
|
|
3909
3994
|
}
|
|
3910
3995
|
|
|
3911
|
-
// src/features/automations/
|
|
3996
|
+
// src/features/automations/executors/index.ts
|
|
3912
3997
|
async function executeAction(action, ctx) {
|
|
3913
3998
|
switch (action.type) {
|
|
3914
3999
|
case "set_variable":
|
|
@@ -3928,7 +4013,7 @@ async function executeAction(action, ctx) {
|
|
|
3928
4013
|
}
|
|
3929
4014
|
}
|
|
3930
4015
|
|
|
3931
|
-
// src/features/automations/when-evaluator.ts
|
|
4016
|
+
// src/features/automations/filters/when-evaluator.ts
|
|
3932
4017
|
function evaluateWhen(node, context) {
|
|
3933
4018
|
if (!node) return true;
|
|
3934
4019
|
if (node.kind === "predicate") return evalPredicate(node, context);
|
|
@@ -4008,7 +4093,7 @@ function coerceEqual(a, b) {
|
|
|
4008
4093
|
return a === b;
|
|
4009
4094
|
}
|
|
4010
4095
|
|
|
4011
|
-
// src/features/automations/var-access-resolver.ts
|
|
4096
|
+
// src/features/automations/evaluator/var-access-resolver.ts
|
|
4012
4097
|
function toNumeric(v) {
|
|
4013
4098
|
const n = Number(v);
|
|
4014
4099
|
if (Number.isFinite(n)) return n;
|
|
@@ -4111,7 +4196,7 @@ function resolveVarAccess(parsed, variables, onMissing) {
|
|
|
4111
4196
|
return current;
|
|
4112
4197
|
}
|
|
4113
4198
|
|
|
4114
|
-
// src/features/automations/automation-runner.ts
|
|
4199
|
+
// src/features/automations/engine/automation-runner.ts
|
|
4115
4200
|
function withVariables(base, variables) {
|
|
4116
4201
|
return {
|
|
4117
4202
|
triggerEntry: base.triggerEntry,
|
|
@@ -4164,10 +4249,10 @@ var AutomationRunner = class {
|
|
|
4164
4249
|
}
|
|
4165
4250
|
};
|
|
4166
4251
|
|
|
4167
|
-
// src/features/automations/automations.handler.ts
|
|
4168
|
-
import { Hono as
|
|
4252
|
+
// src/features/automations/api/automations.handler.ts
|
|
4253
|
+
import { Hono as Hono12 } from "hono";
|
|
4169
4254
|
|
|
4170
|
-
// src/features/automations/automations.schema.ts
|
|
4255
|
+
// src/features/automations/api/automations.schema.ts
|
|
4171
4256
|
import { z as z2 } from "zod";
|
|
4172
4257
|
import { isPrivateHost } from "@beechcms/core";
|
|
4173
4258
|
var whenOperandSchema = z2.union([
|
|
@@ -4298,8 +4383,8 @@ var toggleAutomationSchema = z2.object({
|
|
|
4298
4383
|
enabled: z2.boolean()
|
|
4299
4384
|
});
|
|
4300
4385
|
|
|
4301
|
-
// src/features/automations/automations.handler.ts
|
|
4302
|
-
var automationsApp = new
|
|
4386
|
+
// src/features/automations/api/automations.handler.ts
|
|
4387
|
+
var automationsApp = new Hono12();
|
|
4303
4388
|
automationsApp.get("/", async (context) => {
|
|
4304
4389
|
const seedSlug = context.req.query("seed");
|
|
4305
4390
|
if (!seedSlug) {
|
|
@@ -4445,7 +4530,7 @@ automationsApp.delete("/:id", async (context) => {
|
|
|
4445
4530
|
});
|
|
4446
4531
|
|
|
4447
4532
|
// src/features/stats/stats.handler.ts
|
|
4448
|
-
import { Hono as
|
|
4533
|
+
import { Hono as Hono13 } from "hono";
|
|
4449
4534
|
import { SystemClock } from "@beechcms/core";
|
|
4450
4535
|
var DATABASE_ERROR = "Database error";
|
|
4451
4536
|
var INTERNAL_SERVER_ERROR = "Internal Server Error";
|
|
@@ -4467,7 +4552,7 @@ function getMimeType(extension) {
|
|
|
4467
4552
|
const type = extension === "jpg" ? "jpeg" : extension || "jpeg";
|
|
4468
4553
|
return `image/${type}`;
|
|
4469
4554
|
}
|
|
4470
|
-
var statsApp = new
|
|
4555
|
+
var statsApp = new Hono13();
|
|
4471
4556
|
statsApp.get("/stats/media-library", async (context) => {
|
|
4472
4557
|
try {
|
|
4473
4558
|
const mediaRepository = context.get("mediaRepository");
|
|
@@ -4763,7 +4848,7 @@ statsApp.post("/stats/storage/sync", async (context) => {
|
|
|
4763
4848
|
});
|
|
4764
4849
|
|
|
4765
4850
|
// src/features/backrefs/backrefs.handler.ts
|
|
4766
|
-
import { Hono as
|
|
4851
|
+
import { Hono as Hono14 } from "hono";
|
|
4767
4852
|
import { resolvePolicies as resolvePolicies6 } from "@beechcms/core";
|
|
4768
4853
|
|
|
4769
4854
|
// src/features/backrefs/d1-backref.repository.ts
|
|
@@ -4815,7 +4900,7 @@ var D1BackrefRepository = class {
|
|
|
4815
4900
|
var PREVIEW_LIMIT = 3;
|
|
4816
4901
|
var DEFAULT_PAGE_LIMIT = 20;
|
|
4817
4902
|
var MAX_PAGE_LIMIT = 100;
|
|
4818
|
-
var backrefsApp = new
|
|
4903
|
+
var backrefsApp = new Hono14();
|
|
4819
4904
|
backrefsApp.get("/:targetSlug/:targetId/backrefs", async (c) => {
|
|
4820
4905
|
const targetSlug = c.req.param("targetSlug");
|
|
4821
4906
|
const targetId = c.req.param("targetId");
|
|
@@ -4925,61 +5010,234 @@ function filterVisibility(items, sourceSeed) {
|
|
|
4925
5010
|
if (visibility === "hidden") {
|
|
4926
5011
|
return items.map((item) => ({ ...item, displayName: null }));
|
|
4927
5012
|
}
|
|
4928
|
-
if (visibility === "masked") {
|
|
4929
|
-
return items.map((item) => ({
|
|
4930
|
-
...item,
|
|
4931
|
-
displayName: item.displayName ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : null
|
|
4932
|
-
}));
|
|
5013
|
+
if (visibility === "masked") {
|
|
5014
|
+
return items.map((item) => ({
|
|
5015
|
+
...item,
|
|
5016
|
+
displayName: item.displayName ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : null
|
|
5017
|
+
}));
|
|
5018
|
+
}
|
|
5019
|
+
return items;
|
|
5020
|
+
}
|
|
5021
|
+
|
|
5022
|
+
// src/features/webhooks/index.ts
|
|
5023
|
+
import { Hono as Hono15 } from "hono";
|
|
5024
|
+
import { Receiver } from "@upstash/qstash";
|
|
5025
|
+
var webhooksApp = new Hono15();
|
|
5026
|
+
webhooksApp.post("/qstash", async (context) => {
|
|
5027
|
+
const currentSigningKey = context.env.QSTASH_CURRENT_SIGNING_KEY;
|
|
5028
|
+
const nextSigningKey = context.env.QSTASH_NEXT_SIGNING_KEY;
|
|
5029
|
+
if (!currentSigningKey || !nextSigningKey) {
|
|
5030
|
+
console.error("QStash webhook called but signing keys are not configured");
|
|
5031
|
+
return context.text("Configuration error", 500);
|
|
5032
|
+
}
|
|
5033
|
+
const receiver = new Receiver({
|
|
5034
|
+
currentSigningKey,
|
|
5035
|
+
nextSigningKey
|
|
5036
|
+
});
|
|
5037
|
+
const body = await context.req.text();
|
|
5038
|
+
const signature = context.req.header("Upstash-Signature");
|
|
5039
|
+
if (!signature) {
|
|
5040
|
+
return context.text("Missing signature", 401);
|
|
5041
|
+
}
|
|
5042
|
+
try {
|
|
5043
|
+
const isValid = await receiver.verify({
|
|
5044
|
+
signature,
|
|
5045
|
+
body
|
|
5046
|
+
});
|
|
5047
|
+
if (!isValid) {
|
|
5048
|
+
return context.text("Invalid signature", 401);
|
|
5049
|
+
}
|
|
5050
|
+
} catch (err) {
|
|
5051
|
+
console.error("QStash signature verification failed", err);
|
|
5052
|
+
return context.text("Invalid signature", 401);
|
|
5053
|
+
}
|
|
5054
|
+
try {
|
|
5055
|
+
const input = JSON.parse(body);
|
|
5056
|
+
const notificationRepository = context.get("notificationRepository");
|
|
5057
|
+
await notificationRepository.create({
|
|
5058
|
+
title: input.title,
|
|
5059
|
+
message: input.message,
|
|
5060
|
+
type: input.type ?? "info"
|
|
5061
|
+
});
|
|
5062
|
+
return context.text("OK", 200);
|
|
5063
|
+
} catch (error) {
|
|
5064
|
+
console.error("Failed to process QStash webhook payload", error);
|
|
5065
|
+
return context.text("Internal server error", 500);
|
|
5066
|
+
}
|
|
5067
|
+
});
|
|
5068
|
+
|
|
5069
|
+
// src/features/upload/index.ts
|
|
5070
|
+
import { Hono as Hono16 } from "hono";
|
|
5071
|
+
import { isMimeAccepted } from "@beechcms/core";
|
|
5072
|
+
var DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
5073
|
+
var ABSOLUTE_MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
|
|
5074
|
+
var PRESIGN_TTL_SECONDS = 900;
|
|
5075
|
+
function resolveMaxUploadBytes(env) {
|
|
5076
|
+
const raw = env.MAX_UPLOAD_BYTES;
|
|
5077
|
+
if (!raw) return DEFAULT_MAX_UPLOAD_BYTES;
|
|
5078
|
+
const parsed = Number.parseInt(raw, 10);
|
|
5079
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_MAX_UPLOAD_BYTES;
|
|
5080
|
+
return Math.min(parsed, ABSOLUTE_MAX_UPLOAD_BYTES);
|
|
5081
|
+
}
|
|
5082
|
+
function sanitizeFilename(name) {
|
|
5083
|
+
const base = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 100);
|
|
5084
|
+
return base || "file";
|
|
5085
|
+
}
|
|
5086
|
+
function generateObjectKey(originalName) {
|
|
5087
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
5088
|
+
return `${timestamp}-${sanitizeFilename(originalName)}`;
|
|
5089
|
+
}
|
|
5090
|
+
var FORCE_DOWNLOAD_MIME_PREFIXES = [
|
|
5091
|
+
"image/svg",
|
|
5092
|
+
"text/",
|
|
5093
|
+
"application/xml",
|
|
5094
|
+
"application/xhtml",
|
|
5095
|
+
"application/javascript",
|
|
5096
|
+
"application/x-javascript"
|
|
5097
|
+
];
|
|
5098
|
+
function isActiveMimeType(mime) {
|
|
5099
|
+
const lower = mime.toLowerCase();
|
|
5100
|
+
return FORCE_DOWNLOAD_MIME_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
|
5101
|
+
}
|
|
5102
|
+
var uploadRoutes = new Hono16();
|
|
5103
|
+
uploadRoutes.post("/upload/presign", async (c) => {
|
|
5104
|
+
let body;
|
|
5105
|
+
try {
|
|
5106
|
+
body = await c.req.json();
|
|
5107
|
+
} catch {
|
|
5108
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
5109
|
+
}
|
|
5110
|
+
const { filename, mimeType, sizeBytes } = body;
|
|
5111
|
+
if (typeof filename !== "string" || !filename.trim()) return c.json({ error: "filename is required" }, 400);
|
|
5112
|
+
if (typeof mimeType !== "string" || !mimeType.trim()) return c.json({ error: "mimeType is required" }, 400);
|
|
5113
|
+
if (typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || sizeBytes <= 0) {
|
|
5114
|
+
return c.json({ error: "sizeBytes must be a positive number" }, 400);
|
|
5115
|
+
}
|
|
5116
|
+
const maxBytes = resolveMaxUploadBytes(c.env);
|
|
5117
|
+
if (sizeBytes > maxBytes) return c.json({ error: `File too large. Max ${maxBytes} bytes` }, 400);
|
|
5118
|
+
if (!isMimeAccepted(mimeType, "any")) return c.json({ error: "File type not allowed" }, 400);
|
|
5119
|
+
const key = generateObjectKey(filename);
|
|
5120
|
+
const uploadUrl = await c.var.bucket.presignPut(key, {
|
|
5121
|
+
expiresIn: PRESIGN_TTL_SECONDS,
|
|
5122
|
+
contentType: mimeType,
|
|
5123
|
+
contentLength: sizeBytes
|
|
5124
|
+
});
|
|
5125
|
+
return c.json({ uploadUrl, key, expiresIn: PRESIGN_TTL_SECONDS }, 200);
|
|
5126
|
+
});
|
|
5127
|
+
uploadRoutes.post("/upload/confirm", async (c) => {
|
|
5128
|
+
const { bucket, mediaRepository: mediaRepo, systemStatsRepository: statsRepo } = c.var;
|
|
5129
|
+
let body;
|
|
5130
|
+
try {
|
|
5131
|
+
body = await c.req.json();
|
|
5132
|
+
} catch {
|
|
5133
|
+
return c.json({ error: "Invalid JSON body" }, 400);
|
|
5134
|
+
}
|
|
5135
|
+
const { key } = body;
|
|
5136
|
+
if (typeof key !== "string" || !key.trim()) return c.json({ error: "key is required" }, 400);
|
|
5137
|
+
const sanitizedKey = decodeURIComponent(key).replace(/\.\.[/\\]/g, "").replace(/[^a-zA-Z0-9._\-/]/g, "");
|
|
5138
|
+
if (!sanitizedKey || !/^\d+-[a-zA-Z0-9._-]+$/.test(sanitizedKey)) {
|
|
5139
|
+
return c.json({ error: "Invalid key format" }, 400);
|
|
4933
5140
|
}
|
|
4934
|
-
|
|
4935
|
-
}
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
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);
|
|
5141
|
+
const existing = await mediaRepo.getByKey(sanitizedKey);
|
|
5142
|
+
if (existing) return c.json({ url: bucket.getUrl(sanitizedKey) }, 200);
|
|
5143
|
+
const head = await bucket.head(sanitizedKey);
|
|
5144
|
+
if (!head) return c.json({ error: "Object not found in storage" }, 404);
|
|
5145
|
+
const size = head.size ?? 0;
|
|
5146
|
+
const maxBytes = resolveMaxUploadBytes(c.env);
|
|
5147
|
+
if (size > maxBytes) {
|
|
5148
|
+
return c.json({ error: `File too large. Max ${maxBytes} bytes` }, 400);
|
|
4947
5149
|
}
|
|
4948
|
-
const
|
|
4949
|
-
|
|
4950
|
-
|
|
5150
|
+
const uploadedBy = c.var.jwtPayload?.sub ?? "";
|
|
5151
|
+
const mime = head.contentType ?? "application/octet-stream";
|
|
5152
|
+
const filename = sanitizedKey.replace(/^\d+-/, "") || sanitizedKey;
|
|
5153
|
+
await statsRepo.incrementStorage(size);
|
|
5154
|
+
await mediaRepo.trackUpload({
|
|
5155
|
+
key: sanitizedKey,
|
|
5156
|
+
filename,
|
|
5157
|
+
mime_type: mime,
|
|
5158
|
+
size_bytes: size,
|
|
5159
|
+
uploaded_by: uploadedBy
|
|
4951
5160
|
});
|
|
4952
|
-
const
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
5161
|
+
const jwtPayload = c.get("jwtPayload");
|
|
5162
|
+
if (jwtPayload) {
|
|
5163
|
+
c.get("activityLogger").log({
|
|
5164
|
+
action: "upload",
|
|
5165
|
+
entityType: "media",
|
|
5166
|
+
entityId: sanitizedKey,
|
|
5167
|
+
details: { name: filename, size, type: mime },
|
|
5168
|
+
actor: {
|
|
5169
|
+
id: jwtPayload.sub,
|
|
5170
|
+
email: jwtPayload.email ?? "unknown",
|
|
5171
|
+
name: [jwtPayload.name, jwtPayload.surname].filter(Boolean).join(" ") || null
|
|
5172
|
+
}
|
|
5173
|
+
});
|
|
5174
|
+
}
|
|
5175
|
+
return c.json({ url: bucket.getUrl(sanitizedKey) }, 200);
|
|
5176
|
+
});
|
|
5177
|
+
uploadRoutes.get("/upload/download-url/:key", async (c) => {
|
|
5178
|
+
let key = c.req.param("key");
|
|
5179
|
+
if (!key) return c.json({ error: "Missing key" }, 400);
|
|
5180
|
+
key = decodeURIComponent(key);
|
|
5181
|
+
key = key.replace(/\.\.[/\\]/g, "");
|
|
5182
|
+
key = key.replace(/[^a-zA-Z0-9._\-/]/g, "");
|
|
5183
|
+
if (!key) return c.json({ error: "Invalid key" }, 400);
|
|
5184
|
+
const media = await c.var.mediaRepository.getByKey(key).catch(() => null);
|
|
5185
|
+
if (!media) {
|
|
5186
|
+
return c.json({ error: "Media not found" }, 404);
|
|
5187
|
+
}
|
|
5188
|
+
const jwtPayload = c.var.jwtPayload;
|
|
5189
|
+
const userId = jwtPayload?.sub;
|
|
5190
|
+
const isAdmin = jwtPayload?.role === "admin";
|
|
5191
|
+
if (!isAdmin && userId !== media.uploaded_by) {
|
|
5192
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
4956
5193
|
}
|
|
5194
|
+
const head = await c.var.bucket.head(key);
|
|
5195
|
+
if (!head) return c.json({ error: "Object not found" }, 404);
|
|
5196
|
+
const downloadUrl = await c.var.bucket.presignGet(key, { expiresIn: PRESIGN_TTL_SECONDS });
|
|
5197
|
+
return c.json({ downloadUrl, expiresIn: PRESIGN_TTL_SECONDS }, 200);
|
|
5198
|
+
});
|
|
5199
|
+
uploadRoutes.delete("/upload/:key", async (c) => {
|
|
5200
|
+
let key = c.req.param("key");
|
|
5201
|
+
if (!key) return c.json({ error: "Missing key" }, 400);
|
|
5202
|
+
key = key.replace(/\.\.[/\\]/g, "");
|
|
5203
|
+
key = key.replace(/[^a-zA-Z0-9._\-/]/g, "");
|
|
5204
|
+
if (!key) return c.json({ error: "Invalid key" }, 400);
|
|
5205
|
+
const media = await c.var.mediaRepository.getByKey(key).catch(() => null);
|
|
5206
|
+
if (!media) {
|
|
5207
|
+
return c.json({ error: "Media not found" }, 404);
|
|
5208
|
+
}
|
|
5209
|
+
const jwtPayload = c.var.jwtPayload;
|
|
5210
|
+
const userId = jwtPayload?.sub;
|
|
5211
|
+
const isAdmin = jwtPayload?.role === "admin";
|
|
5212
|
+
if (!isAdmin && userId !== media.uploaded_by) {
|
|
5213
|
+
return c.json({ error: "Forbidden" }, 403);
|
|
5214
|
+
}
|
|
5215
|
+
await deleteR2Objects(c, [key]);
|
|
5216
|
+
return c.json({ success: true }, 200);
|
|
5217
|
+
});
|
|
5218
|
+
async function serveMediaHandler(c) {
|
|
5219
|
+
const key = c.req.param("key");
|
|
5220
|
+
if (!key) return new Response("Missing key", { status: 400 });
|
|
4957
5221
|
try {
|
|
4958
|
-
const
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
if (
|
|
4963
|
-
|
|
5222
|
+
const object = await c.var.bucket.get(key);
|
|
5223
|
+
if (!object) return new Response("Not found", { status: 404 });
|
|
5224
|
+
const headers = new Headers();
|
|
5225
|
+
const originalMime = object.contentType ?? "application/octet-stream";
|
|
5226
|
+
if (isActiveMimeType(originalMime)) {
|
|
5227
|
+
headers.set("Content-Type", "application/octet-stream");
|
|
5228
|
+
headers.set("Content-Disposition", "attachment");
|
|
5229
|
+
} else {
|
|
5230
|
+
headers.set("Content-Type", originalMime);
|
|
4964
5231
|
}
|
|
5232
|
+
headers.set("Content-Security-Policy", "default-src 'none'; sandbox");
|
|
5233
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
5234
|
+
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
|
5235
|
+
return new Response(object.body, { status: 200, headers });
|
|
4965
5236
|
} 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);
|
|
5237
|
+
console.error(`[serveMediaHandler] Error serving file ${key}:`, err);
|
|
5238
|
+
return new Response("Internal error", { status: 500 });
|
|
4981
5239
|
}
|
|
4982
|
-
}
|
|
5240
|
+
}
|
|
4983
5241
|
|
|
4984
5242
|
// src/public/public-errors.ts
|
|
4985
5243
|
var PUBLIC_ERRORS = {
|
|
@@ -5040,11 +5298,16 @@ function publicRateLimitMiddleware() {
|
|
|
5040
5298
|
const key = `${getClientIp(c.req)}:${seed}:${limiterName}`;
|
|
5041
5299
|
const result = await c.get("rateLimiters").getLimiter(limiterName).checkLimit(key);
|
|
5042
5300
|
if (!result.isAllowed) {
|
|
5301
|
+
const headers = {};
|
|
5302
|
+
if (result.retryAfterSeconds !== void 0) {
|
|
5303
|
+
headers["Retry-After"] = String(result.retryAfterSeconds);
|
|
5304
|
+
}
|
|
5043
5305
|
return publicProblem(c, {
|
|
5044
5306
|
type: "rate-limit-exceeded",
|
|
5045
5307
|
title: "Too Many Requests",
|
|
5046
5308
|
status: 429,
|
|
5047
|
-
detail: "Too many requests"
|
|
5309
|
+
detail: "Too many requests",
|
|
5310
|
+
headers
|
|
5048
5311
|
});
|
|
5049
5312
|
}
|
|
5050
5313
|
await next();
|
|
@@ -5363,10 +5626,10 @@ import { isValidContentStatus as isValidContentStatus3, SlugConflictError as Slu
|
|
|
5363
5626
|
import { slugify as slugify3, generateEntrySlug } from "@beechcms/core";
|
|
5364
5627
|
|
|
5365
5628
|
// src/public/sanitize.ts
|
|
5366
|
-
import { validateAndSanitizeSeedPayload as
|
|
5629
|
+
import { validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload6 } from "@beechcms/core";
|
|
5367
5630
|
function sanitizePublicPayload(seed, payload, options = {}) {
|
|
5368
5631
|
const operation = options.operation ?? "create";
|
|
5369
|
-
const result =
|
|
5632
|
+
const result = validateAndSanitizeSeedPayload6(seed, payload, {
|
|
5370
5633
|
allowNull: options.allowNull ?? false,
|
|
5371
5634
|
operation,
|
|
5372
5635
|
requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
|
|
@@ -5678,10 +5941,10 @@ publicApp.post("/:seed/add", publicAddHandler);
|
|
|
5678
5941
|
publicApp.put("/:seed/edit/:id", publicEditHandler);
|
|
5679
5942
|
var publicRoutes = publicApp;
|
|
5680
5943
|
|
|
5681
|
-
// src/search.ts
|
|
5944
|
+
// src/features/search/search.ts
|
|
5682
5945
|
import { Hono as Hono18 } from "hono";
|
|
5683
5946
|
|
|
5684
|
-
// src/search-utils.ts
|
|
5947
|
+
// src/features/search/search-utils.ts
|
|
5685
5948
|
function encodeCursor(rank, entryId) {
|
|
5686
5949
|
return btoa(`${rank}:${entryId}`);
|
|
5687
5950
|
}
|
|
@@ -5786,7 +6049,7 @@ function mapSearchResultRow(row) {
|
|
|
5786
6049
|
};
|
|
5787
6050
|
}
|
|
5788
6051
|
|
|
5789
|
-
// src/search.ts
|
|
6052
|
+
// src/features/search/search.ts
|
|
5790
6053
|
var searchRouter = new Hono18();
|
|
5791
6054
|
searchRouter.use("*", authMiddleware());
|
|
5792
6055
|
searchRouter.get("/", async (c) => {
|
|
@@ -5827,19 +6090,19 @@ searchRouter.get("/", async (c) => {
|
|
|
5827
6090
|
// src/middleware/repository.middleware.ts
|
|
5828
6091
|
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
5829
6092
|
|
|
5830
|
-
// src/shared/content.repository.d1.ts
|
|
6093
|
+
// src/shared/db/repositories/content.repository.d1.ts
|
|
5831
6094
|
import {
|
|
5832
6095
|
EntryNotFoundError as EntryNotFoundError7,
|
|
5833
6096
|
RepositoryError as RepositoryError2,
|
|
5834
|
-
SlugConflictError as
|
|
6097
|
+
SlugConflictError as SlugConflictError4,
|
|
5835
6098
|
RelationTargetNotFoundError as RelationTargetNotFoundError2,
|
|
5836
6099
|
buildSelectQuery,
|
|
5837
6100
|
deserializeFromDb as deserializeFromDb2,
|
|
5838
6101
|
serializeForDb
|
|
5839
6102
|
} from "@beechcms/core";
|
|
5840
6103
|
|
|
5841
|
-
// src/shared/base.repository.d1.ts
|
|
5842
|
-
import { RepositoryError } from "@beechcms/core";
|
|
6104
|
+
// src/shared/db/repositories/base.repository.d1.ts
|
|
6105
|
+
import { RepositoryError, SlugConflictError as SlugConflictError3 } from "@beechcms/core";
|
|
5843
6106
|
var BaseD1Repository = class {
|
|
5844
6107
|
constructor(database) {
|
|
5845
6108
|
this.database = database;
|
|
@@ -5857,11 +6120,14 @@ var BaseD1Repository = class {
|
|
|
5857
6120
|
*/
|
|
5858
6121
|
mapError(error, context) {
|
|
5859
6122
|
const message = error?.message || "Unknown database error";
|
|
6123
|
+
if (message.includes("UNIQUE constraint failed:") && message.includes(".slug")) {
|
|
6124
|
+
return new SlugConflictError3(`${context}: ${message}`);
|
|
6125
|
+
}
|
|
5860
6126
|
return new RepositoryError(`${context}: ${message}`, error);
|
|
5861
6127
|
}
|
|
5862
6128
|
};
|
|
5863
6129
|
|
|
5864
|
-
// src/shared/content.repository.d1.ts
|
|
6130
|
+
// src/shared/db/repositories/content.repository.d1.ts
|
|
5865
6131
|
function multiRelBranches(seed) {
|
|
5866
6132
|
return seed.branches.filter((b) => b.type === "relation" && b.multiple === true);
|
|
5867
6133
|
}
|
|
@@ -5880,6 +6146,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
5880
6146
|
this.hooks = hooks;
|
|
5881
6147
|
}
|
|
5882
6148
|
hooks;
|
|
6149
|
+
/** Assembles the {@link HookContext} passed to every lifecycle hook invocation. */
|
|
5883
6150
|
hookCtx(seed, actor) {
|
|
5884
6151
|
return { seed, repository: this, actor, db: this.database };
|
|
5885
6152
|
}
|
|
@@ -5901,10 +6168,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
5901
6168
|
data[branch.alias] = deserializeFromDb2(branch, row[branch.alias]);
|
|
5902
6169
|
}
|
|
5903
6170
|
}
|
|
6171
|
+
if (row.position !== void 0) data.position = row.position;
|
|
5904
6172
|
return data;
|
|
5905
6173
|
}
|
|
5906
6174
|
/**
|
|
5907
6175
|
* Fetches multi-relation arrays for a single entry and attaches them to `data`.
|
|
6176
|
+
*
|
|
6177
|
+
* @param seed The seed schema definition.
|
|
6178
|
+
* @param entryId Id of the entry to fetch relation targets for.
|
|
6179
|
+
* @param data Mutated in place: one array property per multi-relation branch alias.
|
|
5908
6180
|
*/
|
|
5909
6181
|
async attachMultiRelations(seed, entryId, data) {
|
|
5910
6182
|
const branches = multiRelBranches(seed);
|
|
@@ -5918,51 +6190,85 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
5918
6190
|
}
|
|
5919
6191
|
}
|
|
5920
6192
|
/**
|
|
5921
|
-
*
|
|
5922
|
-
*
|
|
6193
|
+
* Builds one junction-table statement per multi-relation branch, selecting the rows whose
|
|
6194
|
+
* `parent_id` falls within the page defined by `idQuery` (the page selection re-projected to
|
|
6195
|
+
* ids only). Using a subquery instead of a bound `IN (?, ...)` id list keeps the statements
|
|
6196
|
+
* eligible for the same batch as the page query and avoids D1's bound-parameter limit.
|
|
6197
|
+
*
|
|
6198
|
+
* The subquery is wrapped in `SELECT id FROM (...)` because `kanbanOrder` projections carry an
|
|
6199
|
+
* extra `kp.position` column, and `IN` requires a single-column subselect.
|
|
5923
6200
|
*/
|
|
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(
|
|
6201
|
+
multiRelStatements(seed, branches, idQuery) {
|
|
6202
|
+
return branches.map(
|
|
5930
6203
|
(b) => this.database.prepare(
|
|
5931
|
-
`SELECT parent_id, target_id FROM ${jTable(seed.slug, b.alias)} WHERE parent_id IN (${
|
|
5932
|
-
).bind(...
|
|
6204
|
+
`SELECT parent_id, target_id FROM ${jTable(seed.slug, b.alias)} WHERE parent_id IN (SELECT id FROM (${idQuery.sql})) ORDER BY position ASC`
|
|
6205
|
+
).bind(...idQuery.bindings)
|
|
5933
6206
|
);
|
|
5934
|
-
|
|
6207
|
+
}
|
|
6208
|
+
/**
|
|
6209
|
+
* Groups junction rows by parent and attaches them to the entries: one array property per
|
|
6210
|
+
* multi-relation branch alias, per entry. `relResults[i]` must correspond to `branches[i]`.
|
|
6211
|
+
*
|
|
6212
|
+
* @param entries Mutated in place.
|
|
6213
|
+
*/
|
|
6214
|
+
attachMultiRelationRows(branches, relResults, entries) {
|
|
5935
6215
|
for (let i = 0; i < branches.length; i++) {
|
|
5936
6216
|
const branch = branches[i];
|
|
5937
6217
|
const byParent = /* @__PURE__ */ new Map();
|
|
5938
|
-
for (const row of
|
|
6218
|
+
for (const row of relResults[i].results ?? []) {
|
|
5939
6219
|
const r = row;
|
|
5940
6220
|
const pid = r.parent_id;
|
|
5941
|
-
|
|
5942
|
-
|
|
6221
|
+
let targets = byParent.get(pid);
|
|
6222
|
+
if (!targets) {
|
|
6223
|
+
targets = [];
|
|
6224
|
+
byParent.set(pid, targets);
|
|
6225
|
+
}
|
|
6226
|
+
targets.push(r.target_id);
|
|
5943
6227
|
}
|
|
5944
6228
|
for (const entry of entries) {
|
|
5945
6229
|
entry[branch.alias] = byParent.get(entry.id) ?? [];
|
|
5946
6230
|
}
|
|
5947
6231
|
}
|
|
5948
6232
|
}
|
|
6233
|
+
/**
|
|
6234
|
+
* Finds multiple entries for a given seed table based on the provided query options.
|
|
6235
|
+
* Executes a single batch (one D1 round-trip) containing:
|
|
6236
|
+
* 1. A selection query to retrieve the items for the current page (with columns, limits, offsets, and ordering).
|
|
6237
|
+
* 2. A count query (using `isCount: true`) to retrieve the total number of matched items matching the same filters.
|
|
6238
|
+
* 3. One junction-table query per multi-relation branch, scoped to the page via an id subquery.
|
|
6239
|
+
*
|
|
6240
|
+
* @param seed The seed schema definition.
|
|
6241
|
+
* @param options Filtering, search, ordering, and pagination options.
|
|
6242
|
+
* @returns An object containing the list of serialized entries and the total count of matched items.
|
|
6243
|
+
* @throws RepositoryError if the database batch execution fails.
|
|
6244
|
+
*/
|
|
5949
6245
|
async findMany(seed, options) {
|
|
5950
6246
|
try {
|
|
5951
6247
|
const { sql, bindings } = buildSelectQuery(seed, options);
|
|
5952
|
-
const
|
|
5953
|
-
|
|
5954
|
-
|
|
6248
|
+
const { sql: countSql, bindings: countBindings } = buildSelectQuery(seed, {
|
|
6249
|
+
...options,
|
|
6250
|
+
isCount: true
|
|
6251
|
+
});
|
|
6252
|
+
const branches = multiRelBranches(seed);
|
|
6253
|
+
const relStmts = branches.length > 0 ? this.multiRelStatements(seed, branches, buildSelectQuery(seed, { ...options, fields: ["id"] })) : [];
|
|
6254
|
+
const [batchResults, totalCountResult, ...relResults] = await this.database.batch([
|
|
5955
6255
|
this.database.prepare(sql).bind(...bindings),
|
|
5956
|
-
this.database.prepare(countSql).bind(...countBindings)
|
|
6256
|
+
this.database.prepare(countSql).bind(...countBindings),
|
|
6257
|
+
...relStmts
|
|
5957
6258
|
]);
|
|
5958
6259
|
const contentEntries = (batchResults.results || []).map((row) => this.rowToData(seed, row));
|
|
5959
6260
|
const totalEntriesCount = totalCountResult.results?.[0]?.total || 0;
|
|
5960
|
-
|
|
6261
|
+
this.attachMultiRelationRows(branches, relResults, contentEntries);
|
|
5961
6262
|
return { items: contentEntries, total: totalEntriesCount };
|
|
5962
6263
|
} catch (error) {
|
|
5963
6264
|
throw this.mapError(error, `findMany(${seed.slug})`);
|
|
5964
6265
|
}
|
|
5965
6266
|
}
|
|
6267
|
+
/**
|
|
6268
|
+
* Fetches a single entry by id, including its multi-relation arrays.
|
|
6269
|
+
* @throws EntryNotFoundError if no row with `id` exists in the seed's table.
|
|
6270
|
+
* @throws RepositoryError on any other database failure.
|
|
6271
|
+
*/
|
|
5966
6272
|
async findById(seed, id) {
|
|
5967
6273
|
try {
|
|
5968
6274
|
const tableName = this.getTableName(seed.slug);
|
|
@@ -5978,6 +6284,11 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
5978
6284
|
throw this.mapError(error, `findById(${seed.slug}, ${id})`);
|
|
5979
6285
|
}
|
|
5980
6286
|
}
|
|
6287
|
+
/**
|
|
6288
|
+
* Fetches a single entry by its unique `slug`, including its multi-relation arrays.
|
|
6289
|
+
* @throws EntryNotFoundError if no row with `slug` exists in the seed's table.
|
|
6290
|
+
* @throws RepositoryError on any other database failure.
|
|
6291
|
+
*/
|
|
5981
6292
|
async findBySlug(seed, slug) {
|
|
5982
6293
|
try {
|
|
5983
6294
|
const tableName = this.getTableName(seed.slug);
|
|
@@ -5993,6 +6304,11 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
5993
6304
|
throw this.mapError(error, `findBySlug(${seed.slug}, ${slug})`);
|
|
5994
6305
|
}
|
|
5995
6306
|
}
|
|
6307
|
+
/**
|
|
6308
|
+
* Computes aggregate facets for a seed: entry counts per `status`, and the distinct set
|
|
6309
|
+
* of tag values present across all entries for each `tags`-typed branch (via `json_each`
|
|
6310
|
+
* over the JSON-serialized tag column).
|
|
6311
|
+
*/
|
|
5996
6312
|
async getFacets(seed) {
|
|
5997
6313
|
try {
|
|
5998
6314
|
const tableName = this.getTableName(seed.slug);
|
|
@@ -6012,6 +6328,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6012
6328
|
throw this.mapError(error, `getFacets(${seed.slug})`);
|
|
6013
6329
|
}
|
|
6014
6330
|
}
|
|
6331
|
+
/**
|
|
6332
|
+
* Checks whether `slug` is already taken in the seed's table.
|
|
6333
|
+
* @param excludeId When provided (e.g. during an update), excludes that entry's own row from the check.
|
|
6334
|
+
*/
|
|
6015
6335
|
async existsSlug(seed, slug, excludeId) {
|
|
6016
6336
|
try {
|
|
6017
6337
|
const tableName = this.getTableName(seed.slug);
|
|
@@ -6027,6 +6347,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6027
6347
|
throw this.mapError(error, `existsSlug(${seed.slug}, ${slug})`);
|
|
6028
6348
|
}
|
|
6029
6349
|
}
|
|
6350
|
+
/**
|
|
6351
|
+
* Builds the `INSERT` statement for an entry's main table row. Multi-relation branch
|
|
6352
|
+
* values are skipped (they're written as separate junction-table inserts by the caller).
|
|
6353
|
+
*/
|
|
6030
6354
|
buildCreateMainStmt(seed, id, slug, status, data) {
|
|
6031
6355
|
const tableName = this.getTableName(seed.slug);
|
|
6032
6356
|
const columnNames = ["id", "slug", "status"];
|
|
@@ -6044,6 +6368,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6044
6368
|
const mainSql = `INSERT INTO ${tableName} (${columnNames.join(", ")}) VALUES (${placeholders.join(", ")})`;
|
|
6045
6369
|
return this.database.prepare(mainSql).bind(...queryBindings);
|
|
6046
6370
|
}
|
|
6371
|
+
/**
|
|
6372
|
+
* Builds the `UPDATE` statement for an entry's main table row from whichever branch
|
|
6373
|
+
* aliases are present in `data` (partial update — absent keys are left untouched).
|
|
6374
|
+
* Multi-relation branches present in `data` are returned as `junctionUpdates` instead
|
|
6375
|
+
* of being included in the SQL, since they require separate delete+insert statements.
|
|
6376
|
+
*
|
|
6377
|
+
* @returns `stmt: null` when there is nothing to update on the main row (no scalar
|
|
6378
|
+
* fields and no `status` change) even if junction updates are still needed.
|
|
6379
|
+
*/
|
|
6047
6380
|
buildUpdateMainStmt(seed, id, data, status) {
|
|
6048
6381
|
const tableName = this.getTableName(seed.slug);
|
|
6049
6382
|
const updateClauses = [];
|
|
@@ -6054,6 +6387,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6054
6387
|
updateClauses.push("status = ?");
|
|
6055
6388
|
queryBindings.push(status);
|
|
6056
6389
|
}
|
|
6390
|
+
if (data.slug) {
|
|
6391
|
+
updateClauses.push("slug = ?");
|
|
6392
|
+
queryBindings.push(data.slug);
|
|
6393
|
+
}
|
|
6057
6394
|
for (const branch of seed.branches) {
|
|
6058
6395
|
if (mRelAliases.has(branch.alias)) continue;
|
|
6059
6396
|
if (Object.hasOwn(data, branch.alias)) {
|
|
@@ -6073,18 +6410,28 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6073
6410
|
junctionUpdates
|
|
6074
6411
|
};
|
|
6075
6412
|
}
|
|
6413
|
+
/** Builds one `INSERT` per target id into a live multi-relation junction table, preserving array order via `position`. */
|
|
6076
6414
|
buildJunctionInserts(seedSlug, parentId, branchAlias, targetIds) {
|
|
6077
6415
|
const jt = jTable(seedSlug, branchAlias);
|
|
6078
6416
|
return targetIds.map(
|
|
6079
6417
|
(targetId, i) => this.database.prepare(`INSERT INTO ${jt} (parent_id, target_id, position) VALUES (?, ?, ?)`).bind(parentId, targetId, i)
|
|
6080
6418
|
);
|
|
6081
6419
|
}
|
|
6420
|
+
/** Builds one `INSERT` per target id into a draft multi-relation junction table, preserving array order via `position`. */
|
|
6082
6421
|
buildDraftJunctionInserts(seedSlug, entryId, branchAlias, targetIds) {
|
|
6083
6422
|
const jdt = jDraftTable(seedSlug, branchAlias);
|
|
6084
6423
|
return targetIds.map(
|
|
6085
6424
|
(targetId, i) => this.database.prepare(`INSERT INTO ${jdt} (entry_id, target_id, position) VALUES (?, ?, ?)`).bind(entryId, targetId, i)
|
|
6086
6425
|
);
|
|
6087
6426
|
}
|
|
6427
|
+
/**
|
|
6428
|
+
* Validates that every relation target referenced by a draft still exists before publish,
|
|
6429
|
+
* for both single-relation columns and multi-relation draft junction rows.
|
|
6430
|
+
*
|
|
6431
|
+
* @throws RelationTargetNotFoundError if any referenced target id is missing from its target seed's table.
|
|
6432
|
+
* @returns The multi-relation branches and their resolved (validated) draft target ids,
|
|
6433
|
+
* so the caller can reuse them without re-querying the draft junction tables.
|
|
6434
|
+
*/
|
|
6088
6435
|
async validatePublishDraftRelations(seed, draftRow, entryId) {
|
|
6089
6436
|
for (const branch of singleRelBranches(seed)) {
|
|
6090
6437
|
const value = draftRow[branch.alias];
|
|
@@ -6118,6 +6465,12 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6118
6465
|
}
|
|
6119
6466
|
return { mRelBranches, mRelDraftIds };
|
|
6120
6467
|
}
|
|
6468
|
+
/**
|
|
6469
|
+
* Builds the junction-table statements for one array-typed {@link BulkFieldUpdate}:
|
|
6470
|
+
* `array_replace` (delete-all + reinsert), `array_add` (append via `INSERT OR IGNORE`
|
|
6471
|
+
* with position computed from current max), or `array_remove` (targeted delete).
|
|
6472
|
+
* Returns an empty array for a no-op (e.g. `array_remove` with an empty value list).
|
|
6473
|
+
*/
|
|
6121
6474
|
getBulkArrayUpdateStmts(seedSlug, id, alias, update) {
|
|
6122
6475
|
const jt = jTable(seedSlug, alias);
|
|
6123
6476
|
const stmts = [];
|
|
@@ -6143,16 +6496,23 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6143
6496
|
}
|
|
6144
6497
|
return stmts;
|
|
6145
6498
|
}
|
|
6146
|
-
|
|
6499
|
+
/**
|
|
6500
|
+
* Applies a bulk field update to a single entry as one batch: a main-table `UPDATE` for
|
|
6501
|
+
* `set`-kind fields plus any junction statements for array-kind fields.
|
|
6502
|
+
* @throws Error('not-found') if the main `UPDATE` affects zero rows (id doesn't exist).
|
|
6503
|
+
*/
|
|
6504
|
+
async processBulkUpdateSingle(seed, id, fields, tableName) {
|
|
6147
6505
|
const stmts = [];
|
|
6148
6506
|
const setClauses = ["updated_at = (unixepoch())"];
|
|
6149
6507
|
const setBindings = [];
|
|
6150
6508
|
for (const [alias, update] of Object.entries(fields)) {
|
|
6151
6509
|
if (update.kind === "set") {
|
|
6152
|
-
|
|
6153
|
-
|
|
6510
|
+
const branch = seed.branches.find((b) => b.alias === alias);
|
|
6511
|
+
const serializedValue = branch ? serializeForDb(branch, update.value) : update.value;
|
|
6512
|
+
setClauses.push(`${alias} = ?`);
|
|
6513
|
+
setBindings.push(serializedValue);
|
|
6154
6514
|
} else {
|
|
6155
|
-
stmts.push(...this.getBulkArrayUpdateStmts(
|
|
6515
|
+
stmts.push(...this.getBulkArrayUpdateStmts(seed.slug, id, alias, update));
|
|
6156
6516
|
}
|
|
6157
6517
|
}
|
|
6158
6518
|
stmts.unshift(
|
|
@@ -6163,12 +6523,17 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6163
6523
|
throw new Error("not-found");
|
|
6164
6524
|
}
|
|
6165
6525
|
}
|
|
6166
|
-
|
|
6526
|
+
/**
|
|
6527
|
+
* Sequentially applies a bulk field update to each id in `chunk`, isolating per-id
|
|
6528
|
+
* failures (not-found, FK violation, or other error) into the `failed` list rather than
|
|
6529
|
+
* aborting the whole chunk.
|
|
6530
|
+
*/
|
|
6531
|
+
async processBulkUpdateChunk(seed, chunk, fields, tableName) {
|
|
6167
6532
|
let updated = 0;
|
|
6168
6533
|
const failed = [];
|
|
6169
6534
|
for (const id of chunk) {
|
|
6170
6535
|
try {
|
|
6171
|
-
await this.processBulkUpdateSingle(
|
|
6536
|
+
await this.processBulkUpdateSingle(seed, id, fields, tableName);
|
|
6172
6537
|
updated++;
|
|
6173
6538
|
} catch (err) {
|
|
6174
6539
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -6182,6 +6547,14 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6182
6547
|
}
|
|
6183
6548
|
return { updated, failed };
|
|
6184
6549
|
}
|
|
6550
|
+
/**
|
|
6551
|
+
* Creates a new entry: runs `beforeCreate` (which may transform the payload), inserts the
|
|
6552
|
+
* main row plus any multi-relation junction rows as a single batch, then runs `afterCreate`.
|
|
6553
|
+
*
|
|
6554
|
+
* @throws SlugConflictError if `slug` already exists for this seed.
|
|
6555
|
+
* @throws RepositoryError on any other database failure.
|
|
6556
|
+
* @remarks `afterCreate` runs after the batch has committed, so it cannot roll back the write.
|
|
6557
|
+
*/
|
|
6185
6558
|
async create(seed, id, slug, status, data, options) {
|
|
6186
6559
|
let payload = data;
|
|
6187
6560
|
if (this.hooks?.beforeCreate) {
|
|
@@ -6190,7 +6563,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6190
6563
|
}
|
|
6191
6564
|
try {
|
|
6192
6565
|
if (await this.existsSlug(seed, slug)) {
|
|
6193
|
-
throw new
|
|
6566
|
+
throw new SlugConflictError4(`Slug "${slug}" already exists for ${seed.slug}`);
|
|
6194
6567
|
}
|
|
6195
6568
|
const batchStmts = [
|
|
6196
6569
|
this.buildCreateMainStmt(seed, id, slug, status, payload)
|
|
@@ -6206,7 +6579,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6206
6579
|
await this.database.batch(batchStmts);
|
|
6207
6580
|
}
|
|
6208
6581
|
} catch (error) {
|
|
6209
|
-
if (error instanceof
|
|
6582
|
+
if (error instanceof SlugConflictError4) throw error;
|
|
6210
6583
|
throw this.mapError(error, `create(${seed.slug})`);
|
|
6211
6584
|
}
|
|
6212
6585
|
if (this.hooks?.afterCreate) {
|
|
@@ -6214,6 +6587,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6214
6587
|
await this.hooks.afterCreate(entry, this.hookCtx(seed, options?.actor));
|
|
6215
6588
|
}
|
|
6216
6589
|
}
|
|
6590
|
+
/**
|
|
6591
|
+
* Partially updates an existing entry: runs `beforeUpdate`, applies main-row and
|
|
6592
|
+
* junction-table changes as a batch, then runs `afterUpdate`. A no-op payload (no scalar
|
|
6593
|
+
* fields, no multi-relation fields, no status change) returns without touching the database.
|
|
6594
|
+
*
|
|
6595
|
+
* @throws EntryNotFoundError if the main-row `UPDATE` affects zero rows.
|
|
6596
|
+
* @throws RepositoryError on any other database failure.
|
|
6597
|
+
* @remarks `afterUpdate` runs after the batch has committed, so it cannot roll back the write.
|
|
6598
|
+
*/
|
|
6217
6599
|
async update(seed, id, data, status, options) {
|
|
6218
6600
|
let payload = data;
|
|
6219
6601
|
if (this.hooks?.beforeUpdate) {
|
|
@@ -6251,6 +6633,14 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6251
6633
|
await this.hooks.afterUpdate(entry, this.hookCtx(seed, options?.actor));
|
|
6252
6634
|
}
|
|
6253
6635
|
}
|
|
6636
|
+
/**
|
|
6637
|
+
* Deletes an entry: runs `beforeDelete`, reads the row (for the return value), deletes it,
|
|
6638
|
+
* then runs `afterDelete`. Relies on database-level `ON DELETE CASCADE` for junction rows.
|
|
6639
|
+
*
|
|
6640
|
+
* @returns The deserialized row as it existed immediately before deletion.
|
|
6641
|
+
* @throws EntryNotFoundError if no row with `id` exists.
|
|
6642
|
+
* @remarks `afterDelete` runs after the delete has committed, so it cannot roll back the write.
|
|
6643
|
+
*/
|
|
6254
6644
|
async delete(seed, id, options) {
|
|
6255
6645
|
if (this.hooks?.beforeDelete) {
|
|
6256
6646
|
await this.hooks.beforeDelete(id, this.hookCtx(seed, options?.actor));
|
|
@@ -6273,6 +6663,14 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6273
6663
|
}
|
|
6274
6664
|
return { row };
|
|
6275
6665
|
}
|
|
6666
|
+
/**
|
|
6667
|
+
* Atomically increments/decrements a numeric field in a single conditional `UPDATE`
|
|
6668
|
+
* (guarded by optional `min`/`max` bounds evaluated against the new value), avoiding
|
|
6669
|
+
* read-modify-write races.
|
|
6670
|
+
*
|
|
6671
|
+
* @throws RepositoryError if `fieldName` isn't a `number`-typed branch on `seed`, or if
|
|
6672
|
+
* the conditional `UPDATE` affects zero rows (entry not found, or bound exceeded).
|
|
6673
|
+
*/
|
|
6276
6674
|
async mutateField(seed, id, fieldName, operation, options) {
|
|
6277
6675
|
try {
|
|
6278
6676
|
const branch = seed.branches.find((b) => b.alias === fieldName);
|
|
@@ -6302,6 +6700,14 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6302
6700
|
throw this.mapError(error, `mutateField(${seed.slug}, ${id}, ${fieldName})`);
|
|
6303
6701
|
}
|
|
6304
6702
|
}
|
|
6703
|
+
/**
|
|
6704
|
+
* Executes a heterogeneous list of {@link BatchWrite} operations (create/update/mutateField)
|
|
6705
|
+
* as a single atomic `database.batch` call. Does not invoke lifecycle hooks — callers needing
|
|
6706
|
+
* hook side effects should use {@link create}/{@link update}/{@link mutateField} instead.
|
|
6707
|
+
*
|
|
6708
|
+
* @throws RepositoryError if any operation references a non-numeric field for `mutateField`,
|
|
6709
|
+
* or on any other database failure.
|
|
6710
|
+
*/
|
|
6305
6711
|
async runBatch(operations) {
|
|
6306
6712
|
if (operations.length === 0) return;
|
|
6307
6713
|
try {
|
|
@@ -6350,6 +6756,11 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6350
6756
|
throw this.mapError(error, "runBatch");
|
|
6351
6757
|
}
|
|
6352
6758
|
}
|
|
6759
|
+
/**
|
|
6760
|
+
* Upserts a draft row for `entryId` (`INSERT ... ON CONFLICT DO UPDATE` keyed on `entry_id`)
|
|
6761
|
+
* plus a full delete+reinsert of each multi-relation branch's draft junction rows.
|
|
6762
|
+
* @throws RepositoryError if `seed.allowDrafts` is false, or on any database failure.
|
|
6763
|
+
*/
|
|
6353
6764
|
async saveDraft(seed, entryId, data) {
|
|
6354
6765
|
try {
|
|
6355
6766
|
if (!seed.allowDrafts) {
|
|
@@ -6358,6 +6769,21 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6358
6769
|
const draftTableName = this.getTableName(seed.slug, true);
|
|
6359
6770
|
const mRelBranches = multiRelBranches(seed);
|
|
6360
6771
|
const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
|
|
6772
|
+
let existingTouched = [];
|
|
6773
|
+
try {
|
|
6774
|
+
const existing = await this.database.prepare(`SELECT _touched_fields FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId).first();
|
|
6775
|
+
if (existing && existing._touched_fields) {
|
|
6776
|
+
existingTouched = JSON.parse(existing._touched_fields);
|
|
6777
|
+
}
|
|
6778
|
+
} catch {
|
|
6779
|
+
}
|
|
6780
|
+
const currentTouched = new Set(existingTouched);
|
|
6781
|
+
for (const branch of seed.branches) {
|
|
6782
|
+
if (Object.hasOwn(data, branch.alias)) {
|
|
6783
|
+
currentTouched.add(branch.alias);
|
|
6784
|
+
}
|
|
6785
|
+
}
|
|
6786
|
+
const updatedTouchedFields = Array.from(currentTouched);
|
|
6361
6787
|
const columnNames = ["entry_id"];
|
|
6362
6788
|
const placeholders = ["?"];
|
|
6363
6789
|
const queryBindings = [entryId];
|
|
@@ -6372,6 +6798,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6372
6798
|
updateClauses.push(`${branch.alias} = EXCLUDED.${branch.alias}`);
|
|
6373
6799
|
}
|
|
6374
6800
|
}
|
|
6801
|
+
columnNames.push("_touched_fields");
|
|
6802
|
+
placeholders.push("?");
|
|
6803
|
+
queryBindings.push(JSON.stringify(updatedTouchedFields));
|
|
6804
|
+
updateClauses.push(`_touched_fields = EXCLUDED._touched_fields`);
|
|
6375
6805
|
updateClauses.push("updated_at = (unixepoch())");
|
|
6376
6806
|
const sql = `
|
|
6377
6807
|
INSERT INTO ${draftTableName} (${columnNames.join(", ")})
|
|
@@ -6403,6 +6833,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6403
6833
|
throw this.mapError(error, `saveDraft(${seed.slug}, ${entryId})`);
|
|
6404
6834
|
}
|
|
6405
6835
|
}
|
|
6836
|
+
/**
|
|
6837
|
+
* Fetches the draft row for `entryId`, if any, merged with its multi-relation draft arrays.
|
|
6838
|
+
* @returns `null` if `seed.allowDrafts` is false or no draft row exists.
|
|
6839
|
+
*/
|
|
6406
6840
|
async getDraft(seed, entryId) {
|
|
6407
6841
|
try {
|
|
6408
6842
|
if (!seed.allowDrafts) return null;
|
|
@@ -6411,10 +6845,20 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6411
6845
|
if (!draftRow) return null;
|
|
6412
6846
|
const mRelBranches = multiRelBranches(seed);
|
|
6413
6847
|
const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
|
|
6848
|
+
let touchedFields = [];
|
|
6849
|
+
if (draftRow && typeof draftRow["_touched_fields"] === "string") {
|
|
6850
|
+
try {
|
|
6851
|
+
touchedFields = JSON.parse(draftRow["_touched_fields"]);
|
|
6852
|
+
} catch {
|
|
6853
|
+
}
|
|
6854
|
+
}
|
|
6855
|
+
const touchedSet = new Set(touchedFields);
|
|
6414
6856
|
const draftData = {};
|
|
6415
6857
|
for (const branch of seed.branches) {
|
|
6416
6858
|
if (mRelAliases.has(branch.alias)) continue;
|
|
6417
|
-
if (
|
|
6859
|
+
if (touchedSet.has(branch.alias)) {
|
|
6860
|
+
draftData[branch.alias] = draftRow[branch.alias] !== null ? deserializeFromDb2(branch, draftRow[branch.alias]) : null;
|
|
6861
|
+
} else if (draftRow[branch.alias] !== null) {
|
|
6418
6862
|
draftData[branch.alias] = deserializeFromDb2(branch, draftRow[branch.alias]);
|
|
6419
6863
|
}
|
|
6420
6864
|
}
|
|
@@ -6424,9 +6868,12 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6424
6868
|
);
|
|
6425
6869
|
const results = await this.database.batch(stmts);
|
|
6426
6870
|
for (let i = 0; i < mRelBranches.length; i++) {
|
|
6871
|
+
const alias = mRelBranches[i].alias;
|
|
6427
6872
|
const ids = (results[i].results ?? []).map((r) => r.target_id);
|
|
6428
|
-
if (
|
|
6429
|
-
draftData[
|
|
6873
|
+
if (touchedSet.has(alias)) {
|
|
6874
|
+
draftData[alias] = ids;
|
|
6875
|
+
} else if (ids.length > 0) {
|
|
6876
|
+
draftData[alias] = ids;
|
|
6430
6877
|
}
|
|
6431
6878
|
}
|
|
6432
6879
|
}
|
|
@@ -6435,6 +6882,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6435
6882
|
throw this.mapError(error, `getDraft(${seed.slug}, ${entryId})`);
|
|
6436
6883
|
}
|
|
6437
6884
|
}
|
|
6885
|
+
/** Checks whether a draft row exists for `entryId`. Always `false` if `seed.allowDrafts` is false. */
|
|
6438
6886
|
async hasDraft(seed, entryId) {
|
|
6439
6887
|
try {
|
|
6440
6888
|
if (!seed.allowDrafts) return false;
|
|
@@ -6445,6 +6893,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6445
6893
|
throw this.mapError(error, `hasDraft(${seed.slug}, ${entryId})`);
|
|
6446
6894
|
}
|
|
6447
6895
|
}
|
|
6896
|
+
/**
|
|
6897
|
+
* Promotes a draft to the live entry: validates all relation targets referenced by the
|
|
6898
|
+
* draft still exist, copies the draft's scalar fields into the live row, replaces the
|
|
6899
|
+
* live junction rows with the draft's, and deletes the draft — all as one batch.
|
|
6900
|
+
* A no-op if `seed.allowDrafts` is false.
|
|
6901
|
+
*
|
|
6902
|
+
* @throws EntryNotFoundError if no draft row exists for `entryId`.
|
|
6903
|
+
* @throws RelationTargetNotFoundError if any relation the draft references no longer exists.
|
|
6904
|
+
*/
|
|
6448
6905
|
async publishDraft(seed, entryId) {
|
|
6449
6906
|
try {
|
|
6450
6907
|
if (!seed.allowDrafts) return;
|
|
@@ -6454,33 +6911,56 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6454
6911
|
if (!draftRow) {
|
|
6455
6912
|
throw new EntryNotFoundError7(`No draft found for ${entryId} in ${seed.slug}`);
|
|
6456
6913
|
}
|
|
6914
|
+
let touchedFields = [];
|
|
6915
|
+
if (draftRow && typeof draftRow["_touched_fields"] === "string") {
|
|
6916
|
+
try {
|
|
6917
|
+
touchedFields = JSON.parse(draftRow["_touched_fields"]);
|
|
6918
|
+
} catch {
|
|
6919
|
+
}
|
|
6920
|
+
}
|
|
6921
|
+
const touchedSet = new Set(touchedFields);
|
|
6457
6922
|
const { mRelBranches, mRelDraftIds } = await this.validatePublishDraftRelations(seed, draftRow, entryId);
|
|
6458
6923
|
const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
|
|
6459
6924
|
const updateClauses = [];
|
|
6460
6925
|
const queryBindings = [];
|
|
6461
6926
|
for (const branch of seed.branches) {
|
|
6462
6927
|
if (mRelAliases.has(branch.alias)) continue;
|
|
6463
|
-
if (
|
|
6928
|
+
if (touchedSet.has(branch.alias)) {
|
|
6929
|
+
updateClauses.push(`${branch.alias} = ?`);
|
|
6930
|
+
queryBindings.push(draftRow[branch.alias]);
|
|
6931
|
+
} else if (draftRow[branch.alias] !== null) {
|
|
6464
6932
|
updateClauses.push(`${branch.alias} = ?`);
|
|
6465
6933
|
queryBindings.push(draftRow[branch.alias]);
|
|
6466
6934
|
}
|
|
6467
6935
|
}
|
|
6468
|
-
|
|
6469
|
-
|
|
6470
|
-
|
|
6936
|
+
let updateSql = "";
|
|
6937
|
+
if (updateClauses.length > 0) {
|
|
6938
|
+
updateClauses.push("updated_at = (unixepoch())");
|
|
6939
|
+
updateSql = `UPDATE ${liveTableName} SET ${updateClauses.join(", ")} WHERE id = ?`;
|
|
6940
|
+
queryBindings.push(entryId);
|
|
6941
|
+
} else {
|
|
6942
|
+
updateSql = `UPDATE ${liveTableName} SET updated_at = (unixepoch()) WHERE id = ?`;
|
|
6943
|
+
queryBindings.push(entryId);
|
|
6944
|
+
}
|
|
6471
6945
|
const batchStmts = [
|
|
6472
6946
|
this.database.prepare(updateSql).bind(...queryBindings),
|
|
6473
6947
|
this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId)
|
|
6474
6948
|
];
|
|
6475
6949
|
for (const branch of mRelBranches) {
|
|
6476
|
-
const lt = jTable(seed.slug, branch.alias);
|
|
6477
6950
|
const jdt = jDraftTable(seed.slug, branch.alias);
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6951
|
+
if (touchedSet.has(branch.alias)) {
|
|
6952
|
+
const lt = jTable(seed.slug, branch.alias);
|
|
6953
|
+
const targetIds = mRelDraftIds.get(branch.alias) ?? [];
|
|
6954
|
+
batchStmts.push(
|
|
6955
|
+
this.database.prepare(`DELETE FROM ${lt} WHERE parent_id = ?`).bind(entryId),
|
|
6956
|
+
this.database.prepare(`DELETE FROM ${jdt} WHERE entry_id = ?`).bind(entryId),
|
|
6957
|
+
...this.buildJunctionInserts(seed.slug, entryId, branch.alias, targetIds)
|
|
6958
|
+
);
|
|
6959
|
+
} else {
|
|
6960
|
+
batchStmts.push(
|
|
6961
|
+
this.database.prepare(`DELETE FROM ${jdt} WHERE entry_id = ?`).bind(entryId)
|
|
6962
|
+
);
|
|
6963
|
+
}
|
|
6484
6964
|
}
|
|
6485
6965
|
await this.database.batch(batchStmts);
|
|
6486
6966
|
} catch (error) {
|
|
@@ -6489,19 +6969,25 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6489
6969
|
throw this.mapError(error, `publishDraft(${seed.slug}, ${entryId})`);
|
|
6490
6970
|
}
|
|
6491
6971
|
}
|
|
6492
|
-
|
|
6972
|
+
/**
|
|
6973
|
+
* Applies the same set of field updates across many entries, chunked at 50 ids per batch
|
|
6974
|
+
* to bound per-call payload size. Per-id failures are collected rather than aborting the
|
|
6975
|
+
* whole operation — see {@link processBulkUpdateSingle} for failure classification.
|
|
6976
|
+
*/
|
|
6977
|
+
async bulkUpdate(seed, ids, fields) {
|
|
6493
6978
|
const CHUNK = 50;
|
|
6494
|
-
const tableName = this.getTableName(
|
|
6979
|
+
const tableName = this.getTableName(seed.slug);
|
|
6495
6980
|
let updated = 0;
|
|
6496
6981
|
const failed = [];
|
|
6497
6982
|
for (let offset = 0; offset < ids.length; offset += CHUNK) {
|
|
6498
6983
|
const chunk = ids.slice(offset, offset + CHUNK);
|
|
6499
|
-
const res = await this.processBulkUpdateChunk(
|
|
6984
|
+
const res = await this.processBulkUpdateChunk(seed, chunk, fields, tableName);
|
|
6500
6985
|
updated += res.updated;
|
|
6501
6986
|
failed.push(...res.failed);
|
|
6502
6987
|
}
|
|
6503
6988
|
return { updated, failed };
|
|
6504
6989
|
}
|
|
6990
|
+
/** Deletes the draft row for `entryId`, if any. A no-op if `seed.allowDrafts` is false. */
|
|
6505
6991
|
async deleteDraft(seed, entryId) {
|
|
6506
6992
|
try {
|
|
6507
6993
|
if (!seed.allowDrafts) return;
|
|
@@ -6511,6 +6997,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6511
6997
|
throw this.mapError(error, `deleteDraft(${seed.slug}, ${entryId})`);
|
|
6512
6998
|
}
|
|
6513
6999
|
}
|
|
7000
|
+
/**
|
|
7001
|
+
* Finds every pending draft across the given seeds, `UNION ALL`-ing one query per seed
|
|
7002
|
+
* so results span heterogeneous `content_{slug}_drafts` tables, then left-joins each
|
|
7003
|
+
* against `activity_logs` to attribute the most recent "draft saved" action.
|
|
7004
|
+
*
|
|
7005
|
+
* @param seeds Seeds to search; drafts are looked up per-seed since draft tables aren't unified.
|
|
7006
|
+
* @returns Draft summaries ordered by most-recently-updated first. `title` falls back to the
|
|
7007
|
+
* draft's own `id` if the display-name column is blank on both the draft and its live counterpart.
|
|
7008
|
+
*/
|
|
6514
7009
|
async findPendingDrafts(seeds) {
|
|
6515
7010
|
try {
|
|
6516
7011
|
if (seeds.length === 0) return [];
|
|
@@ -6520,6 +7015,8 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6520
7015
|
const draftTable = `content_${seed.slug}_drafts`;
|
|
6521
7016
|
const liveTable = `content_${seed.slug}`;
|
|
6522
7017
|
const titleCol = seed.displayNameAlias;
|
|
7018
|
+
const isScalar = seed.branches.some((b) => b.alias === titleCol && !(b.type === "relation" && b.multiple === true));
|
|
7019
|
+
const titleExpr = isScalar ? `COALESCE(d.${titleCol}, l.${titleCol})` : `COALESCE(l.slug, d.entry_id)`;
|
|
6523
7020
|
const seedLabel = seed.labelPlural ?? seed.label;
|
|
6524
7021
|
unionSelects.push(`
|
|
6525
7022
|
SELECT
|
|
@@ -6527,7 +7024,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6527
7024
|
? AS seed_label,
|
|
6528
7025
|
d.entry_id AS id,
|
|
6529
7026
|
d.updated_at AS updated_at,
|
|
6530
|
-
|
|
7027
|
+
${titleExpr} AS title
|
|
6531
7028
|
FROM ${draftTable} d
|
|
6532
7029
|
LEFT JOIN ${liveTable} l ON l.id = d.entry_id
|
|
6533
7030
|
`);
|
|
@@ -6574,9 +7071,48 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6574
7071
|
throw this.mapError(error, "findPendingDrafts");
|
|
6575
7072
|
}
|
|
6576
7073
|
}
|
|
7074
|
+
/**
|
|
7075
|
+
* Updates an entry's field values (optional) and its kanban board position atomically in one
|
|
7076
|
+
* batch: an optional main-row/junction update via {@link buildUpdateMainStmt}, plus an upsert
|
|
7077
|
+
* into `kanban_positions` keyed on `(seed_slug, entry_id, axis_branch_id)`.
|
|
7078
|
+
*
|
|
7079
|
+
* @param patch Field changes to apply alongside the drag/drop, or `null` if only position changed.
|
|
7080
|
+
* @param position Opaque fractional-index (or similar) position value used for ordering within the axis bucket.
|
|
7081
|
+
* @param axisBranchId Identifies which branch/column axis this position is relative to (e.g. status column).
|
|
7082
|
+
* @remarks Unlike {@link create}/{@link update}, this method does not invoke `beforeUpdate`/`afterUpdate`
|
|
7083
|
+
* lifecycle hooks — the `_ctx.actor` parameter is currently unused. Kanban drag-and-drop writes
|
|
7084
|
+
* therefore bypass hook-based side effects (e.g. audit logging) that `update()` would trigger.
|
|
7085
|
+
*/
|
|
7086
|
+
async updateWithKanbanPosition(seed, id, patch, position, axisBranchId, _ctx) {
|
|
7087
|
+
try {
|
|
7088
|
+
const stmts = [];
|
|
7089
|
+
if (patch) {
|
|
7090
|
+
const { stmt, junctionUpdates } = this.buildUpdateMainStmt(seed, id, patch);
|
|
7091
|
+
if (stmt) stmts.push(stmt);
|
|
7092
|
+
for (const branch of junctionUpdates) {
|
|
7093
|
+
const jt = jTable(seed.slug, branch.alias);
|
|
7094
|
+
const value = patch[branch.alias] ?? [];
|
|
7095
|
+
stmts.push(this.database.prepare(`DELETE FROM ${jt} WHERE parent_id = ?`).bind(id));
|
|
7096
|
+
stmts.push(...this.buildJunctionInserts(seed.slug, id, branch.alias, value));
|
|
7097
|
+
}
|
|
7098
|
+
}
|
|
7099
|
+
stmts.push(
|
|
7100
|
+
this.database.prepare(`
|
|
7101
|
+
INSERT INTO kanban_positions (seed_slug, entry_id, axis_branch_id, position, updated_at)
|
|
7102
|
+
VALUES (?, ?, ?, ?, unixepoch())
|
|
7103
|
+
ON CONFLICT(seed_slug, entry_id, axis_branch_id)
|
|
7104
|
+
DO UPDATE SET position = excluded.position, updated_at = excluded.updated_at
|
|
7105
|
+
`).bind(seed.slug, id, axisBranchId, position)
|
|
7106
|
+
);
|
|
7107
|
+
await this.database.batch(stmts);
|
|
7108
|
+
return { success: true };
|
|
7109
|
+
} catch (error) {
|
|
7110
|
+
throw this.mapError(error, `updateWithKanbanPosition(${seed.slug}, ${id})`);
|
|
7111
|
+
}
|
|
7112
|
+
}
|
|
6577
7113
|
};
|
|
6578
7114
|
|
|
6579
|
-
// src/shared/idempotency.repository.d1.ts
|
|
7115
|
+
// src/shared/db/repositories/idempotency.repository.d1.ts
|
|
6580
7116
|
var D1IdempotencyRepository = class extends BaseD1Repository {
|
|
6581
7117
|
/**
|
|
6582
7118
|
* Looks up an idempotency record by its key.
|
|
@@ -6627,7 +7163,7 @@ var D1IdempotencyRepository = class extends BaseD1Repository {
|
|
|
6627
7163
|
}
|
|
6628
7164
|
};
|
|
6629
7165
|
|
|
6630
|
-
// src/shared/media.repository.d1.ts
|
|
7166
|
+
// src/shared/db/repositories/media.repository.d1.ts
|
|
6631
7167
|
var D1MediaRepository = class {
|
|
6632
7168
|
constructor(database) {
|
|
6633
7169
|
this.database = database;
|
|
@@ -6683,7 +7219,7 @@ var D1MediaRepository = class {
|
|
|
6683
7219
|
}
|
|
6684
7220
|
};
|
|
6685
7221
|
|
|
6686
|
-
// src/shared/system-stats.repository.d1.ts
|
|
7222
|
+
// src/shared/db/repositories/system-stats.repository.d1.ts
|
|
6687
7223
|
var D1SystemStatsRepository = class {
|
|
6688
7224
|
constructor(database) {
|
|
6689
7225
|
this.database = database;
|
|
@@ -6722,7 +7258,7 @@ var D1SystemStatsRepository = class {
|
|
|
6722
7258
|
}
|
|
6723
7259
|
};
|
|
6724
7260
|
|
|
6725
|
-
// src/shared/d1-user.repository.ts
|
|
7261
|
+
// src/shared/db/repositories/d1-user.repository.ts
|
|
6726
7262
|
function rowToRecord(row) {
|
|
6727
7263
|
return {
|
|
6728
7264
|
id: row.id,
|
|
@@ -6789,7 +7325,7 @@ var D1UserRepository = class {
|
|
|
6789
7325
|
}
|
|
6790
7326
|
};
|
|
6791
7327
|
|
|
6792
|
-
// src/shared/d1-session.repository.ts
|
|
7328
|
+
// src/shared/db/repositories/d1-session.repository.ts
|
|
6793
7329
|
function rowToRecord2(row) {
|
|
6794
7330
|
return {
|
|
6795
7331
|
id: row.id,
|
|
@@ -6848,7 +7384,7 @@ var D1SessionRepository = class {
|
|
|
6848
7384
|
}
|
|
6849
7385
|
};
|
|
6850
7386
|
|
|
6851
|
-
// src/shared/d1-password-reset-token.repository.ts
|
|
7387
|
+
// src/shared/db/repositories/d1-password-reset-token.repository.ts
|
|
6852
7388
|
var D1PasswordResetTokenRepository = class {
|
|
6853
7389
|
constructor(db, idGenerator) {
|
|
6854
7390
|
this.db = db;
|
|
@@ -6878,7 +7414,7 @@ var D1PasswordResetTokenRepository = class {
|
|
|
6878
7414
|
}
|
|
6879
7415
|
};
|
|
6880
7416
|
|
|
6881
|
-
// src/shared/d1-activity-log.repository.ts
|
|
7417
|
+
// src/shared/db/repositories/d1-activity-log.repository.ts
|
|
6882
7418
|
var D1ActivityLogRepository = class {
|
|
6883
7419
|
constructor(database) {
|
|
6884
7420
|
this.database = database;
|
|
@@ -6941,7 +7477,7 @@ function parseDetails(raw) {
|
|
|
6941
7477
|
}
|
|
6942
7478
|
}
|
|
6943
7479
|
|
|
6944
|
-
// src/shared/d1-notification.repository.ts
|
|
7480
|
+
// src/shared/db/repositories/d1-notification.repository.ts
|
|
6945
7481
|
var D1NotificationRepository = class {
|
|
6946
7482
|
constructor(database, clock, idGenerator) {
|
|
6947
7483
|
this.database = database;
|
|
@@ -7005,7 +7541,7 @@ function mapRowToRecord2(row) {
|
|
|
7005
7541
|
};
|
|
7006
7542
|
}
|
|
7007
7543
|
|
|
7008
|
-
// src/shared/d1-widget.repository.ts
|
|
7544
|
+
// src/shared/db/repositories/d1-widget.repository.ts
|
|
7009
7545
|
import {
|
|
7010
7546
|
isDateRange
|
|
7011
7547
|
} from "@beechcms/core";
|
|
@@ -7079,9 +7615,10 @@ var D1WidgetRepository = class {
|
|
|
7079
7615
|
const bindings = [];
|
|
7080
7616
|
const trimmedSearch = options.search?.trim() ?? "";
|
|
7081
7617
|
if (trimmedSearch.length > 0) {
|
|
7618
|
+
const escapedSearch = trimmedSearch.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
7082
7619
|
const displayColumn = this.resolveColumnExpression(seed, seed.displayNameAlias);
|
|
7083
|
-
conditions.push(`${displayColumn} LIKE
|
|
7084
|
-
bindings.push(`%${
|
|
7620
|
+
conditions.push(`${displayColumn} LIKE ? ESCAPE '\\'`);
|
|
7621
|
+
bindings.push(`%${escapedSearch}%`);
|
|
7085
7622
|
}
|
|
7086
7623
|
for (const filter of options.filters ?? []) {
|
|
7087
7624
|
this.appendFilterCondition(seed, filter, conditions, bindings);
|
|
@@ -7282,7 +7819,7 @@ var D1WidgetRepository = class {
|
|
|
7282
7819
|
}
|
|
7283
7820
|
};
|
|
7284
7821
|
|
|
7285
|
-
// src/shared/d1-search.repository.ts
|
|
7822
|
+
// src/shared/db/repositories/d1-search.repository.ts
|
|
7286
7823
|
var EMPTY_QUERY_ERROR = "EMPTY_QUERY";
|
|
7287
7824
|
var D1SearchRepository = class {
|
|
7288
7825
|
constructor(database) {
|
|
@@ -7342,7 +7879,7 @@ function mapFtsRowToResultRow(row) {
|
|
|
7342
7879
|
};
|
|
7343
7880
|
}
|
|
7344
7881
|
|
|
7345
|
-
// src/shared/d1-analytics.repository.ts
|
|
7882
|
+
// src/shared/db/repositories/d1-analytics.repository.ts
|
|
7346
7883
|
var SECONDS_PER_DAY2 = 86400;
|
|
7347
7884
|
var D1AnalyticsRepository = class {
|
|
7348
7885
|
constructor(database, clock) {
|
|
@@ -7394,7 +7931,7 @@ var D1AnalyticsRepository = class {
|
|
|
7394
7931
|
}
|
|
7395
7932
|
};
|
|
7396
7933
|
|
|
7397
|
-
// src/shared/d1-content-scan.repository.ts
|
|
7934
|
+
// src/shared/db/repositories/d1-content-scan.repository.ts
|
|
7398
7935
|
var D1ContentScanRepository = class {
|
|
7399
7936
|
constructor(db) {
|
|
7400
7937
|
this.db = db;
|
|
@@ -7423,7 +7960,7 @@ var D1ContentScanRepository = class {
|
|
|
7423
7960
|
}
|
|
7424
7961
|
};
|
|
7425
7962
|
|
|
7426
|
-
// src/shared/site-settings.repository.d1.ts
|
|
7963
|
+
// src/shared/db/repositories/site-settings.repository.d1.ts
|
|
7427
7964
|
var DEFAULTS = {
|
|
7428
7965
|
siteTitle: "Beech CMS",
|
|
7429
7966
|
defaultLanguage: "en",
|
|
@@ -7463,7 +8000,7 @@ var D1SiteSettingsRepository = class {
|
|
|
7463
8000
|
}
|
|
7464
8001
|
};
|
|
7465
8002
|
|
|
7466
|
-
// src/shared/demo-data-sql.ts
|
|
8003
|
+
// src/shared/db/migrations/demo-data-sql.ts
|
|
7467
8004
|
var DEMO_DATA_SQL = `
|
|
7468
8005
|
INSERT OR IGNORE INTO content_clienti (id, slug, status, name, email, company, tier, account_status, mrr, created_at, updated_at)
|
|
7469
8006
|
VALUES
|
|
@@ -8442,7 +8979,7 @@ VALUES
|
|
|
8442
8979
|
|
|
8443
8980
|
`;
|
|
8444
8981
|
|
|
8445
|
-
// src/shared/demo-data.repository.d1.ts
|
|
8982
|
+
// src/shared/db/repositories/demo-data.repository.d1.ts
|
|
8446
8983
|
var D1DemoDataRepository = class {
|
|
8447
8984
|
constructor(db) {
|
|
8448
8985
|
this.db = db;
|
|
@@ -8454,7 +8991,7 @@ var D1DemoDataRepository = class {
|
|
|
8454
8991
|
}
|
|
8455
8992
|
};
|
|
8456
8993
|
|
|
8457
|
-
// src/shared/d1-setup-checklist.repository.ts
|
|
8994
|
+
// src/shared/db/repositories/d1-setup-checklist.repository.ts
|
|
8458
8995
|
var D1SetupChecklistRepository = class {
|
|
8459
8996
|
constructor(db) {
|
|
8460
8997
|
this.db = db;
|
|
@@ -8474,7 +9011,8 @@ var D1SetupChecklistRepository = class {
|
|
|
8474
9011
|
}
|
|
8475
9012
|
};
|
|
8476
9013
|
|
|
8477
|
-
// src/shared/seed-layout.repository.d1.ts
|
|
9014
|
+
// src/shared/db/repositories/seed-layout.repository.d1.ts
|
|
9015
|
+
import { seedViewConfigSchema as seedViewConfigSchema2 } from "@beechcms/core";
|
|
8478
9016
|
var D1SeedLayoutRepository = class {
|
|
8479
9017
|
constructor(db) {
|
|
8480
9018
|
this.db = db;
|
|
@@ -8516,9 +9054,25 @@ var D1SeedLayoutRepository = class {
|
|
|
8516
9054
|
async remove(slug) {
|
|
8517
9055
|
await this.db.prepare("DELETE FROM seed_layouts WHERE slug = ?").bind(slug).run();
|
|
8518
9056
|
}
|
|
9057
|
+
async getViewConfig(slug) {
|
|
9058
|
+
const row = await this.db.prepare("SELECT view_config FROM seed_layouts WHERE slug = ?").bind(slug).first();
|
|
9059
|
+
if (!row?.view_config) return null;
|
|
9060
|
+
return seedViewConfigSchema2.parse(JSON.parse(row.view_config));
|
|
9061
|
+
}
|
|
9062
|
+
async setViewConfig(slug, config, updatedBy) {
|
|
9063
|
+
const json = JSON.stringify(config);
|
|
9064
|
+
await this.db.prepare(`
|
|
9065
|
+
INSERT INTO seed_layouts (slug, layout, view_config, updated_at, updated_by)
|
|
9066
|
+
VALUES (?, '{}', ?, unixepoch(), ?)
|
|
9067
|
+
ON CONFLICT(slug) DO UPDATE SET
|
|
9068
|
+
view_config = excluded.view_config,
|
|
9069
|
+
updated_at = excluded.updated_at,
|
|
9070
|
+
updated_by = excluded.updated_by
|
|
9071
|
+
`).bind(slug, json, updatedBy).run();
|
|
9072
|
+
}
|
|
8519
9073
|
};
|
|
8520
9074
|
|
|
8521
|
-
// src/shared/dashboard-layout.repository.d1.ts
|
|
9075
|
+
// src/shared/db/repositories/dashboard-layout.repository.d1.ts
|
|
8522
9076
|
var D1DashboardLayoutRepository = class {
|
|
8523
9077
|
constructor(db) {
|
|
8524
9078
|
this.db = db;
|
|
@@ -8559,7 +9113,7 @@ var D1DashboardLayoutRepository = class {
|
|
|
8559
9113
|
}
|
|
8560
9114
|
};
|
|
8561
9115
|
|
|
8562
|
-
// src/shared/seed.repository.d1.ts
|
|
9116
|
+
// src/shared/db/repositories/seed.repository.d1.ts
|
|
8563
9117
|
var D1SeedRepository = class {
|
|
8564
9118
|
constructor(db) {
|
|
8565
9119
|
this.db = db;
|
|
@@ -8634,7 +9188,7 @@ var D1SeedRepository = class {
|
|
|
8634
9188
|
}
|
|
8635
9189
|
};
|
|
8636
9190
|
|
|
8637
|
-
// src/shared/schema-mutator.d1.ts
|
|
9191
|
+
// src/shared/db/migrations/schema-mutator.d1.ts
|
|
8638
9192
|
var D1SchemaMutator = class _D1SchemaMutator {
|
|
8639
9193
|
constructor(db) {
|
|
8640
9194
|
this.db = db;
|
|
@@ -8677,13 +9231,59 @@ var D1SchemaMutator = class _D1SchemaMutator {
|
|
|
8677
9231
|
if (statements.length === 0) return;
|
|
8678
9232
|
await this.db.batch(statements.map((s) => this.db.prepare(s)));
|
|
8679
9233
|
}
|
|
9234
|
+
async fetchRows(table, columns) {
|
|
9235
|
+
_D1SchemaMutator.assertIdentifier(table);
|
|
9236
|
+
for (const col of columns) _D1SchemaMutator.assertIdentifier(col);
|
|
9237
|
+
const colList = columns.join(", ");
|
|
9238
|
+
const rs = await this.db.prepare(`SELECT ${colList} FROM ${table}`).all();
|
|
9239
|
+
return rs.results ?? [];
|
|
9240
|
+
}
|
|
9241
|
+
};
|
|
9242
|
+
|
|
9243
|
+
// src/shared/db/repositories/kanban-position.repository.d1.ts
|
|
9244
|
+
var D1KanbanPositionRepository = class {
|
|
9245
|
+
constructor(db) {
|
|
9246
|
+
this.db = db;
|
|
9247
|
+
}
|
|
9248
|
+
db;
|
|
9249
|
+
async getColumn(seedSlug, axisBranchId) {
|
|
9250
|
+
const rs = await this.db.prepare("SELECT entry_id, position FROM kanban_positions WHERE seed_slug = ? AND axis_branch_id = ?").bind(seedSlug, axisBranchId).all();
|
|
9251
|
+
const map = /* @__PURE__ */ new Map();
|
|
9252
|
+
for (const row of rs.results ?? []) {
|
|
9253
|
+
map.set(row.entry_id, row.position);
|
|
9254
|
+
}
|
|
9255
|
+
return map;
|
|
9256
|
+
}
|
|
9257
|
+
async setPosition(seedSlug, entryId, axisBranchId, position) {
|
|
9258
|
+
await this.db.prepare(`
|
|
9259
|
+
INSERT INTO kanban_positions (seed_slug, entry_id, axis_branch_id, position, updated_at)
|
|
9260
|
+
VALUES (?, ?, ?, ?, unixepoch())
|
|
9261
|
+
ON CONFLICT(seed_slug, entry_id, axis_branch_id)
|
|
9262
|
+
DO UPDATE SET position = excluded.position, updated_at = excluded.updated_at
|
|
9263
|
+
`).bind(seedSlug, entryId, axisBranchId, position).run();
|
|
9264
|
+
}
|
|
9265
|
+
async remove(seedSlug, entryId, axisBranchId) {
|
|
9266
|
+
await this.db.prepare("DELETE FROM kanban_positions WHERE seed_slug = ? AND entry_id = ? AND axis_branch_id = ?").bind(seedSlug, entryId, axisBranchId).run();
|
|
9267
|
+
}
|
|
9268
|
+
async rebalance(seedSlug, axisBranchId, ordered) {
|
|
9269
|
+
if (ordered.length === 0) return;
|
|
9270
|
+
const stmts = ordered.map(
|
|
9271
|
+
(r) => 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, r.entryId, axisBranchId, r.position)
|
|
9277
|
+
);
|
|
9278
|
+
await this.db.batch(stmts);
|
|
9279
|
+
}
|
|
8680
9280
|
};
|
|
8681
9281
|
|
|
8682
9282
|
// src/middleware/repository.middleware.ts
|
|
8683
9283
|
import { SystemClock as SystemClock2, SystemIdGenerator } from "@beechcms/core";
|
|
8684
9284
|
import { NoOpScheduler } from "@beechcms/core";
|
|
8685
9285
|
|
|
8686
|
-
// src/shared/automations.repository.d1.ts
|
|
9286
|
+
// src/shared/db/repositories/automations.repository.d1.ts
|
|
8687
9287
|
var D1AutomationRepository = class {
|
|
8688
9288
|
constructor(db) {
|
|
8689
9289
|
this.db = db;
|
|
@@ -8769,7 +9369,7 @@ function rowToAutomation(row) {
|
|
|
8769
9369
|
};
|
|
8770
9370
|
}
|
|
8771
9371
|
|
|
8772
|
-
// src/shared/execution-context-scheduler.ts
|
|
9372
|
+
// src/shared/services/scheduler/execution-context-scheduler.ts
|
|
8773
9373
|
var ExecutionContextScheduler = class {
|
|
8774
9374
|
constructor(ctx) {
|
|
8775
9375
|
this.ctx = ctx;
|
|
@@ -8828,6 +9428,7 @@ var repositoryMiddleware = (overrides) => {
|
|
|
8828
9428
|
context.set("seedRepository", overrides?.seedRepository ?? new D1SeedRepository(database));
|
|
8829
9429
|
context.set("schemaMutator", overrides?.schemaMutator ?? new D1SchemaMutator(database));
|
|
8830
9430
|
context.set("dashboardLayoutRepository", overrides?.dashboardLayoutRepository ?? new D1DashboardLayoutRepository(database));
|
|
9431
|
+
context.set("kanbanPositionRepository", overrides?.kanbanPositionRepository ?? new D1KanbanPositionRepository(database));
|
|
8831
9432
|
await next();
|
|
8832
9433
|
});
|
|
8833
9434
|
};
|
|
@@ -8835,7 +9436,7 @@ var repositoryMiddleware = (overrides) => {
|
|
|
8835
9436
|
// src/middleware/seed-registry.middleware.ts
|
|
8836
9437
|
import { createMiddleware as createMiddleware3 } from "hono/factory";
|
|
8837
9438
|
|
|
8838
|
-
// src/shared/seed-registry-cache.ts
|
|
9439
|
+
// src/shared/services/cache/seed-registry-cache.ts
|
|
8839
9440
|
import { SeedRegistry, buildBackrefMap } from "@beechcms/core";
|
|
8840
9441
|
var cache = null;
|
|
8841
9442
|
var TTL_MS = 5e3;
|
|
@@ -8895,30 +9496,12 @@ var S3Bucket = class {
|
|
|
8895
9496
|
const command = new PutObjectCommand({
|
|
8896
9497
|
Bucket: this.bucketName,
|
|
8897
9498
|
Key: key,
|
|
8898
|
-
Body: body instanceof ReadableStream ?
|
|
9499
|
+
Body: body instanceof ReadableStream ? body : new Uint8Array(body),
|
|
8899
9500
|
ContentType: options?.contentType,
|
|
8900
9501
|
Metadata: options?.metadata
|
|
8901
9502
|
});
|
|
8902
9503
|
await this.client.send(command);
|
|
8903
9504
|
}
|
|
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
9505
|
async get(key) {
|
|
8923
9506
|
try {
|
|
8924
9507
|
const command = new GetObjectCommand({
|
|
@@ -8967,10 +9550,11 @@ var S3Bucket = class {
|
|
|
8967
9550
|
}
|
|
8968
9551
|
}
|
|
8969
9552
|
getUrl(key) {
|
|
9553
|
+
const encodedKey = key.split("/").map(encodeURIComponent).join("/");
|
|
8970
9554
|
if (this.cdnUrl) {
|
|
8971
|
-
return `${this.cdnUrl}/${
|
|
9555
|
+
return `${this.cdnUrl}/${encodedKey}`;
|
|
8972
9556
|
}
|
|
8973
|
-
return `${this.baseUrl}/api/media/${
|
|
9557
|
+
return `${this.baseUrl}/api/media/${encodedKey}`;
|
|
8974
9558
|
}
|
|
8975
9559
|
async getTotalSize() {
|
|
8976
9560
|
let total = 0;
|
|
@@ -8992,7 +9576,8 @@ var S3Bucket = class {
|
|
|
8992
9576
|
const command = new PutObjectCommand({
|
|
8993
9577
|
Bucket: this.bucketName,
|
|
8994
9578
|
Key: key,
|
|
8995
|
-
ContentType: options.contentType
|
|
9579
|
+
ContentType: options.contentType,
|
|
9580
|
+
ContentLength: options.contentLength
|
|
8996
9581
|
});
|
|
8997
9582
|
return getSignedUrl(this.client, command, { expiresIn: options.expiresIn });
|
|
8998
9583
|
}
|
|
@@ -9022,9 +9607,21 @@ var S3Bucket = class {
|
|
|
9022
9607
|
};
|
|
9023
9608
|
|
|
9024
9609
|
// src/shared/storage/factory.ts
|
|
9610
|
+
import { HTTPException as HTTPException2 } from "hono/http-exception";
|
|
9025
9611
|
var NullBucket = class {
|
|
9026
9612
|
fail() {
|
|
9027
|
-
throw new
|
|
9613
|
+
throw new HTTPException2(503, {
|
|
9614
|
+
res: new Response(
|
|
9615
|
+
JSON.stringify({
|
|
9616
|
+
error: "storage_not_configured",
|
|
9617
|
+
message: "Storage is not configured. Please set R2 credentials or run MinIO."
|
|
9618
|
+
}),
|
|
9619
|
+
{
|
|
9620
|
+
status: 503,
|
|
9621
|
+
headers: { "Content-Type": "application/json" }
|
|
9622
|
+
}
|
|
9623
|
+
)
|
|
9624
|
+
});
|
|
9028
9625
|
}
|
|
9029
9626
|
put() {
|
|
9030
9627
|
return this.fail();
|
|
@@ -9081,7 +9678,7 @@ var storageMiddleware = (overrides) => {
|
|
|
9081
9678
|
});
|
|
9082
9679
|
};
|
|
9083
9680
|
|
|
9084
|
-
// src/shared/in-memory-seed.repository.ts
|
|
9681
|
+
// src/shared/db/repositories/in-memory-seed.repository.ts
|
|
9085
9682
|
var InMemorySeedRepository = class {
|
|
9086
9683
|
seeds;
|
|
9087
9684
|
version = 1;
|
|
@@ -9127,7 +9724,7 @@ var InMemorySeedRepository = class {
|
|
|
9127
9724
|
import { createMiddleware as createMiddleware5 } from "hono/factory";
|
|
9128
9725
|
import { SystemClock as SystemClock3 } from "@beechcms/core";
|
|
9129
9726
|
|
|
9130
|
-
// src/auth/
|
|
9727
|
+
// src/auth/providers/hash.provider.ts
|
|
9131
9728
|
import bcrypt from "bcryptjs";
|
|
9132
9729
|
var BCRYPT_SALT_ROUNDS = 10;
|
|
9133
9730
|
var BcryptHashProvider = class {
|
|
@@ -9143,7 +9740,7 @@ var BcryptHashProvider = class {
|
|
|
9143
9740
|
}
|
|
9144
9741
|
};
|
|
9145
9742
|
|
|
9146
|
-
// src/auth/
|
|
9743
|
+
// src/auth/providers/jwt-token.service.ts
|
|
9147
9744
|
import { SignJWT, jwtVerify } from "jose";
|
|
9148
9745
|
var DEFAULT_TOKEN_TTL_SECONDS = 900;
|
|
9149
9746
|
var DEFAULT_ALGORITHM = "HS256";
|
|
@@ -9175,8 +9772,12 @@ var JoseTokenService = class {
|
|
|
9175
9772
|
const { payload } = await jwtVerify(token, this.secretBytes, {
|
|
9176
9773
|
algorithms: [algorithm],
|
|
9177
9774
|
issuer: this.config.issuer,
|
|
9178
|
-
audience: this.config.audience
|
|
9775
|
+
audience: this.config.audience,
|
|
9776
|
+
currentDate: new Date(this.clock.nowSeconds() * 1e3)
|
|
9179
9777
|
});
|
|
9778
|
+
if (typeof payload.sub !== "string" || payload.sub === "") {
|
|
9779
|
+
return null;
|
|
9780
|
+
}
|
|
9180
9781
|
return payload;
|
|
9181
9782
|
} catch {
|
|
9182
9783
|
return null;
|
|
@@ -9214,13 +9815,23 @@ import { createMiddleware as createMiddleware6 } from "hono/factory";
|
|
|
9214
9815
|
|
|
9215
9816
|
// src/rate-limit/cloudflare-rate-limiter.ts
|
|
9216
9817
|
var CloudflareRateLimiter = class {
|
|
9217
|
-
constructor(binding) {
|
|
9818
|
+
constructor(binding, options = {}) {
|
|
9218
9819
|
this.binding = binding;
|
|
9820
|
+
this.options = options;
|
|
9219
9821
|
}
|
|
9220
9822
|
binding;
|
|
9823
|
+
options;
|
|
9221
9824
|
async checkLimit(key) {
|
|
9222
|
-
|
|
9223
|
-
|
|
9825
|
+
try {
|
|
9826
|
+
const res = await this.binding.limit({ key });
|
|
9827
|
+
return {
|
|
9828
|
+
isAllowed: res.success,
|
|
9829
|
+
retryAfterSeconds: res.retryAfterSeconds ?? res.retryAfter
|
|
9830
|
+
};
|
|
9831
|
+
} catch (error) {
|
|
9832
|
+
console.warn(`Rate limiter binding error for key "${key}":`, error);
|
|
9833
|
+
return { isAllowed: !this.options.failClosed };
|
|
9834
|
+
}
|
|
9224
9835
|
}
|
|
9225
9836
|
};
|
|
9226
9837
|
|
|
@@ -9235,13 +9846,20 @@ var NoOpRateLimiter = class {
|
|
|
9235
9846
|
var NO_OP = new NoOpRateLimiter();
|
|
9236
9847
|
function buildDefaultRegistry(env) {
|
|
9237
9848
|
const isDev = env.ENV === "development" && !(typeof globalThis.process !== "undefined" && globalThis.process.env?.VITEST);
|
|
9849
|
+
const isProd = env.ENV === "production";
|
|
9850
|
+
const getLimiter = (bindingName, binding, options) => {
|
|
9851
|
+
if (isProd && !binding) {
|
|
9852
|
+
throw new Error(`Missing rate limiter binding: ${bindingName} is required in production environment.`);
|
|
9853
|
+
}
|
|
9854
|
+
return !isDev && binding ? new CloudflareRateLimiter(binding, options) : NO_OP;
|
|
9855
|
+
};
|
|
9238
9856
|
const limiters = {
|
|
9239
|
-
login:
|
|
9240
|
-
tokenRefresh:
|
|
9241
|
-
forgotPassword:
|
|
9242
|
-
resetPassword:
|
|
9243
|
-
publicApiRead:
|
|
9244
|
-
publicApiWrite:
|
|
9857
|
+
login: getLimiter("LOGIN_RATE_LIMITER", env.LOGIN_RATE_LIMITER, { failClosed: true }),
|
|
9858
|
+
tokenRefresh: getLimiter("REFRESH_RATE_LIMITER", env.REFRESH_RATE_LIMITER, { failClosed: true }),
|
|
9859
|
+
forgotPassword: getLimiter("FORGOT_PASSWORD_RATE_LIMITER", env.FORGOT_PASSWORD_RATE_LIMITER, { failClosed: true }),
|
|
9860
|
+
resetPassword: getLimiter("RESET_PASSWORD_RATE_LIMITER", env.RESET_PASSWORD_RATE_LIMITER, { failClosed: true }),
|
|
9861
|
+
publicApiRead: getLimiter("PUBLIC_READ_RATE_LIMITER", env.PUBLIC_READ_RATE_LIMITER, { failClosed: false }),
|
|
9862
|
+
publicApiWrite: getLimiter("PUBLIC_WRITE_RATE_LIMITER", env.PUBLIC_WRITE_RATE_LIMITER, { failClosed: false })
|
|
9245
9863
|
};
|
|
9246
9864
|
return { getLimiter: (name) => limiters[name] };
|
|
9247
9865
|
}
|
|
@@ -9257,7 +9875,7 @@ var rateLimiterMiddleware = (overrides) => {
|
|
|
9257
9875
|
import { createMiddleware as createMiddleware7 } from "hono/factory";
|
|
9258
9876
|
import { SystemClock as SystemClock4, SystemIdGenerator as SystemIdGenerator2 } from "@beechcms/core";
|
|
9259
9877
|
|
|
9260
|
-
// src/shared/d1-activity-logger.ts
|
|
9878
|
+
// src/shared/services/activity-log/d1-activity-logger.ts
|
|
9261
9879
|
var ACTIVITY_LOG_INSERT_SQL = `INSERT INTO activity_logs
|
|
9262
9880
|
(id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
|
|
9263
9881
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
|
@@ -9302,7 +9920,7 @@ var D1ActivityLogger = class {
|
|
|
9302
9920
|
}
|
|
9303
9921
|
};
|
|
9304
9922
|
|
|
9305
|
-
// src/shared/background-notification-service.ts
|
|
9923
|
+
// src/shared/services/notification/background-notification-service.ts
|
|
9306
9924
|
var DEFAULT_NOTIFICATION_TYPE = "info";
|
|
9307
9925
|
var BackgroundNotificationService = class {
|
|
9308
9926
|
constructor(notificationRepository, scheduleBackgroundTask) {
|
|
@@ -9332,7 +9950,7 @@ var BackgroundNotificationService = class {
|
|
|
9332
9950
|
}
|
|
9333
9951
|
};
|
|
9334
9952
|
|
|
9335
|
-
// src/shared/qstash-notification-service.ts
|
|
9953
|
+
// src/shared/services/notification/qstash-notification-service.ts
|
|
9336
9954
|
import { Client } from "@upstash/qstash";
|
|
9337
9955
|
var QStashNotificationService = class {
|
|
9338
9956
|
constructor(token, appUrl, scheduleBackgroundTask, qstashUrl) {
|
|
@@ -9405,7 +10023,7 @@ var observabilityMiddleware = (overrides) => {
|
|
|
9405
10023
|
import { createMiddleware as createMiddleware8 } from "hono/factory";
|
|
9406
10024
|
import { SystemClock as SystemClock5, SystemIdGenerator as SystemIdGenerator3 } from "@beechcms/core";
|
|
9407
10025
|
|
|
9408
|
-
// src/shared/cloudflare-queue-service.ts
|
|
10026
|
+
// src/shared/services/queue/cloudflare-queue-service.ts
|
|
9409
10027
|
var CloudflareQueueService = class {
|
|
9410
10028
|
constructor(queue) {
|
|
9411
10029
|
this.queue = queue;
|
|
@@ -9420,7 +10038,7 @@ var CloudflareQueueService = class {
|
|
|
9420
10038
|
}
|
|
9421
10039
|
};
|
|
9422
10040
|
|
|
9423
|
-
// src/shared/in-memory-queue-service.ts
|
|
10041
|
+
// src/shared/services/queue/in-memory-queue-service.ts
|
|
9424
10042
|
var InMemoryQueueService = class {
|
|
9425
10043
|
constructor(jobs, context, scheduleBackgroundTask) {
|
|
9426
10044
|
this.jobs = jobs;
|
|
@@ -9547,7 +10165,7 @@ function createBeechApp(config) {
|
|
|
9547
10165
|
try {
|
|
9548
10166
|
const originUrl = new URL(origin);
|
|
9549
10167
|
const requestUrl = new URL(context.req.url);
|
|
9550
|
-
if (originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
|
|
10168
|
+
if (originUrl.protocol === requestUrl.protocol && originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
|
|
9551
10169
|
return origin;
|
|
9552
10170
|
}
|
|
9553
10171
|
} catch {
|
|
@@ -9601,7 +10219,13 @@ function createBeechApp(config) {
|
|
|
9601
10219
|
if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400);
|
|
9602
10220
|
const clientIp = getClientIp(context.req);
|
|
9603
10221
|
const loginRateLimit = await context.get("rateLimiters").getLimiter("login").checkLimit(clientIp);
|
|
9604
|
-
if (!loginRateLimit.isAllowed)
|
|
10222
|
+
if (!loginRateLimit.isAllowed) {
|
|
10223
|
+
const headers = {};
|
|
10224
|
+
if (loginRateLimit.retryAfterSeconds !== void 0) {
|
|
10225
|
+
headers["Retry-After"] = String(loginRateLimit.retryAfterSeconds);
|
|
10226
|
+
}
|
|
10227
|
+
return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429, headers);
|
|
10228
|
+
}
|
|
9605
10229
|
const user = await context.get("userRepository").findByEmail(email);
|
|
9606
10230
|
const hashToCompare = user?.passwordHash ?? DUMMY_PASSWORD_HASH;
|
|
9607
10231
|
const isValid = await verifyPassword(password, hashToCompare, context.get("hashProvider"));
|
|
@@ -9626,7 +10250,13 @@ function createBeechApp(config) {
|
|
|
9626
10250
|
try {
|
|
9627
10251
|
const refreshClientIp = getClientIp(context.req);
|
|
9628
10252
|
const refreshRateLimit = await context.get("rateLimiters").getLimiter("tokenRefresh").checkLimit(refreshClientIp);
|
|
9629
|
-
if (!refreshRateLimit.isAllowed)
|
|
10253
|
+
if (!refreshRateLimit.isAllowed) {
|
|
10254
|
+
const headers = {};
|
|
10255
|
+
if (refreshRateLimit.retryAfterSeconds !== void 0) {
|
|
10256
|
+
headers["Retry-After"] = String(refreshRateLimit.retryAfterSeconds);
|
|
10257
|
+
}
|
|
10258
|
+
return context.json({ error: AUTH_ERRORS.RATE_LIMIT_EXCEEDED }, 429, headers);
|
|
10259
|
+
}
|
|
9630
10260
|
const refreshToken = getCookie(context, "refresh_token");
|
|
9631
10261
|
if (!refreshToken) return context.json({ error: "Refresh token missing" }, 401);
|
|
9632
10262
|
const nowSeconds = SystemClock6.nowSeconds();
|
|
@@ -9647,10 +10277,19 @@ function createBeechApp(config) {
|
|
|
9647
10277
|
tokenHash: newRefreshTokenHash,
|
|
9648
10278
|
expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY3
|
|
9649
10279
|
});
|
|
9650
|
-
|
|
9651
|
-
|
|
9652
|
-
|
|
9653
|
-
|
|
10280
|
+
try {
|
|
10281
|
+
const revoked = await context.get("sessionRepository").revokeByHash(tokenHash, nowSeconds);
|
|
10282
|
+
if (!revoked) {
|
|
10283
|
+
await context.get("sessionRepository").revokeByHash(newRefreshTokenHash, nowSeconds);
|
|
10284
|
+
return context.json({ error: "Invalid refresh token" }, 401);
|
|
10285
|
+
}
|
|
10286
|
+
setCookie(context, "refresh_token", newRefreshToken, getRefreshTokenCookieOptions(isRequestSecure(context.req.url)));
|
|
10287
|
+
return context.json({ token: newAccessToken, expiresIn: "15m" }, 200);
|
|
10288
|
+
} catch (error) {
|
|
10289
|
+
await context.get("sessionRepository").revokeByHash(newRefreshTokenHash, nowSeconds).catch(() => {
|
|
10290
|
+
});
|
|
10291
|
+
throw error;
|
|
10292
|
+
}
|
|
9654
10293
|
} catch (error) {
|
|
9655
10294
|
return handleAuthError(context, error, "Refresh");
|
|
9656
10295
|
}
|
|
@@ -9692,7 +10331,7 @@ function createBeechApp(config) {
|
|
|
9692
10331
|
apiPublic.route("/", publicRoutes);
|
|
9693
10332
|
app.route("/api/v1/public", apiPublic);
|
|
9694
10333
|
app.route("/api/webhooks", webhooksApp);
|
|
9695
|
-
app.get("/api/media/:key", (context) => serveMediaHandler(context));
|
|
10334
|
+
app.get("/api/media/:key{.+}", (context) => serveMediaHandler(context));
|
|
9696
10335
|
if (config.customRoutes) {
|
|
9697
10336
|
const publicRouter = new Hono19();
|
|
9698
10337
|
const protectedRouter = new Hono19();
|