@beechcms/api 0.6.0-preview.3 → 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/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-iaYz4FeR.js +1 -0
- package/assets/dashboard/assets/timeseries-chart-recharts-C8Lcylom.js +1 -0
- package/assets/dashboard/index.html +3 -4
- package/dist/auth/constants.d.ts +5 -5
- package/dist/factory.d.ts +7 -1
- 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 +2 -1
- package/dist/index.js +1126 -421
- package/dist/{middleware.d.ts → middleware/auth.middleware.d.ts} +1 -1
- package/dist/middleware/queue.middleware.d.ts +11 -0
- 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/cloudflare-queue-service.d.ts +11 -0
- 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/in-memory-queue-service.d.ts +16 -0
- package/dist/shared/jobs/queue-consumer.d.ts +9 -0
- package/dist/shared/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 +95 -2
- package/package.json +6 -4
- package/assets/dashboard/assets/chart-Cws4Yj6K.js +0 -39
- package/assets/dashboard/assets/index-BjPpppHe.css +0 -1
- package/assets/dashboard/assets/index-CrW-zhkA.js +0 -642
- package/assets/dashboard/assets/pie-chart-recharts-CAxoDQp2.js +0 -1
- package/assets/dashboard/assets/timeseries-chart-recharts-CixDW-LM.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} +19 -19
- /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
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
import { Hono as Hono19 } from "hono";
|
|
3
3
|
import { cors } from "hono/cors";
|
|
4
4
|
import { getCookie, setCookie, deleteCookie } from "hono/cookie";
|
|
5
|
-
import { sha256hex as sha256hex7, SystemClock as
|
|
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
|
}
|
|
@@ -751,7 +786,7 @@ async function updateHandler(context) {
|
|
|
751
786
|
detail: "Missing required field: slug"
|
|
752
787
|
});
|
|
753
788
|
}
|
|
754
|
-
|
|
789
|
+
const mergedData = {};
|
|
755
790
|
if (Object.keys(bodyForData).length > 0) {
|
|
756
791
|
const sensitiveAliases = Object.keys(bodyForData).filter((alias) => {
|
|
757
792
|
const branch = seed.branches.find((b) => b.alias === alias);
|
|
@@ -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,27 +3751,16 @@ 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
|
|
3755
|
+
import { signWebhookBody } from "@beechcms/core/webhook-crypto";
|
|
3670
3756
|
var WEBHOOK_TIMEOUT_MS = 8e3;
|
|
3671
3757
|
var warnedNoSecret = false;
|
|
3672
|
-
async function signBody(body, secret) {
|
|
3673
|
-
const encoder = new TextEncoder();
|
|
3674
|
-
const key = await crypto.subtle.importKey(
|
|
3675
|
-
"raw",
|
|
3676
|
-
encoder.encode(secret),
|
|
3677
|
-
{ name: "HMAC", hash: "SHA-256" },
|
|
3678
|
-
false,
|
|
3679
|
-
["sign"]
|
|
3680
|
-
);
|
|
3681
|
-
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
|
|
3682
|
-
return "sha256=" + Array.from(new Uint8Array(sig)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
3683
|
-
}
|
|
3684
3758
|
async function executeWebhook(action, context, env) {
|
|
3685
3759
|
const body = interpolate(action.body_template ?? "{}", context);
|
|
3686
3760
|
const secret = env.WEBHOOK_SECRET;
|
|
3687
3761
|
let signatureHeader = {};
|
|
3688
3762
|
if (secret) {
|
|
3689
|
-
signatureHeader = { "X-BeechCMS-Signature": await
|
|
3763
|
+
signatureHeader = { "X-BeechCMS-Signature": await signWebhookBody(body, secret) };
|
|
3690
3764
|
} else if (!warnedNoSecret) {
|
|
3691
3765
|
warnedNoSecret = true;
|
|
3692
3766
|
console.warn("[webhook] WEBHOOK_SECRET not set \u2014 outgoing webhooks are unsigned");
|
|
@@ -3713,7 +3787,7 @@ async function executeWebhook(action, context, env) {
|
|
|
3713
3787
|
}
|
|
3714
3788
|
}
|
|
3715
3789
|
|
|
3716
|
-
// src/features/automations/
|
|
3790
|
+
// src/features/automations/executors/send-mail.executor.ts
|
|
3717
3791
|
async function executeSendMail(action, context, env) {
|
|
3718
3792
|
const missingFields = [];
|
|
3719
3793
|
const logMissing = (field) => missingFields.push(field);
|
|
@@ -3746,7 +3820,7 @@ async function executeSendMail(action, context, env) {
|
|
|
3746
3820
|
}
|
|
3747
3821
|
}
|
|
3748
3822
|
|
|
3749
|
-
// src/features/automations/
|
|
3823
|
+
// src/features/automations/executors/edit-field.executor.ts
|
|
3750
3824
|
async function executeEditField(action, entry, context, repository, seed) {
|
|
3751
3825
|
const id = entry.id;
|
|
3752
3826
|
if (typeof id !== "string") {
|
|
@@ -3756,7 +3830,7 @@ async function executeEditField(action, entry, context, repository, seed) {
|
|
|
3756
3830
|
await repository.update(seed, id, { [action.field]: resolved });
|
|
3757
3831
|
}
|
|
3758
3832
|
|
|
3759
|
-
// src/features/automations/
|
|
3833
|
+
// src/features/automations/executors/create-entry.executor.ts
|
|
3760
3834
|
async function executeCreateEntry(action, entry, repository, getSeed, idGenerator) {
|
|
3761
3835
|
const targetSeed = getSeed(action.seed_slug);
|
|
3762
3836
|
if (!targetSeed) throw new Error(`create_entry: unknown seed ${action.seed_slug}`);
|
|
@@ -3768,7 +3842,7 @@ async function executeCreateEntry(action, entry, repository, getSeed, idGenerato
|
|
|
3768
3842
|
await repository.create(targetSeed, newId, newId, "draft", payload);
|
|
3769
3843
|
}
|
|
3770
3844
|
|
|
3771
|
-
// src/features/automations/filter-translation.ts
|
|
3845
|
+
// src/features/automations/filters/filter-translation.ts
|
|
3772
3846
|
var SYSTEM_COLUMNS = /* @__PURE__ */ new Set(["id", "slug", "status", "created_at", "updated_at"]);
|
|
3773
3847
|
function mapBranchTypeToFilterType(type) {
|
|
3774
3848
|
switch (type) {
|
|
@@ -3806,7 +3880,7 @@ function conditionToFilterGroup(c, seed) {
|
|
|
3806
3880
|
};
|
|
3807
3881
|
}
|
|
3808
3882
|
|
|
3809
|
-
// src/features/automations/
|
|
3883
|
+
// src/features/automations/executors/set-variable.executor.ts
|
|
3810
3884
|
async function executeSetVariable(action, ctx) {
|
|
3811
3885
|
const seedSlug = action.seed_slug ?? ctx.seed.slug;
|
|
3812
3886
|
const targetSeed = ctx.getSeed(seedSlug);
|
|
@@ -3919,7 +3993,7 @@ function calculateGeneralStats(seed, items) {
|
|
|
3919
3993
|
return { count: items.length, sum, avg, min, max, pluck };
|
|
3920
3994
|
}
|
|
3921
3995
|
|
|
3922
|
-
// src/features/automations/
|
|
3996
|
+
// src/features/automations/executors/index.ts
|
|
3923
3997
|
async function executeAction(action, ctx) {
|
|
3924
3998
|
switch (action.type) {
|
|
3925
3999
|
case "set_variable":
|
|
@@ -3939,7 +4013,7 @@ async function executeAction(action, ctx) {
|
|
|
3939
4013
|
}
|
|
3940
4014
|
}
|
|
3941
4015
|
|
|
3942
|
-
// src/features/automations/when-evaluator.ts
|
|
4016
|
+
// src/features/automations/filters/when-evaluator.ts
|
|
3943
4017
|
function evaluateWhen(node, context) {
|
|
3944
4018
|
if (!node) return true;
|
|
3945
4019
|
if (node.kind === "predicate") return evalPredicate(node, context);
|
|
@@ -4019,7 +4093,7 @@ function coerceEqual(a, b) {
|
|
|
4019
4093
|
return a === b;
|
|
4020
4094
|
}
|
|
4021
4095
|
|
|
4022
|
-
// src/features/automations/var-access-resolver.ts
|
|
4096
|
+
// src/features/automations/evaluator/var-access-resolver.ts
|
|
4023
4097
|
function toNumeric(v) {
|
|
4024
4098
|
const n = Number(v);
|
|
4025
4099
|
if (Number.isFinite(n)) return n;
|
|
@@ -4122,7 +4196,7 @@ function resolveVarAccess(parsed, variables, onMissing) {
|
|
|
4122
4196
|
return current;
|
|
4123
4197
|
}
|
|
4124
4198
|
|
|
4125
|
-
// src/features/automations/automation-runner.ts
|
|
4199
|
+
// src/features/automations/engine/automation-runner.ts
|
|
4126
4200
|
function withVariables(base, variables) {
|
|
4127
4201
|
return {
|
|
4128
4202
|
triggerEntry: base.triggerEntry,
|
|
@@ -4175,10 +4249,10 @@ var AutomationRunner = class {
|
|
|
4175
4249
|
}
|
|
4176
4250
|
};
|
|
4177
4251
|
|
|
4178
|
-
// src/features/automations/automations.handler.ts
|
|
4179
|
-
import { Hono as
|
|
4252
|
+
// src/features/automations/api/automations.handler.ts
|
|
4253
|
+
import { Hono as Hono12 } from "hono";
|
|
4180
4254
|
|
|
4181
|
-
// src/features/automations/automations.schema.ts
|
|
4255
|
+
// src/features/automations/api/automations.schema.ts
|
|
4182
4256
|
import { z as z2 } from "zod";
|
|
4183
4257
|
import { isPrivateHost } from "@beechcms/core";
|
|
4184
4258
|
var whenOperandSchema = z2.union([
|
|
@@ -4309,8 +4383,8 @@ var toggleAutomationSchema = z2.object({
|
|
|
4309
4383
|
enabled: z2.boolean()
|
|
4310
4384
|
});
|
|
4311
4385
|
|
|
4312
|
-
// src/features/automations/automations.handler.ts
|
|
4313
|
-
var automationsApp = new
|
|
4386
|
+
// src/features/automations/api/automations.handler.ts
|
|
4387
|
+
var automationsApp = new Hono12();
|
|
4314
4388
|
automationsApp.get("/", async (context) => {
|
|
4315
4389
|
const seedSlug = context.req.query("seed");
|
|
4316
4390
|
if (!seedSlug) {
|
|
@@ -4456,7 +4530,7 @@ automationsApp.delete("/:id", async (context) => {
|
|
|
4456
4530
|
});
|
|
4457
4531
|
|
|
4458
4532
|
// src/features/stats/stats.handler.ts
|
|
4459
|
-
import { Hono as
|
|
4533
|
+
import { Hono as Hono13 } from "hono";
|
|
4460
4534
|
import { SystemClock } from "@beechcms/core";
|
|
4461
4535
|
var DATABASE_ERROR = "Database error";
|
|
4462
4536
|
var INTERNAL_SERVER_ERROR = "Internal Server Error";
|
|
@@ -4478,7 +4552,7 @@ function getMimeType(extension) {
|
|
|
4478
4552
|
const type = extension === "jpg" ? "jpeg" : extension || "jpeg";
|
|
4479
4553
|
return `image/${type}`;
|
|
4480
4554
|
}
|
|
4481
|
-
var statsApp = new
|
|
4555
|
+
var statsApp = new Hono13();
|
|
4482
4556
|
statsApp.get("/stats/media-library", async (context) => {
|
|
4483
4557
|
try {
|
|
4484
4558
|
const mediaRepository = context.get("mediaRepository");
|
|
@@ -4774,7 +4848,7 @@ statsApp.post("/stats/storage/sync", async (context) => {
|
|
|
4774
4848
|
});
|
|
4775
4849
|
|
|
4776
4850
|
// src/features/backrefs/backrefs.handler.ts
|
|
4777
|
-
import { Hono as
|
|
4851
|
+
import { Hono as Hono14 } from "hono";
|
|
4778
4852
|
import { resolvePolicies as resolvePolicies6 } from "@beechcms/core";
|
|
4779
4853
|
|
|
4780
4854
|
// src/features/backrefs/d1-backref.repository.ts
|
|
@@ -4826,7 +4900,7 @@ var D1BackrefRepository = class {
|
|
|
4826
4900
|
var PREVIEW_LIMIT = 3;
|
|
4827
4901
|
var DEFAULT_PAGE_LIMIT = 20;
|
|
4828
4902
|
var MAX_PAGE_LIMIT = 100;
|
|
4829
|
-
var backrefsApp = new
|
|
4903
|
+
var backrefsApp = new Hono14();
|
|
4830
4904
|
backrefsApp.get("/:targetSlug/:targetId/backrefs", async (c) => {
|
|
4831
4905
|
const targetSlug = c.req.param("targetSlug");
|
|
4832
4906
|
const targetId = c.req.param("targetId");
|
|
@@ -4946,9 +5020,9 @@ function filterVisibility(items, sourceSeed) {
|
|
|
4946
5020
|
}
|
|
4947
5021
|
|
|
4948
5022
|
// src/features/webhooks/index.ts
|
|
4949
|
-
import { Hono as
|
|
5023
|
+
import { Hono as Hono15 } from "hono";
|
|
4950
5024
|
import { Receiver } from "@upstash/qstash";
|
|
4951
|
-
var webhooksApp = new
|
|
5025
|
+
var webhooksApp = new Hono15();
|
|
4952
5026
|
webhooksApp.post("/qstash", async (context) => {
|
|
4953
5027
|
const currentSigningKey = context.env.QSTASH_CURRENT_SIGNING_KEY;
|
|
4954
5028
|
const nextSigningKey = context.env.QSTASH_NEXT_SIGNING_KEY;
|
|
@@ -4956,41 +5030,214 @@ webhooksApp.post("/qstash", async (context) => {
|
|
|
4956
5030
|
console.error("QStash webhook called but signing keys are not configured");
|
|
4957
5031
|
return context.text("Configuration error", 500);
|
|
4958
5032
|
}
|
|
4959
|
-
const receiver = new Receiver({
|
|
4960
|
-
currentSigningKey,
|
|
4961
|
-
nextSigningKey
|
|
4962
|
-
});
|
|
4963
|
-
const body = await context.req.text();
|
|
4964
|
-
const signature = context.req.header("Upstash-Signature");
|
|
4965
|
-
if (!signature) {
|
|
4966
|
-
return context.text("Missing signature", 401);
|
|
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);
|
|
5140
|
+
}
|
|
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);
|
|
5149
|
+
}
|
|
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
|
|
5160
|
+
});
|
|
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);
|
|
4967
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 });
|
|
4968
5221
|
try {
|
|
4969
|
-
const
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
if (
|
|
4974
|
-
|
|
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);
|
|
4975
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 });
|
|
4976
5236
|
} catch (err) {
|
|
4977
|
-
console.error(
|
|
4978
|
-
return
|
|
4979
|
-
}
|
|
4980
|
-
try {
|
|
4981
|
-
const input = JSON.parse(body);
|
|
4982
|
-
const notificationRepository = context.get("notificationRepository");
|
|
4983
|
-
await notificationRepository.create({
|
|
4984
|
-
title: input.title,
|
|
4985
|
-
message: input.message,
|
|
4986
|
-
type: input.type ?? "info"
|
|
4987
|
-
});
|
|
4988
|
-
return context.text("OK", 200);
|
|
4989
|
-
} catch (error) {
|
|
4990
|
-
console.error("Failed to process QStash webhook payload", error);
|
|
4991
|
-
return context.text("Internal server error", 500);
|
|
5237
|
+
console.error(`[serveMediaHandler] Error serving file ${key}:`, err);
|
|
5238
|
+
return new Response("Internal error", { status: 500 });
|
|
4992
5239
|
}
|
|
4993
|
-
}
|
|
5240
|
+
}
|
|
4994
5241
|
|
|
4995
5242
|
// src/public/public-errors.ts
|
|
4996
5243
|
var PUBLIC_ERRORS = {
|
|
@@ -5051,11 +5298,16 @@ function publicRateLimitMiddleware() {
|
|
|
5051
5298
|
const key = `${getClientIp(c.req)}:${seed}:${limiterName}`;
|
|
5052
5299
|
const result = await c.get("rateLimiters").getLimiter(limiterName).checkLimit(key);
|
|
5053
5300
|
if (!result.isAllowed) {
|
|
5301
|
+
const headers = {};
|
|
5302
|
+
if (result.retryAfterSeconds !== void 0) {
|
|
5303
|
+
headers["Retry-After"] = String(result.retryAfterSeconds);
|
|
5304
|
+
}
|
|
5054
5305
|
return publicProblem(c, {
|
|
5055
5306
|
type: "rate-limit-exceeded",
|
|
5056
5307
|
title: "Too Many Requests",
|
|
5057
5308
|
status: 429,
|
|
5058
|
-
detail: "Too many requests"
|
|
5309
|
+
detail: "Too many requests",
|
|
5310
|
+
headers
|
|
5059
5311
|
});
|
|
5060
5312
|
}
|
|
5061
5313
|
await next();
|
|
@@ -5374,10 +5626,10 @@ import { isValidContentStatus as isValidContentStatus3, SlugConflictError as Slu
|
|
|
5374
5626
|
import { slugify as slugify3, generateEntrySlug } from "@beechcms/core";
|
|
5375
5627
|
|
|
5376
5628
|
// src/public/sanitize.ts
|
|
5377
|
-
import { validateAndSanitizeSeedPayload as
|
|
5629
|
+
import { validateAndSanitizeSeedPayload as validateAndSanitizeSeedPayload6 } from "@beechcms/core";
|
|
5378
5630
|
function sanitizePublicPayload(seed, payload, options = {}) {
|
|
5379
5631
|
const operation = options.operation ?? "create";
|
|
5380
|
-
const result =
|
|
5632
|
+
const result = validateAndSanitizeSeedPayload6(seed, payload, {
|
|
5381
5633
|
allowNull: options.allowNull ?? false,
|
|
5382
5634
|
operation,
|
|
5383
5635
|
requireAtLeastOneValidField: options.requireAtLeastOneValidField ?? true,
|
|
@@ -5689,10 +5941,10 @@ publicApp.post("/:seed/add", publicAddHandler);
|
|
|
5689
5941
|
publicApp.put("/:seed/edit/:id", publicEditHandler);
|
|
5690
5942
|
var publicRoutes = publicApp;
|
|
5691
5943
|
|
|
5692
|
-
// src/search.ts
|
|
5944
|
+
// src/features/search/search.ts
|
|
5693
5945
|
import { Hono as Hono18 } from "hono";
|
|
5694
5946
|
|
|
5695
|
-
// src/search-utils.ts
|
|
5947
|
+
// src/features/search/search-utils.ts
|
|
5696
5948
|
function encodeCursor(rank, entryId) {
|
|
5697
5949
|
return btoa(`${rank}:${entryId}`);
|
|
5698
5950
|
}
|
|
@@ -5797,7 +6049,7 @@ function mapSearchResultRow(row) {
|
|
|
5797
6049
|
};
|
|
5798
6050
|
}
|
|
5799
6051
|
|
|
5800
|
-
// src/search.ts
|
|
6052
|
+
// src/features/search/search.ts
|
|
5801
6053
|
var searchRouter = new Hono18();
|
|
5802
6054
|
searchRouter.use("*", authMiddleware());
|
|
5803
6055
|
searchRouter.get("/", async (c) => {
|
|
@@ -5838,19 +6090,19 @@ searchRouter.get("/", async (c) => {
|
|
|
5838
6090
|
// src/middleware/repository.middleware.ts
|
|
5839
6091
|
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
5840
6092
|
|
|
5841
|
-
// src/shared/content.repository.d1.ts
|
|
6093
|
+
// src/shared/db/repositories/content.repository.d1.ts
|
|
5842
6094
|
import {
|
|
5843
6095
|
EntryNotFoundError as EntryNotFoundError7,
|
|
5844
6096
|
RepositoryError as RepositoryError2,
|
|
5845
|
-
SlugConflictError as
|
|
6097
|
+
SlugConflictError as SlugConflictError4,
|
|
5846
6098
|
RelationTargetNotFoundError as RelationTargetNotFoundError2,
|
|
5847
6099
|
buildSelectQuery,
|
|
5848
6100
|
deserializeFromDb as deserializeFromDb2,
|
|
5849
6101
|
serializeForDb
|
|
5850
6102
|
} from "@beechcms/core";
|
|
5851
6103
|
|
|
5852
|
-
// src/shared/base.repository.d1.ts
|
|
5853
|
-
import { RepositoryError } from "@beechcms/core";
|
|
6104
|
+
// src/shared/db/repositories/base.repository.d1.ts
|
|
6105
|
+
import { RepositoryError, SlugConflictError as SlugConflictError3 } from "@beechcms/core";
|
|
5854
6106
|
var BaseD1Repository = class {
|
|
5855
6107
|
constructor(database) {
|
|
5856
6108
|
this.database = database;
|
|
@@ -5868,11 +6120,14 @@ var BaseD1Repository = class {
|
|
|
5868
6120
|
*/
|
|
5869
6121
|
mapError(error, context) {
|
|
5870
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
|
+
}
|
|
5871
6126
|
return new RepositoryError(`${context}: ${message}`, error);
|
|
5872
6127
|
}
|
|
5873
6128
|
};
|
|
5874
6129
|
|
|
5875
|
-
// src/shared/content.repository.d1.ts
|
|
6130
|
+
// src/shared/db/repositories/content.repository.d1.ts
|
|
5876
6131
|
function multiRelBranches(seed) {
|
|
5877
6132
|
return seed.branches.filter((b) => b.type === "relation" && b.multiple === true);
|
|
5878
6133
|
}
|
|
@@ -5891,6 +6146,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
5891
6146
|
this.hooks = hooks;
|
|
5892
6147
|
}
|
|
5893
6148
|
hooks;
|
|
6149
|
+
/** Assembles the {@link HookContext} passed to every lifecycle hook invocation. */
|
|
5894
6150
|
hookCtx(seed, actor) {
|
|
5895
6151
|
return { seed, repository: this, actor, db: this.database };
|
|
5896
6152
|
}
|
|
@@ -5912,10 +6168,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
5912
6168
|
data[branch.alias] = deserializeFromDb2(branch, row[branch.alias]);
|
|
5913
6169
|
}
|
|
5914
6170
|
}
|
|
6171
|
+
if (row.position !== void 0) data.position = row.position;
|
|
5915
6172
|
return data;
|
|
5916
6173
|
}
|
|
5917
6174
|
/**
|
|
5918
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.
|
|
5919
6180
|
*/
|
|
5920
6181
|
async attachMultiRelations(seed, entryId, data) {
|
|
5921
6182
|
const branches = multiRelBranches(seed);
|
|
@@ -5929,51 +6190,85 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
5929
6190
|
}
|
|
5930
6191
|
}
|
|
5931
6192
|
/**
|
|
5932
|
-
*
|
|
5933
|
-
*
|
|
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.
|
|
5934
6200
|
*/
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
if (branches.length === 0 || entries.length === 0) return;
|
|
5938
|
-
const ids = entries.map((e) => e.id);
|
|
5939
|
-
const placeholders = ids.map(() => "?").join(", ");
|
|
5940
|
-
const stmts = branches.map(
|
|
6201
|
+
multiRelStatements(seed, branches, idQuery) {
|
|
6202
|
+
return branches.map(
|
|
5941
6203
|
(b) => this.database.prepare(
|
|
5942
|
-
`SELECT parent_id, target_id FROM ${jTable(seed.slug, b.alias)} WHERE parent_id IN (${
|
|
5943
|
-
).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)
|
|
5944
6206
|
);
|
|
5945
|
-
|
|
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) {
|
|
5946
6215
|
for (let i = 0; i < branches.length; i++) {
|
|
5947
6216
|
const branch = branches[i];
|
|
5948
6217
|
const byParent = /* @__PURE__ */ new Map();
|
|
5949
|
-
for (const row of
|
|
6218
|
+
for (const row of relResults[i].results ?? []) {
|
|
5950
6219
|
const r = row;
|
|
5951
6220
|
const pid = r.parent_id;
|
|
5952
|
-
|
|
5953
|
-
|
|
6221
|
+
let targets = byParent.get(pid);
|
|
6222
|
+
if (!targets) {
|
|
6223
|
+
targets = [];
|
|
6224
|
+
byParent.set(pid, targets);
|
|
6225
|
+
}
|
|
6226
|
+
targets.push(r.target_id);
|
|
5954
6227
|
}
|
|
5955
6228
|
for (const entry of entries) {
|
|
5956
6229
|
entry[branch.alias] = byParent.get(entry.id) ?? [];
|
|
5957
6230
|
}
|
|
5958
6231
|
}
|
|
5959
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
|
+
*/
|
|
5960
6245
|
async findMany(seed, options) {
|
|
5961
6246
|
try {
|
|
5962
6247
|
const { sql, bindings } = buildSelectQuery(seed, options);
|
|
5963
|
-
const
|
|
5964
|
-
|
|
5965
|
-
|
|
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([
|
|
5966
6255
|
this.database.prepare(sql).bind(...bindings),
|
|
5967
|
-
this.database.prepare(countSql).bind(...countBindings)
|
|
6256
|
+
this.database.prepare(countSql).bind(...countBindings),
|
|
6257
|
+
...relStmts
|
|
5968
6258
|
]);
|
|
5969
6259
|
const contentEntries = (batchResults.results || []).map((row) => this.rowToData(seed, row));
|
|
5970
6260
|
const totalEntriesCount = totalCountResult.results?.[0]?.total || 0;
|
|
5971
|
-
|
|
6261
|
+
this.attachMultiRelationRows(branches, relResults, contentEntries);
|
|
5972
6262
|
return { items: contentEntries, total: totalEntriesCount };
|
|
5973
6263
|
} catch (error) {
|
|
5974
6264
|
throw this.mapError(error, `findMany(${seed.slug})`);
|
|
5975
6265
|
}
|
|
5976
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
|
+
*/
|
|
5977
6272
|
async findById(seed, id) {
|
|
5978
6273
|
try {
|
|
5979
6274
|
const tableName = this.getTableName(seed.slug);
|
|
@@ -5989,6 +6284,11 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
5989
6284
|
throw this.mapError(error, `findById(${seed.slug}, ${id})`);
|
|
5990
6285
|
}
|
|
5991
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
|
+
*/
|
|
5992
6292
|
async findBySlug(seed, slug) {
|
|
5993
6293
|
try {
|
|
5994
6294
|
const tableName = this.getTableName(seed.slug);
|
|
@@ -6004,6 +6304,11 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6004
6304
|
throw this.mapError(error, `findBySlug(${seed.slug}, ${slug})`);
|
|
6005
6305
|
}
|
|
6006
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
|
+
*/
|
|
6007
6312
|
async getFacets(seed) {
|
|
6008
6313
|
try {
|
|
6009
6314
|
const tableName = this.getTableName(seed.slug);
|
|
@@ -6023,6 +6328,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6023
6328
|
throw this.mapError(error, `getFacets(${seed.slug})`);
|
|
6024
6329
|
}
|
|
6025
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
|
+
*/
|
|
6026
6335
|
async existsSlug(seed, slug, excludeId) {
|
|
6027
6336
|
try {
|
|
6028
6337
|
const tableName = this.getTableName(seed.slug);
|
|
@@ -6038,6 +6347,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6038
6347
|
throw this.mapError(error, `existsSlug(${seed.slug}, ${slug})`);
|
|
6039
6348
|
}
|
|
6040
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
|
+
*/
|
|
6041
6354
|
buildCreateMainStmt(seed, id, slug, status, data) {
|
|
6042
6355
|
const tableName = this.getTableName(seed.slug);
|
|
6043
6356
|
const columnNames = ["id", "slug", "status"];
|
|
@@ -6055,6 +6368,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6055
6368
|
const mainSql = `INSERT INTO ${tableName} (${columnNames.join(", ")}) VALUES (${placeholders.join(", ")})`;
|
|
6056
6369
|
return this.database.prepare(mainSql).bind(...queryBindings);
|
|
6057
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
|
+
*/
|
|
6058
6380
|
buildUpdateMainStmt(seed, id, data, status) {
|
|
6059
6381
|
const tableName = this.getTableName(seed.slug);
|
|
6060
6382
|
const updateClauses = [];
|
|
@@ -6065,6 +6387,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6065
6387
|
updateClauses.push("status = ?");
|
|
6066
6388
|
queryBindings.push(status);
|
|
6067
6389
|
}
|
|
6390
|
+
if (data.slug) {
|
|
6391
|
+
updateClauses.push("slug = ?");
|
|
6392
|
+
queryBindings.push(data.slug);
|
|
6393
|
+
}
|
|
6068
6394
|
for (const branch of seed.branches) {
|
|
6069
6395
|
if (mRelAliases.has(branch.alias)) continue;
|
|
6070
6396
|
if (Object.hasOwn(data, branch.alias)) {
|
|
@@ -6084,18 +6410,28 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6084
6410
|
junctionUpdates
|
|
6085
6411
|
};
|
|
6086
6412
|
}
|
|
6413
|
+
/** Builds one `INSERT` per target id into a live multi-relation junction table, preserving array order via `position`. */
|
|
6087
6414
|
buildJunctionInserts(seedSlug, parentId, branchAlias, targetIds) {
|
|
6088
6415
|
const jt = jTable(seedSlug, branchAlias);
|
|
6089
6416
|
return targetIds.map(
|
|
6090
6417
|
(targetId, i) => this.database.prepare(`INSERT INTO ${jt} (parent_id, target_id, position) VALUES (?, ?, ?)`).bind(parentId, targetId, i)
|
|
6091
6418
|
);
|
|
6092
6419
|
}
|
|
6420
|
+
/** Builds one `INSERT` per target id into a draft multi-relation junction table, preserving array order via `position`. */
|
|
6093
6421
|
buildDraftJunctionInserts(seedSlug, entryId, branchAlias, targetIds) {
|
|
6094
6422
|
const jdt = jDraftTable(seedSlug, branchAlias);
|
|
6095
6423
|
return targetIds.map(
|
|
6096
6424
|
(targetId, i) => this.database.prepare(`INSERT INTO ${jdt} (entry_id, target_id, position) VALUES (?, ?, ?)`).bind(entryId, targetId, i)
|
|
6097
6425
|
);
|
|
6098
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
|
+
*/
|
|
6099
6435
|
async validatePublishDraftRelations(seed, draftRow, entryId) {
|
|
6100
6436
|
for (const branch of singleRelBranches(seed)) {
|
|
6101
6437
|
const value = draftRow[branch.alias];
|
|
@@ -6129,6 +6465,12 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6129
6465
|
}
|
|
6130
6466
|
return { mRelBranches, mRelDraftIds };
|
|
6131
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
|
+
*/
|
|
6132
6474
|
getBulkArrayUpdateStmts(seedSlug, id, alias, update) {
|
|
6133
6475
|
const jt = jTable(seedSlug, alias);
|
|
6134
6476
|
const stmts = [];
|
|
@@ -6154,16 +6496,23 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6154
6496
|
}
|
|
6155
6497
|
return stmts;
|
|
6156
6498
|
}
|
|
6157
|
-
|
|
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) {
|
|
6158
6505
|
const stmts = [];
|
|
6159
6506
|
const setClauses = ["updated_at = (unixepoch())"];
|
|
6160
6507
|
const setBindings = [];
|
|
6161
6508
|
for (const [alias, update] of Object.entries(fields)) {
|
|
6162
6509
|
if (update.kind === "set") {
|
|
6163
|
-
|
|
6164
|
-
|
|
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);
|
|
6165
6514
|
} else {
|
|
6166
|
-
stmts.push(...this.getBulkArrayUpdateStmts(
|
|
6515
|
+
stmts.push(...this.getBulkArrayUpdateStmts(seed.slug, id, alias, update));
|
|
6167
6516
|
}
|
|
6168
6517
|
}
|
|
6169
6518
|
stmts.unshift(
|
|
@@ -6174,12 +6523,17 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6174
6523
|
throw new Error("not-found");
|
|
6175
6524
|
}
|
|
6176
6525
|
}
|
|
6177
|
-
|
|
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) {
|
|
6178
6532
|
let updated = 0;
|
|
6179
6533
|
const failed = [];
|
|
6180
6534
|
for (const id of chunk) {
|
|
6181
6535
|
try {
|
|
6182
|
-
await this.processBulkUpdateSingle(
|
|
6536
|
+
await this.processBulkUpdateSingle(seed, id, fields, tableName);
|
|
6183
6537
|
updated++;
|
|
6184
6538
|
} catch (err) {
|
|
6185
6539
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -6193,6 +6547,14 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6193
6547
|
}
|
|
6194
6548
|
return { updated, failed };
|
|
6195
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
|
+
*/
|
|
6196
6558
|
async create(seed, id, slug, status, data, options) {
|
|
6197
6559
|
let payload = data;
|
|
6198
6560
|
if (this.hooks?.beforeCreate) {
|
|
@@ -6201,7 +6563,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6201
6563
|
}
|
|
6202
6564
|
try {
|
|
6203
6565
|
if (await this.existsSlug(seed, slug)) {
|
|
6204
|
-
throw new
|
|
6566
|
+
throw new SlugConflictError4(`Slug "${slug}" already exists for ${seed.slug}`);
|
|
6205
6567
|
}
|
|
6206
6568
|
const batchStmts = [
|
|
6207
6569
|
this.buildCreateMainStmt(seed, id, slug, status, payload)
|
|
@@ -6217,7 +6579,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6217
6579
|
await this.database.batch(batchStmts);
|
|
6218
6580
|
}
|
|
6219
6581
|
} catch (error) {
|
|
6220
|
-
if (error instanceof
|
|
6582
|
+
if (error instanceof SlugConflictError4) throw error;
|
|
6221
6583
|
throw this.mapError(error, `create(${seed.slug})`);
|
|
6222
6584
|
}
|
|
6223
6585
|
if (this.hooks?.afterCreate) {
|
|
@@ -6225,6 +6587,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6225
6587
|
await this.hooks.afterCreate(entry, this.hookCtx(seed, options?.actor));
|
|
6226
6588
|
}
|
|
6227
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
|
+
*/
|
|
6228
6599
|
async update(seed, id, data, status, options) {
|
|
6229
6600
|
let payload = data;
|
|
6230
6601
|
if (this.hooks?.beforeUpdate) {
|
|
@@ -6262,6 +6633,14 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6262
6633
|
await this.hooks.afterUpdate(entry, this.hookCtx(seed, options?.actor));
|
|
6263
6634
|
}
|
|
6264
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
|
+
*/
|
|
6265
6644
|
async delete(seed, id, options) {
|
|
6266
6645
|
if (this.hooks?.beforeDelete) {
|
|
6267
6646
|
await this.hooks.beforeDelete(id, this.hookCtx(seed, options?.actor));
|
|
@@ -6284,6 +6663,14 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6284
6663
|
}
|
|
6285
6664
|
return { row };
|
|
6286
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
|
+
*/
|
|
6287
6674
|
async mutateField(seed, id, fieldName, operation, options) {
|
|
6288
6675
|
try {
|
|
6289
6676
|
const branch = seed.branches.find((b) => b.alias === fieldName);
|
|
@@ -6313,6 +6700,14 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6313
6700
|
throw this.mapError(error, `mutateField(${seed.slug}, ${id}, ${fieldName})`);
|
|
6314
6701
|
}
|
|
6315
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
|
+
*/
|
|
6316
6711
|
async runBatch(operations) {
|
|
6317
6712
|
if (operations.length === 0) return;
|
|
6318
6713
|
try {
|
|
@@ -6361,6 +6756,11 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6361
6756
|
throw this.mapError(error, "runBatch");
|
|
6362
6757
|
}
|
|
6363
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
|
+
*/
|
|
6364
6764
|
async saveDraft(seed, entryId, data) {
|
|
6365
6765
|
try {
|
|
6366
6766
|
if (!seed.allowDrafts) {
|
|
@@ -6369,6 +6769,21 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6369
6769
|
const draftTableName = this.getTableName(seed.slug, true);
|
|
6370
6770
|
const mRelBranches = multiRelBranches(seed);
|
|
6371
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);
|
|
6372
6787
|
const columnNames = ["entry_id"];
|
|
6373
6788
|
const placeholders = ["?"];
|
|
6374
6789
|
const queryBindings = [entryId];
|
|
@@ -6383,6 +6798,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6383
6798
|
updateClauses.push(`${branch.alias} = EXCLUDED.${branch.alias}`);
|
|
6384
6799
|
}
|
|
6385
6800
|
}
|
|
6801
|
+
columnNames.push("_touched_fields");
|
|
6802
|
+
placeholders.push("?");
|
|
6803
|
+
queryBindings.push(JSON.stringify(updatedTouchedFields));
|
|
6804
|
+
updateClauses.push(`_touched_fields = EXCLUDED._touched_fields`);
|
|
6386
6805
|
updateClauses.push("updated_at = (unixepoch())");
|
|
6387
6806
|
const sql = `
|
|
6388
6807
|
INSERT INTO ${draftTableName} (${columnNames.join(", ")})
|
|
@@ -6414,6 +6833,10 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6414
6833
|
throw this.mapError(error, `saveDraft(${seed.slug}, ${entryId})`);
|
|
6415
6834
|
}
|
|
6416
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
|
+
*/
|
|
6417
6840
|
async getDraft(seed, entryId) {
|
|
6418
6841
|
try {
|
|
6419
6842
|
if (!seed.allowDrafts) return null;
|
|
@@ -6422,10 +6845,20 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6422
6845
|
if (!draftRow) return null;
|
|
6423
6846
|
const mRelBranches = multiRelBranches(seed);
|
|
6424
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);
|
|
6425
6856
|
const draftData = {};
|
|
6426
6857
|
for (const branch of seed.branches) {
|
|
6427
6858
|
if (mRelAliases.has(branch.alias)) continue;
|
|
6428
|
-
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) {
|
|
6429
6862
|
draftData[branch.alias] = deserializeFromDb2(branch, draftRow[branch.alias]);
|
|
6430
6863
|
}
|
|
6431
6864
|
}
|
|
@@ -6435,9 +6868,12 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6435
6868
|
);
|
|
6436
6869
|
const results = await this.database.batch(stmts);
|
|
6437
6870
|
for (let i = 0; i < mRelBranches.length; i++) {
|
|
6871
|
+
const alias = mRelBranches[i].alias;
|
|
6438
6872
|
const ids = (results[i].results ?? []).map((r) => r.target_id);
|
|
6439
|
-
if (
|
|
6440
|
-
draftData[
|
|
6873
|
+
if (touchedSet.has(alias)) {
|
|
6874
|
+
draftData[alias] = ids;
|
|
6875
|
+
} else if (ids.length > 0) {
|
|
6876
|
+
draftData[alias] = ids;
|
|
6441
6877
|
}
|
|
6442
6878
|
}
|
|
6443
6879
|
}
|
|
@@ -6446,6 +6882,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6446
6882
|
throw this.mapError(error, `getDraft(${seed.slug}, ${entryId})`);
|
|
6447
6883
|
}
|
|
6448
6884
|
}
|
|
6885
|
+
/** Checks whether a draft row exists for `entryId`. Always `false` if `seed.allowDrafts` is false. */
|
|
6449
6886
|
async hasDraft(seed, entryId) {
|
|
6450
6887
|
try {
|
|
6451
6888
|
if (!seed.allowDrafts) return false;
|
|
@@ -6456,6 +6893,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6456
6893
|
throw this.mapError(error, `hasDraft(${seed.slug}, ${entryId})`);
|
|
6457
6894
|
}
|
|
6458
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
|
+
*/
|
|
6459
6905
|
async publishDraft(seed, entryId) {
|
|
6460
6906
|
try {
|
|
6461
6907
|
if (!seed.allowDrafts) return;
|
|
@@ -6465,33 +6911,56 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6465
6911
|
if (!draftRow) {
|
|
6466
6912
|
throw new EntryNotFoundError7(`No draft found for ${entryId} in ${seed.slug}`);
|
|
6467
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);
|
|
6468
6922
|
const { mRelBranches, mRelDraftIds } = await this.validatePublishDraftRelations(seed, draftRow, entryId);
|
|
6469
6923
|
const mRelAliases = new Set(mRelBranches.map((b) => b.alias));
|
|
6470
6924
|
const updateClauses = [];
|
|
6471
6925
|
const queryBindings = [];
|
|
6472
6926
|
for (const branch of seed.branches) {
|
|
6473
6927
|
if (mRelAliases.has(branch.alias)) continue;
|
|
6474
|
-
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) {
|
|
6475
6932
|
updateClauses.push(`${branch.alias} = ?`);
|
|
6476
6933
|
queryBindings.push(draftRow[branch.alias]);
|
|
6477
6934
|
}
|
|
6478
6935
|
}
|
|
6479
|
-
|
|
6480
|
-
|
|
6481
|
-
|
|
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
|
+
}
|
|
6482
6945
|
const batchStmts = [
|
|
6483
6946
|
this.database.prepare(updateSql).bind(...queryBindings),
|
|
6484
6947
|
this.database.prepare(`DELETE FROM ${draftTableName} WHERE entry_id = ?`).bind(entryId)
|
|
6485
6948
|
];
|
|
6486
6949
|
for (const branch of mRelBranches) {
|
|
6487
|
-
const lt = jTable(seed.slug, branch.alias);
|
|
6488
6950
|
const jdt = jDraftTable(seed.slug, branch.alias);
|
|
6489
|
-
|
|
6490
|
-
|
|
6491
|
-
|
|
6492
|
-
|
|
6493
|
-
|
|
6494
|
-
|
|
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
|
+
}
|
|
6495
6964
|
}
|
|
6496
6965
|
await this.database.batch(batchStmts);
|
|
6497
6966
|
} catch (error) {
|
|
@@ -6500,19 +6969,25 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6500
6969
|
throw this.mapError(error, `publishDraft(${seed.slug}, ${entryId})`);
|
|
6501
6970
|
}
|
|
6502
6971
|
}
|
|
6503
|
-
|
|
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) {
|
|
6504
6978
|
const CHUNK = 50;
|
|
6505
|
-
const tableName = this.getTableName(
|
|
6979
|
+
const tableName = this.getTableName(seed.slug);
|
|
6506
6980
|
let updated = 0;
|
|
6507
6981
|
const failed = [];
|
|
6508
6982
|
for (let offset = 0; offset < ids.length; offset += CHUNK) {
|
|
6509
6983
|
const chunk = ids.slice(offset, offset + CHUNK);
|
|
6510
|
-
const res = await this.processBulkUpdateChunk(
|
|
6984
|
+
const res = await this.processBulkUpdateChunk(seed, chunk, fields, tableName);
|
|
6511
6985
|
updated += res.updated;
|
|
6512
6986
|
failed.push(...res.failed);
|
|
6513
6987
|
}
|
|
6514
6988
|
return { updated, failed };
|
|
6515
6989
|
}
|
|
6990
|
+
/** Deletes the draft row for `entryId`, if any. A no-op if `seed.allowDrafts` is false. */
|
|
6516
6991
|
async deleteDraft(seed, entryId) {
|
|
6517
6992
|
try {
|
|
6518
6993
|
if (!seed.allowDrafts) return;
|
|
@@ -6522,6 +6997,15 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6522
6997
|
throw this.mapError(error, `deleteDraft(${seed.slug}, ${entryId})`);
|
|
6523
6998
|
}
|
|
6524
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
|
+
*/
|
|
6525
7009
|
async findPendingDrafts(seeds) {
|
|
6526
7010
|
try {
|
|
6527
7011
|
if (seeds.length === 0) return [];
|
|
@@ -6531,6 +7015,8 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6531
7015
|
const draftTable = `content_${seed.slug}_drafts`;
|
|
6532
7016
|
const liveTable = `content_${seed.slug}`;
|
|
6533
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)`;
|
|
6534
7020
|
const seedLabel = seed.labelPlural ?? seed.label;
|
|
6535
7021
|
unionSelects.push(`
|
|
6536
7022
|
SELECT
|
|
@@ -6538,7 +7024,7 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6538
7024
|
? AS seed_label,
|
|
6539
7025
|
d.entry_id AS id,
|
|
6540
7026
|
d.updated_at AS updated_at,
|
|
6541
|
-
|
|
7027
|
+
${titleExpr} AS title
|
|
6542
7028
|
FROM ${draftTable} d
|
|
6543
7029
|
LEFT JOIN ${liveTable} l ON l.id = d.entry_id
|
|
6544
7030
|
`);
|
|
@@ -6585,9 +7071,48 @@ var D1ContentRepository = class extends BaseD1Repository {
|
|
|
6585
7071
|
throw this.mapError(error, "findPendingDrafts");
|
|
6586
7072
|
}
|
|
6587
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
|
+
}
|
|
6588
7113
|
};
|
|
6589
7114
|
|
|
6590
|
-
// src/shared/idempotency.repository.d1.ts
|
|
7115
|
+
// src/shared/db/repositories/idempotency.repository.d1.ts
|
|
6591
7116
|
var D1IdempotencyRepository = class extends BaseD1Repository {
|
|
6592
7117
|
/**
|
|
6593
7118
|
* Looks up an idempotency record by its key.
|
|
@@ -6638,7 +7163,7 @@ var D1IdempotencyRepository = class extends BaseD1Repository {
|
|
|
6638
7163
|
}
|
|
6639
7164
|
};
|
|
6640
7165
|
|
|
6641
|
-
// src/shared/media.repository.d1.ts
|
|
7166
|
+
// src/shared/db/repositories/media.repository.d1.ts
|
|
6642
7167
|
var D1MediaRepository = class {
|
|
6643
7168
|
constructor(database) {
|
|
6644
7169
|
this.database = database;
|
|
@@ -6694,7 +7219,7 @@ var D1MediaRepository = class {
|
|
|
6694
7219
|
}
|
|
6695
7220
|
};
|
|
6696
7221
|
|
|
6697
|
-
// src/shared/system-stats.repository.d1.ts
|
|
7222
|
+
// src/shared/db/repositories/system-stats.repository.d1.ts
|
|
6698
7223
|
var D1SystemStatsRepository = class {
|
|
6699
7224
|
constructor(database) {
|
|
6700
7225
|
this.database = database;
|
|
@@ -6733,7 +7258,7 @@ var D1SystemStatsRepository = class {
|
|
|
6733
7258
|
}
|
|
6734
7259
|
};
|
|
6735
7260
|
|
|
6736
|
-
// src/shared/d1-user.repository.ts
|
|
7261
|
+
// src/shared/db/repositories/d1-user.repository.ts
|
|
6737
7262
|
function rowToRecord(row) {
|
|
6738
7263
|
return {
|
|
6739
7264
|
id: row.id,
|
|
@@ -6800,7 +7325,7 @@ var D1UserRepository = class {
|
|
|
6800
7325
|
}
|
|
6801
7326
|
};
|
|
6802
7327
|
|
|
6803
|
-
// src/shared/d1-session.repository.ts
|
|
7328
|
+
// src/shared/db/repositories/d1-session.repository.ts
|
|
6804
7329
|
function rowToRecord2(row) {
|
|
6805
7330
|
return {
|
|
6806
7331
|
id: row.id,
|
|
@@ -6859,7 +7384,7 @@ var D1SessionRepository = class {
|
|
|
6859
7384
|
}
|
|
6860
7385
|
};
|
|
6861
7386
|
|
|
6862
|
-
// src/shared/d1-password-reset-token.repository.ts
|
|
7387
|
+
// src/shared/db/repositories/d1-password-reset-token.repository.ts
|
|
6863
7388
|
var D1PasswordResetTokenRepository = class {
|
|
6864
7389
|
constructor(db, idGenerator) {
|
|
6865
7390
|
this.db = db;
|
|
@@ -6889,7 +7414,7 @@ var D1PasswordResetTokenRepository = class {
|
|
|
6889
7414
|
}
|
|
6890
7415
|
};
|
|
6891
7416
|
|
|
6892
|
-
// src/shared/d1-activity-log.repository.ts
|
|
7417
|
+
// src/shared/db/repositories/d1-activity-log.repository.ts
|
|
6893
7418
|
var D1ActivityLogRepository = class {
|
|
6894
7419
|
constructor(database) {
|
|
6895
7420
|
this.database = database;
|
|
@@ -6952,7 +7477,7 @@ function parseDetails(raw) {
|
|
|
6952
7477
|
}
|
|
6953
7478
|
}
|
|
6954
7479
|
|
|
6955
|
-
// src/shared/d1-notification.repository.ts
|
|
7480
|
+
// src/shared/db/repositories/d1-notification.repository.ts
|
|
6956
7481
|
var D1NotificationRepository = class {
|
|
6957
7482
|
constructor(database, clock, idGenerator) {
|
|
6958
7483
|
this.database = database;
|
|
@@ -7016,7 +7541,7 @@ function mapRowToRecord2(row) {
|
|
|
7016
7541
|
};
|
|
7017
7542
|
}
|
|
7018
7543
|
|
|
7019
|
-
// src/shared/d1-widget.repository.ts
|
|
7544
|
+
// src/shared/db/repositories/d1-widget.repository.ts
|
|
7020
7545
|
import {
|
|
7021
7546
|
isDateRange
|
|
7022
7547
|
} from "@beechcms/core";
|
|
@@ -7090,9 +7615,10 @@ var D1WidgetRepository = class {
|
|
|
7090
7615
|
const bindings = [];
|
|
7091
7616
|
const trimmedSearch = options.search?.trim() ?? "";
|
|
7092
7617
|
if (trimmedSearch.length > 0) {
|
|
7618
|
+
const escapedSearch = trimmedSearch.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
|
|
7093
7619
|
const displayColumn = this.resolveColumnExpression(seed, seed.displayNameAlias);
|
|
7094
|
-
conditions.push(`${displayColumn} LIKE
|
|
7095
|
-
bindings.push(`%${
|
|
7620
|
+
conditions.push(`${displayColumn} LIKE ? ESCAPE '\\'`);
|
|
7621
|
+
bindings.push(`%${escapedSearch}%`);
|
|
7096
7622
|
}
|
|
7097
7623
|
for (const filter of options.filters ?? []) {
|
|
7098
7624
|
this.appendFilterCondition(seed, filter, conditions, bindings);
|
|
@@ -7293,7 +7819,7 @@ var D1WidgetRepository = class {
|
|
|
7293
7819
|
}
|
|
7294
7820
|
};
|
|
7295
7821
|
|
|
7296
|
-
// src/shared/d1-search.repository.ts
|
|
7822
|
+
// src/shared/db/repositories/d1-search.repository.ts
|
|
7297
7823
|
var EMPTY_QUERY_ERROR = "EMPTY_QUERY";
|
|
7298
7824
|
var D1SearchRepository = class {
|
|
7299
7825
|
constructor(database) {
|
|
@@ -7353,7 +7879,7 @@ function mapFtsRowToResultRow(row) {
|
|
|
7353
7879
|
};
|
|
7354
7880
|
}
|
|
7355
7881
|
|
|
7356
|
-
// src/shared/d1-analytics.repository.ts
|
|
7882
|
+
// src/shared/db/repositories/d1-analytics.repository.ts
|
|
7357
7883
|
var SECONDS_PER_DAY2 = 86400;
|
|
7358
7884
|
var D1AnalyticsRepository = class {
|
|
7359
7885
|
constructor(database, clock) {
|
|
@@ -7405,7 +7931,7 @@ var D1AnalyticsRepository = class {
|
|
|
7405
7931
|
}
|
|
7406
7932
|
};
|
|
7407
7933
|
|
|
7408
|
-
// src/shared/d1-content-scan.repository.ts
|
|
7934
|
+
// src/shared/db/repositories/d1-content-scan.repository.ts
|
|
7409
7935
|
var D1ContentScanRepository = class {
|
|
7410
7936
|
constructor(db) {
|
|
7411
7937
|
this.db = db;
|
|
@@ -7434,7 +7960,7 @@ var D1ContentScanRepository = class {
|
|
|
7434
7960
|
}
|
|
7435
7961
|
};
|
|
7436
7962
|
|
|
7437
|
-
// src/shared/site-settings.repository.d1.ts
|
|
7963
|
+
// src/shared/db/repositories/site-settings.repository.d1.ts
|
|
7438
7964
|
var DEFAULTS = {
|
|
7439
7965
|
siteTitle: "Beech CMS",
|
|
7440
7966
|
defaultLanguage: "en",
|
|
@@ -7474,7 +8000,7 @@ var D1SiteSettingsRepository = class {
|
|
|
7474
8000
|
}
|
|
7475
8001
|
};
|
|
7476
8002
|
|
|
7477
|
-
// src/shared/demo-data-sql.ts
|
|
8003
|
+
// src/shared/db/migrations/demo-data-sql.ts
|
|
7478
8004
|
var DEMO_DATA_SQL = `
|
|
7479
8005
|
INSERT OR IGNORE INTO content_clienti (id, slug, status, name, email, company, tier, account_status, mrr, created_at, updated_at)
|
|
7480
8006
|
VALUES
|
|
@@ -8453,7 +8979,7 @@ VALUES
|
|
|
8453
8979
|
|
|
8454
8980
|
`;
|
|
8455
8981
|
|
|
8456
|
-
// src/shared/demo-data.repository.d1.ts
|
|
8982
|
+
// src/shared/db/repositories/demo-data.repository.d1.ts
|
|
8457
8983
|
var D1DemoDataRepository = class {
|
|
8458
8984
|
constructor(db) {
|
|
8459
8985
|
this.db = db;
|
|
@@ -8465,7 +8991,7 @@ var D1DemoDataRepository = class {
|
|
|
8465
8991
|
}
|
|
8466
8992
|
};
|
|
8467
8993
|
|
|
8468
|
-
// src/shared/d1-setup-checklist.repository.ts
|
|
8994
|
+
// src/shared/db/repositories/d1-setup-checklist.repository.ts
|
|
8469
8995
|
var D1SetupChecklistRepository = class {
|
|
8470
8996
|
constructor(db) {
|
|
8471
8997
|
this.db = db;
|
|
@@ -8485,7 +9011,8 @@ var D1SetupChecklistRepository = class {
|
|
|
8485
9011
|
}
|
|
8486
9012
|
};
|
|
8487
9013
|
|
|
8488
|
-
// 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";
|
|
8489
9016
|
var D1SeedLayoutRepository = class {
|
|
8490
9017
|
constructor(db) {
|
|
8491
9018
|
this.db = db;
|
|
@@ -8527,9 +9054,25 @@ var D1SeedLayoutRepository = class {
|
|
|
8527
9054
|
async remove(slug) {
|
|
8528
9055
|
await this.db.prepare("DELETE FROM seed_layouts WHERE slug = ?").bind(slug).run();
|
|
8529
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
|
+
}
|
|
8530
9073
|
};
|
|
8531
9074
|
|
|
8532
|
-
// src/shared/dashboard-layout.repository.d1.ts
|
|
9075
|
+
// src/shared/db/repositories/dashboard-layout.repository.d1.ts
|
|
8533
9076
|
var D1DashboardLayoutRepository = class {
|
|
8534
9077
|
constructor(db) {
|
|
8535
9078
|
this.db = db;
|
|
@@ -8570,7 +9113,7 @@ var D1DashboardLayoutRepository = class {
|
|
|
8570
9113
|
}
|
|
8571
9114
|
};
|
|
8572
9115
|
|
|
8573
|
-
// src/shared/seed.repository.d1.ts
|
|
9116
|
+
// src/shared/db/repositories/seed.repository.d1.ts
|
|
8574
9117
|
var D1SeedRepository = class {
|
|
8575
9118
|
constructor(db) {
|
|
8576
9119
|
this.db = db;
|
|
@@ -8645,7 +9188,7 @@ var D1SeedRepository = class {
|
|
|
8645
9188
|
}
|
|
8646
9189
|
};
|
|
8647
9190
|
|
|
8648
|
-
// src/shared/schema-mutator.d1.ts
|
|
9191
|
+
// src/shared/db/migrations/schema-mutator.d1.ts
|
|
8649
9192
|
var D1SchemaMutator = class _D1SchemaMutator {
|
|
8650
9193
|
constructor(db) {
|
|
8651
9194
|
this.db = db;
|
|
@@ -8688,13 +9231,59 @@ var D1SchemaMutator = class _D1SchemaMutator {
|
|
|
8688
9231
|
if (statements.length === 0) return;
|
|
8689
9232
|
await this.db.batch(statements.map((s) => this.db.prepare(s)));
|
|
8690
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
|
+
}
|
|
8691
9280
|
};
|
|
8692
9281
|
|
|
8693
9282
|
// src/middleware/repository.middleware.ts
|
|
8694
9283
|
import { SystemClock as SystemClock2, SystemIdGenerator } from "@beechcms/core";
|
|
8695
9284
|
import { NoOpScheduler } from "@beechcms/core";
|
|
8696
9285
|
|
|
8697
|
-
// src/shared/automations.repository.d1.ts
|
|
9286
|
+
// src/shared/db/repositories/automations.repository.d1.ts
|
|
8698
9287
|
var D1AutomationRepository = class {
|
|
8699
9288
|
constructor(db) {
|
|
8700
9289
|
this.db = db;
|
|
@@ -8780,7 +9369,7 @@ function rowToAutomation(row) {
|
|
|
8780
9369
|
};
|
|
8781
9370
|
}
|
|
8782
9371
|
|
|
8783
|
-
// src/shared/execution-context-scheduler.ts
|
|
9372
|
+
// src/shared/services/scheduler/execution-context-scheduler.ts
|
|
8784
9373
|
var ExecutionContextScheduler = class {
|
|
8785
9374
|
constructor(ctx) {
|
|
8786
9375
|
this.ctx = ctx;
|
|
@@ -8839,6 +9428,7 @@ var repositoryMiddleware = (overrides) => {
|
|
|
8839
9428
|
context.set("seedRepository", overrides?.seedRepository ?? new D1SeedRepository(database));
|
|
8840
9429
|
context.set("schemaMutator", overrides?.schemaMutator ?? new D1SchemaMutator(database));
|
|
8841
9430
|
context.set("dashboardLayoutRepository", overrides?.dashboardLayoutRepository ?? new D1DashboardLayoutRepository(database));
|
|
9431
|
+
context.set("kanbanPositionRepository", overrides?.kanbanPositionRepository ?? new D1KanbanPositionRepository(database));
|
|
8842
9432
|
await next();
|
|
8843
9433
|
});
|
|
8844
9434
|
};
|
|
@@ -8846,7 +9436,7 @@ var repositoryMiddleware = (overrides) => {
|
|
|
8846
9436
|
// src/middleware/seed-registry.middleware.ts
|
|
8847
9437
|
import { createMiddleware as createMiddleware3 } from "hono/factory";
|
|
8848
9438
|
|
|
8849
|
-
// src/shared/seed-registry-cache.ts
|
|
9439
|
+
// src/shared/services/cache/seed-registry-cache.ts
|
|
8850
9440
|
import { SeedRegistry, buildBackrefMap } from "@beechcms/core";
|
|
8851
9441
|
var cache = null;
|
|
8852
9442
|
var TTL_MS = 5e3;
|
|
@@ -8906,30 +9496,12 @@ var S3Bucket = class {
|
|
|
8906
9496
|
const command = new PutObjectCommand({
|
|
8907
9497
|
Bucket: this.bucketName,
|
|
8908
9498
|
Key: key,
|
|
8909
|
-
Body: body instanceof ReadableStream ?
|
|
9499
|
+
Body: body instanceof ReadableStream ? body : new Uint8Array(body),
|
|
8910
9500
|
ContentType: options?.contentType,
|
|
8911
9501
|
Metadata: options?.metadata
|
|
8912
9502
|
});
|
|
8913
9503
|
await this.client.send(command);
|
|
8914
9504
|
}
|
|
8915
|
-
async streamToUint8Array(stream) {
|
|
8916
|
-
const reader = stream.getReader();
|
|
8917
|
-
const chunks = [];
|
|
8918
|
-
let totalLength = 0;
|
|
8919
|
-
while (true) {
|
|
8920
|
-
const { done, value } = await reader.read();
|
|
8921
|
-
if (done) break;
|
|
8922
|
-
chunks.push(value);
|
|
8923
|
-
totalLength += value.length;
|
|
8924
|
-
}
|
|
8925
|
-
const result = new Uint8Array(totalLength);
|
|
8926
|
-
let offset = 0;
|
|
8927
|
-
for (const chunk of chunks) {
|
|
8928
|
-
result.set(chunk, offset);
|
|
8929
|
-
offset += chunk.length;
|
|
8930
|
-
}
|
|
8931
|
-
return result;
|
|
8932
|
-
}
|
|
8933
9505
|
async get(key) {
|
|
8934
9506
|
try {
|
|
8935
9507
|
const command = new GetObjectCommand({
|
|
@@ -8978,10 +9550,11 @@ var S3Bucket = class {
|
|
|
8978
9550
|
}
|
|
8979
9551
|
}
|
|
8980
9552
|
getUrl(key) {
|
|
9553
|
+
const encodedKey = key.split("/").map(encodeURIComponent).join("/");
|
|
8981
9554
|
if (this.cdnUrl) {
|
|
8982
|
-
return `${this.cdnUrl}/${
|
|
9555
|
+
return `${this.cdnUrl}/${encodedKey}`;
|
|
8983
9556
|
}
|
|
8984
|
-
return `${this.baseUrl}/api/media/${
|
|
9557
|
+
return `${this.baseUrl}/api/media/${encodedKey}`;
|
|
8985
9558
|
}
|
|
8986
9559
|
async getTotalSize() {
|
|
8987
9560
|
let total = 0;
|
|
@@ -9003,7 +9576,8 @@ var S3Bucket = class {
|
|
|
9003
9576
|
const command = new PutObjectCommand({
|
|
9004
9577
|
Bucket: this.bucketName,
|
|
9005
9578
|
Key: key,
|
|
9006
|
-
ContentType: options.contentType
|
|
9579
|
+
ContentType: options.contentType,
|
|
9580
|
+
ContentLength: options.contentLength
|
|
9007
9581
|
});
|
|
9008
9582
|
return getSignedUrl(this.client, command, { expiresIn: options.expiresIn });
|
|
9009
9583
|
}
|
|
@@ -9033,9 +9607,21 @@ var S3Bucket = class {
|
|
|
9033
9607
|
};
|
|
9034
9608
|
|
|
9035
9609
|
// src/shared/storage/factory.ts
|
|
9610
|
+
import { HTTPException as HTTPException2 } from "hono/http-exception";
|
|
9036
9611
|
var NullBucket = class {
|
|
9037
9612
|
fail() {
|
|
9038
|
-
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
|
+
});
|
|
9039
9625
|
}
|
|
9040
9626
|
put() {
|
|
9041
9627
|
return this.fail();
|
|
@@ -9092,7 +9678,7 @@ var storageMiddleware = (overrides) => {
|
|
|
9092
9678
|
});
|
|
9093
9679
|
};
|
|
9094
9680
|
|
|
9095
|
-
// src/shared/in-memory-seed.repository.ts
|
|
9681
|
+
// src/shared/db/repositories/in-memory-seed.repository.ts
|
|
9096
9682
|
var InMemorySeedRepository = class {
|
|
9097
9683
|
seeds;
|
|
9098
9684
|
version = 1;
|
|
@@ -9138,7 +9724,7 @@ var InMemorySeedRepository = class {
|
|
|
9138
9724
|
import { createMiddleware as createMiddleware5 } from "hono/factory";
|
|
9139
9725
|
import { SystemClock as SystemClock3 } from "@beechcms/core";
|
|
9140
9726
|
|
|
9141
|
-
// src/auth/
|
|
9727
|
+
// src/auth/providers/hash.provider.ts
|
|
9142
9728
|
import bcrypt from "bcryptjs";
|
|
9143
9729
|
var BCRYPT_SALT_ROUNDS = 10;
|
|
9144
9730
|
var BcryptHashProvider = class {
|
|
@@ -9154,7 +9740,7 @@ var BcryptHashProvider = class {
|
|
|
9154
9740
|
}
|
|
9155
9741
|
};
|
|
9156
9742
|
|
|
9157
|
-
// src/auth/
|
|
9743
|
+
// src/auth/providers/jwt-token.service.ts
|
|
9158
9744
|
import { SignJWT, jwtVerify } from "jose";
|
|
9159
9745
|
var DEFAULT_TOKEN_TTL_SECONDS = 900;
|
|
9160
9746
|
var DEFAULT_ALGORITHM = "HS256";
|
|
@@ -9186,8 +9772,12 @@ var JoseTokenService = class {
|
|
|
9186
9772
|
const { payload } = await jwtVerify(token, this.secretBytes, {
|
|
9187
9773
|
algorithms: [algorithm],
|
|
9188
9774
|
issuer: this.config.issuer,
|
|
9189
|
-
audience: this.config.audience
|
|
9775
|
+
audience: this.config.audience,
|
|
9776
|
+
currentDate: new Date(this.clock.nowSeconds() * 1e3)
|
|
9190
9777
|
});
|
|
9778
|
+
if (typeof payload.sub !== "string" || payload.sub === "") {
|
|
9779
|
+
return null;
|
|
9780
|
+
}
|
|
9191
9781
|
return payload;
|
|
9192
9782
|
} catch {
|
|
9193
9783
|
return null;
|
|
@@ -9225,13 +9815,23 @@ import { createMiddleware as createMiddleware6 } from "hono/factory";
|
|
|
9225
9815
|
|
|
9226
9816
|
// src/rate-limit/cloudflare-rate-limiter.ts
|
|
9227
9817
|
var CloudflareRateLimiter = class {
|
|
9228
|
-
constructor(binding) {
|
|
9818
|
+
constructor(binding, options = {}) {
|
|
9229
9819
|
this.binding = binding;
|
|
9820
|
+
this.options = options;
|
|
9230
9821
|
}
|
|
9231
9822
|
binding;
|
|
9823
|
+
options;
|
|
9232
9824
|
async checkLimit(key) {
|
|
9233
|
-
|
|
9234
|
-
|
|
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
|
+
}
|
|
9235
9835
|
}
|
|
9236
9836
|
};
|
|
9237
9837
|
|
|
@@ -9246,13 +9846,20 @@ var NoOpRateLimiter = class {
|
|
|
9246
9846
|
var NO_OP = new NoOpRateLimiter();
|
|
9247
9847
|
function buildDefaultRegistry(env) {
|
|
9248
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
|
+
};
|
|
9249
9856
|
const limiters = {
|
|
9250
|
-
login:
|
|
9251
|
-
tokenRefresh:
|
|
9252
|
-
forgotPassword:
|
|
9253
|
-
resetPassword:
|
|
9254
|
-
publicApiRead:
|
|
9255
|
-
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 })
|
|
9256
9863
|
};
|
|
9257
9864
|
return { getLimiter: (name) => limiters[name] };
|
|
9258
9865
|
}
|
|
@@ -9268,7 +9875,7 @@ var rateLimiterMiddleware = (overrides) => {
|
|
|
9268
9875
|
import { createMiddleware as createMiddleware7 } from "hono/factory";
|
|
9269
9876
|
import { SystemClock as SystemClock4, SystemIdGenerator as SystemIdGenerator2 } from "@beechcms/core";
|
|
9270
9877
|
|
|
9271
|
-
// src/shared/d1-activity-logger.ts
|
|
9878
|
+
// src/shared/services/activity-log/d1-activity-logger.ts
|
|
9272
9879
|
var ACTIVITY_LOG_INSERT_SQL = `INSERT INTO activity_logs
|
|
9273
9880
|
(id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
|
|
9274
9881
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
|
@@ -9313,7 +9920,7 @@ var D1ActivityLogger = class {
|
|
|
9313
9920
|
}
|
|
9314
9921
|
};
|
|
9315
9922
|
|
|
9316
|
-
// src/shared/background-notification-service.ts
|
|
9923
|
+
// src/shared/services/notification/background-notification-service.ts
|
|
9317
9924
|
var DEFAULT_NOTIFICATION_TYPE = "info";
|
|
9318
9925
|
var BackgroundNotificationService = class {
|
|
9319
9926
|
constructor(notificationRepository, scheduleBackgroundTask) {
|
|
@@ -9343,7 +9950,7 @@ var BackgroundNotificationService = class {
|
|
|
9343
9950
|
}
|
|
9344
9951
|
};
|
|
9345
9952
|
|
|
9346
|
-
// src/shared/qstash-notification-service.ts
|
|
9953
|
+
// src/shared/services/notification/qstash-notification-service.ts
|
|
9347
9954
|
import { Client } from "@upstash/qstash";
|
|
9348
9955
|
var QStashNotificationService = class {
|
|
9349
9956
|
constructor(token, appUrl, scheduleBackgroundTask, qstashUrl) {
|
|
@@ -9412,6 +10019,82 @@ var observabilityMiddleware = (overrides) => {
|
|
|
9412
10019
|
});
|
|
9413
10020
|
};
|
|
9414
10021
|
|
|
10022
|
+
// src/middleware/queue.middleware.ts
|
|
10023
|
+
import { createMiddleware as createMiddleware8 } from "hono/factory";
|
|
10024
|
+
import { SystemClock as SystemClock5, SystemIdGenerator as SystemIdGenerator3 } from "@beechcms/core";
|
|
10025
|
+
|
|
10026
|
+
// src/shared/services/queue/cloudflare-queue-service.ts
|
|
10027
|
+
var CloudflareQueueService = class {
|
|
10028
|
+
constructor(queue) {
|
|
10029
|
+
this.queue = queue;
|
|
10030
|
+
}
|
|
10031
|
+
queue;
|
|
10032
|
+
async enqueue(name, payload) {
|
|
10033
|
+
try {
|
|
10034
|
+
await this.queue.send({ name, payload });
|
|
10035
|
+
} catch (error) {
|
|
10036
|
+
console.error(`CloudflareQueueService: failed to enqueue "${name}"`, error);
|
|
10037
|
+
}
|
|
10038
|
+
}
|
|
10039
|
+
};
|
|
10040
|
+
|
|
10041
|
+
// src/shared/services/queue/in-memory-queue-service.ts
|
|
10042
|
+
var InMemoryQueueService = class {
|
|
10043
|
+
constructor(jobs, context, scheduleBackgroundTask) {
|
|
10044
|
+
this.jobs = jobs;
|
|
10045
|
+
this.context = context;
|
|
10046
|
+
this.scheduleBackgroundTask = scheduleBackgroundTask;
|
|
10047
|
+
}
|
|
10048
|
+
jobs;
|
|
10049
|
+
context;
|
|
10050
|
+
scheduleBackgroundTask;
|
|
10051
|
+
async enqueue(name, payload) {
|
|
10052
|
+
const handler = this.jobs[name];
|
|
10053
|
+
if (!handler) {
|
|
10054
|
+
console.error(`InMemoryQueueService: no handler registered for "${name}"`);
|
|
10055
|
+
return;
|
|
10056
|
+
}
|
|
10057
|
+
const run = handler(payload, this.context).catch((error) => {
|
|
10058
|
+
console.error(`InMemoryQueueService: job "${name}" failed`, error);
|
|
10059
|
+
});
|
|
10060
|
+
if (this.scheduleBackgroundTask) {
|
|
10061
|
+
this.scheduleBackgroundTask(run);
|
|
10062
|
+
return;
|
|
10063
|
+
}
|
|
10064
|
+
await run;
|
|
10065
|
+
}
|
|
10066
|
+
};
|
|
10067
|
+
|
|
10068
|
+
// src/middleware/queue.middleware.ts
|
|
10069
|
+
var queueMiddleware = (jobs = {}, overrides) => {
|
|
10070
|
+
return createMiddleware8(async (context, next) => {
|
|
10071
|
+
let scheduleBackgroundTask;
|
|
10072
|
+
try {
|
|
10073
|
+
const executionContext = context.executionCtx;
|
|
10074
|
+
scheduleBackgroundTask = executionContext.waitUntil.bind(executionContext);
|
|
10075
|
+
} catch {
|
|
10076
|
+
scheduleBackgroundTask = void 0;
|
|
10077
|
+
}
|
|
10078
|
+
let queue;
|
|
10079
|
+
if (overrides?.queue) {
|
|
10080
|
+
queue = overrides.queue;
|
|
10081
|
+
} else if (context.env.QUEUE) {
|
|
10082
|
+
queue = new CloudflareQueueService(context.env.QUEUE);
|
|
10083
|
+
} else {
|
|
10084
|
+
const jobContext = {
|
|
10085
|
+
repository: context.get("repository"),
|
|
10086
|
+
bucket: context.get("bucket"),
|
|
10087
|
+
clock: SystemClock5,
|
|
10088
|
+
idGenerator: SystemIdGenerator3,
|
|
10089
|
+
env: context.env
|
|
10090
|
+
};
|
|
10091
|
+
queue = new InMemoryQueueService(jobs, jobContext, scheduleBackgroundTask);
|
|
10092
|
+
}
|
|
10093
|
+
context.set("queue", queue);
|
|
10094
|
+
await next();
|
|
10095
|
+
});
|
|
10096
|
+
};
|
|
10097
|
+
|
|
9415
10098
|
// src/factory.ts
|
|
9416
10099
|
var REFRESH_TOKEN_EXPIRY_DAYS = 7;
|
|
9417
10100
|
var SECONDS_PER_DAY3 = 24 * 60 * 60;
|
|
@@ -9461,6 +10144,7 @@ function createBeechApp(config) {
|
|
|
9461
10144
|
app.use("*", storageMiddleware({
|
|
9462
10145
|
bucket: config.bucket
|
|
9463
10146
|
}));
|
|
10147
|
+
app.use("*", queueMiddleware(config.jobs ?? {}));
|
|
9464
10148
|
app.use("*", authProvidersMiddleware());
|
|
9465
10149
|
app.use("*", rateLimiterMiddleware());
|
|
9466
10150
|
app.use("*", observabilityMiddleware());
|
|
@@ -9481,7 +10165,7 @@ function createBeechApp(config) {
|
|
|
9481
10165
|
try {
|
|
9482
10166
|
const originUrl = new URL(origin);
|
|
9483
10167
|
const requestUrl = new URL(context.req.url);
|
|
9484
|
-
if (originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
|
|
10168
|
+
if (originUrl.protocol === requestUrl.protocol && originUrl.hostname === requestUrl.hostname && originUrl.port === requestUrl.port) {
|
|
9485
10169
|
return origin;
|
|
9486
10170
|
}
|
|
9487
10171
|
} catch {
|
|
@@ -9535,7 +10219,13 @@ function createBeechApp(config) {
|
|
|
9535
10219
|
if (!validateLoginInput(email, password)) return context.json({ error: AUTH_ERRORS.INVALID_REQUEST }, 400);
|
|
9536
10220
|
const clientIp = getClientIp(context.req);
|
|
9537
10221
|
const loginRateLimit = await context.get("rateLimiters").getLimiter("login").checkLimit(clientIp);
|
|
9538
|
-
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
|
+
}
|
|
9539
10229
|
const user = await context.get("userRepository").findByEmail(email);
|
|
9540
10230
|
const hashToCompare = user?.passwordHash ?? DUMMY_PASSWORD_HASH;
|
|
9541
10231
|
const isValid = await verifyPassword(password, hashToCompare, context.get("hashProvider"));
|
|
@@ -9543,9 +10233,9 @@ function createBeechApp(config) {
|
|
|
9543
10233
|
const accessToken = await context.get("tokenService").issue({ sub: user.id, email: user.email, name: user.name ?? void 0, surname: user.surname ?? void 0, role: user.role });
|
|
9544
10234
|
const refreshToken = generateRefreshToken();
|
|
9545
10235
|
const refreshTokenHash = await sha256hex7(refreshToken);
|
|
9546
|
-
const nowSeconds =
|
|
10236
|
+
const nowSeconds = SystemClock6.nowSeconds();
|
|
9547
10237
|
await context.get("sessionRepository").saveRefreshToken({
|
|
9548
|
-
id:
|
|
10238
|
+
id: SystemIdGenerator4.uuid(),
|
|
9549
10239
|
userId: user.id,
|
|
9550
10240
|
tokenHash: refreshTokenHash,
|
|
9551
10241
|
expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY3
|
|
@@ -9560,10 +10250,16 @@ function createBeechApp(config) {
|
|
|
9560
10250
|
try {
|
|
9561
10251
|
const refreshClientIp = getClientIp(context.req);
|
|
9562
10252
|
const refreshRateLimit = await context.get("rateLimiters").getLimiter("tokenRefresh").checkLimit(refreshClientIp);
|
|
9563
|
-
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
|
+
}
|
|
9564
10260
|
const refreshToken = getCookie(context, "refresh_token");
|
|
9565
10261
|
if (!refreshToken) return context.json({ error: "Refresh token missing" }, 401);
|
|
9566
|
-
const nowSeconds =
|
|
10262
|
+
const nowSeconds = SystemClock6.nowSeconds();
|
|
9567
10263
|
const tokenHash = await sha256hex7(refreshToken);
|
|
9568
10264
|
const activeSession = await context.get("sessionRepository").findActiveByHash(tokenHash, nowSeconds);
|
|
9569
10265
|
if (!activeSession) return context.json({ error: "Invalid refresh token" }, 401);
|
|
@@ -9576,15 +10272,24 @@ function createBeechApp(config) {
|
|
|
9576
10272
|
const newRefreshToken = generateRefreshToken();
|
|
9577
10273
|
const newRefreshTokenHash = await sha256hex7(newRefreshToken);
|
|
9578
10274
|
await context.get("sessionRepository").saveRefreshToken({
|
|
9579
|
-
id:
|
|
10275
|
+
id: SystemIdGenerator4.uuid(),
|
|
9580
10276
|
userId: user.id,
|
|
9581
10277
|
tokenHash: newRefreshTokenHash,
|
|
9582
10278
|
expiresAt: nowSeconds + REFRESH_TOKEN_EXPIRY_DAYS * SECONDS_PER_DAY3
|
|
9583
10279
|
});
|
|
9584
|
-
|
|
9585
|
-
|
|
9586
|
-
|
|
9587
|
-
|
|
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
|
+
}
|
|
9588
10293
|
} catch (error) {
|
|
9589
10294
|
return handleAuthError(context, error, "Refresh");
|
|
9590
10295
|
}
|
|
@@ -9593,7 +10298,7 @@ function createBeechApp(config) {
|
|
|
9593
10298
|
try {
|
|
9594
10299
|
const refreshToken = getCookie(context, "refresh_token");
|
|
9595
10300
|
if (refreshToken) {
|
|
9596
|
-
const nowSeconds =
|
|
10301
|
+
const nowSeconds = SystemClock6.nowSeconds();
|
|
9597
10302
|
const tokenHash = await sha256hex7(refreshToken);
|
|
9598
10303
|
await context.get("sessionRepository").revokeByHash(tokenHash, nowSeconds);
|
|
9599
10304
|
}
|
|
@@ -9626,7 +10331,7 @@ function createBeechApp(config) {
|
|
|
9626
10331
|
apiPublic.route("/", publicRoutes);
|
|
9627
10332
|
app.route("/api/v1/public", apiPublic);
|
|
9628
10333
|
app.route("/api/webhooks", webhooksApp);
|
|
9629
|
-
app.get("/api/media/:key", (context) => serveMediaHandler(context));
|
|
10334
|
+
app.get("/api/media/:key{.+}", (context) => serveMediaHandler(context));
|
|
9630
10335
|
if (config.customRoutes) {
|
|
9631
10336
|
const publicRouter = new Hono19();
|
|
9632
10337
|
const protectedRouter = new Hono19();
|