@orion-studios/cms 0.5.4 → 0.5.6
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/dist/{chunk-VPUODCNH.js → chunk-CFZP7674.js} +29 -8
- package/dist/{chunk-WQDHEQDE.js → chunk-ULE565KD.js} +27 -1
- package/dist/forms/index.d.ts +1 -1
- package/dist/forms/index.js +5 -1
- package/dist/forms/react.d.ts +1 -1
- package/dist/forms/react.js +1 -1
- package/dist/server/index.d.ts +26 -4
- package/dist/server/index.js +340 -103
- package/dist/studio/index.d.ts +1 -2
- package/dist/studio/index.js +42 -8
- package/dist/{submission-BKdBedOe.d.ts → submission-CGz0lElf.d.ts} +11 -2
- package/dist/{submission-CzrfXu17.d.ts → submission-CKZgx1h7.d.ts} +2 -0
- package/package.json +2 -1
- package/sql/bootstrap.sql +61 -9
- package/sql/migrations/20260827164127_sec_014_form_notify_privacy.sql +41 -0
- package/sql/migrations/20260827172120_sec_015_analytics_ingest_limits.sql +101 -0
- package/sql/migrations/20260827232146_sec_016_media_privacy.sql +21 -0
package/dist/server/index.js
CHANGED
|
@@ -3,12 +3,14 @@ import {
|
|
|
3
3
|
} from "../chunk-AYV6KYDP.js";
|
|
4
4
|
import {
|
|
5
5
|
createMemoryRateLimitStore,
|
|
6
|
+
getAutoReplyEmailFields,
|
|
6
7
|
isOriginAllowed,
|
|
7
|
-
processSubmission
|
|
8
|
-
|
|
8
|
+
processSubmission,
|
|
9
|
+
resolveAutoReplyEmailField
|
|
10
|
+
} from "../chunk-CFZP7674.js";
|
|
9
11
|
|
|
10
12
|
// src/server/routes.ts
|
|
11
|
-
import { createHash as createHash2 } from "crypto";
|
|
13
|
+
import { createHash as createHash2, createHmac as createHmac3, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
12
14
|
|
|
13
15
|
// src/analytics/aggregate.ts
|
|
14
16
|
var CONVERSION_NAMES = /* @__PURE__ */ new Set(["call", "email"]);
|
|
@@ -38,7 +40,7 @@ function normalizeSource(utmSource, referrer) {
|
|
|
38
40
|
if (host.includes("twitter.") || host === "t.co" || host.includes("x.com")) return "X (Twitter)";
|
|
39
41
|
return host;
|
|
40
42
|
}
|
|
41
|
-
var isConversion = (event) => event.type === "click" && CONVERSION_NAMES.has(event.name) || event.type === "form" && event.name.endsWith(":submit");
|
|
43
|
+
var isConversion = (event) => event.server_verified === true && (event.type === "click" && CONVERSION_NAMES.has(event.name) || event.type === "form" && event.name.endsWith(":submit"));
|
|
42
44
|
var top = (map, limit, by) => [...map.entries()].sort((a, b) => by(b[1]) - by(a[1])).slice(0, limit);
|
|
43
45
|
function computeKpis(events) {
|
|
44
46
|
const sessions = /* @__PURE__ */ new Set();
|
|
@@ -63,10 +65,12 @@ function computeKpis(events) {
|
|
|
63
65
|
if (event.type === "click" && event.name === "call") calls += 1;
|
|
64
66
|
if (event.type === "click" && event.name === "email") emails += 1;
|
|
65
67
|
if (event.type === "click" && event.name === "portal") portalClicks += 1;
|
|
66
|
-
if (event.type === "form" && event.name.endsWith(":submit"))
|
|
68
|
+
if (event.server_verified === true && event.type === "form" && event.name.endsWith(":submit")) {
|
|
69
|
+
formSubmits += 1;
|
|
70
|
+
}
|
|
67
71
|
if (isConversion(event) && event.session_key) converting.add(event.session_key);
|
|
68
72
|
}
|
|
69
|
-
const conversions =
|
|
73
|
+
const conversions = events.filter(isConversion).length;
|
|
70
74
|
const returningVisitors = [...visitorSessions.values()].filter((keys) => keys.size > 1).length;
|
|
71
75
|
return {
|
|
72
76
|
visitors: visitors.size,
|
|
@@ -173,7 +177,7 @@ function aggregateAnalytics(events, previousEvents, range) {
|
|
|
173
177
|
const entry = forms.get(slug) || { views: 0, starts: 0, submits: 0 };
|
|
174
178
|
if (stage === "view") entry.views += 1;
|
|
175
179
|
if (stage === "start") entry.starts += 1;
|
|
176
|
-
if (stage === "submit") entry.submits += 1;
|
|
180
|
+
if (stage === "submit" && event.server_verified === true) entry.submits += 1;
|
|
177
181
|
forms.set(slug, entry);
|
|
178
182
|
}
|
|
179
183
|
const notFound = /* @__PURE__ */ new Map();
|
|
@@ -338,12 +342,16 @@ ${rendered}
|
|
|
338
342
|
}
|
|
339
343
|
return lines.join("\n");
|
|
340
344
|
}
|
|
345
|
+
function resolveSubmitterEmail(config, data) {
|
|
346
|
+
const fieldName = resolveAutoReplyEmailField(config);
|
|
347
|
+
if (!fieldName) return void 0;
|
|
348
|
+
const value = data[fieldName];
|
|
349
|
+
return isEmail(value) ? value.trim().toLowerCase() : void 0;
|
|
350
|
+
}
|
|
341
351
|
async function notifySubmission(args) {
|
|
342
352
|
const notify = args.config.notify || {};
|
|
343
353
|
const recipients = (notify.emails || []).filter(isEmail);
|
|
344
|
-
const submitterEmail =
|
|
345
|
-
([key, value]) => key.toLowerCase().includes("email") && isEmail(value)
|
|
346
|
-
)?.[1];
|
|
354
|
+
const submitterEmail = resolveSubmitterEmail(args.config, args.data);
|
|
347
355
|
const subject = (notify.subject || "New {form} submission").replace(
|
|
348
356
|
/\{form\}/g,
|
|
349
357
|
args.formTitle || "form"
|
|
@@ -360,7 +368,7 @@ async function notifySubmission(args) {
|
|
|
360
368
|
console.error("[orion-cms] submission notification failed:", error);
|
|
361
369
|
}
|
|
362
370
|
}
|
|
363
|
-
if (notify.autoReply && submitterEmail) {
|
|
371
|
+
if (notify.autoReply === true && submitterEmail && args.autoReplyAllowed === true) {
|
|
364
372
|
try {
|
|
365
373
|
await args.sendEmail({
|
|
366
374
|
to: [submitterEmail],
|
|
@@ -429,12 +437,15 @@ var encode = (value) => Buffer.from(value, "utf8").toString("base64url");
|
|
|
429
437
|
var decode = (value) => Buffer.from(value, "base64url").toString("utf8");
|
|
430
438
|
var sign = (payload, secret) => createHmac2("sha256", secret).update(payload).digest("base64url");
|
|
431
439
|
var PREVIEW_TOKEN_TTL_MS = 60 * 60 * 1e3;
|
|
440
|
+
var PREVIEW_SESSION_COOKIE = "orion_preview_session";
|
|
432
441
|
function createPreviewToken(pageId, secret, ttlMs = PREVIEW_TOKEN_TTL_MS) {
|
|
433
442
|
const payload = encode(`${pageId}|${Date.now() + ttlMs}`);
|
|
434
443
|
return `${payload}.${sign(payload, secret)}`;
|
|
435
444
|
}
|
|
436
445
|
function verifyPreviewToken(token, secret) {
|
|
437
|
-
const
|
|
446
|
+
const segments = token.split(".");
|
|
447
|
+
if (segments.length !== 2) return null;
|
|
448
|
+
const [payload, signature] = segments;
|
|
438
449
|
if (!payload || !signature) return null;
|
|
439
450
|
const expected = sign(payload, secret);
|
|
440
451
|
const expectedBuffer = Buffer.from(expected);
|
|
@@ -506,6 +517,11 @@ async function resolveUser(request, client = getServiceClient()) {
|
|
|
506
517
|
|
|
507
518
|
// src/server/sync.ts
|
|
508
519
|
var isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
520
|
+
var publicFormConfig = (value) => {
|
|
521
|
+
const config = isRecord(value) ? { ...value } : {};
|
|
522
|
+
delete config.notify;
|
|
523
|
+
return config;
|
|
524
|
+
};
|
|
509
525
|
var mimeByExtension = {
|
|
510
526
|
avif: "image/avif",
|
|
511
527
|
gif: "image/gif",
|
|
@@ -629,7 +645,7 @@ async function runContentSync(client, registry, input) {
|
|
|
629
645
|
{
|
|
630
646
|
slug: form.slug,
|
|
631
647
|
title: typeof form.title === "string" ? form.title : form.slug,
|
|
632
|
-
config:
|
|
648
|
+
config: publicFormConfig(form.config),
|
|
633
649
|
success_message: typeof form.successMessage === "string" ? form.successMessage : "",
|
|
634
650
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
635
651
|
},
|
|
@@ -642,7 +658,6 @@ async function runContentSync(client, registry, input) {
|
|
|
642
658
|
}
|
|
643
659
|
|
|
644
660
|
// src/server/routes.ts
|
|
645
|
-
import { timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
646
661
|
var tokenEquals = (candidate, secret) => {
|
|
647
662
|
if (!candidate || !secret) return false;
|
|
648
663
|
const a = Buffer.from(candidate);
|
|
@@ -666,15 +681,29 @@ var PATH_PATTERN = /^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/;
|
|
|
666
681
|
var USER_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
667
682
|
var canonicalizeUserId = (value) => USER_ID_PATTERN.test(value) ? value.toLowerCase() : null;
|
|
668
683
|
var DEFAULT_MAX_UPLOAD_BYTES = 15 * 1024 * 1024;
|
|
669
|
-
var
|
|
670
|
-
"image/jpeg"
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
"image/
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
684
|
+
var UPLOAD_RULES = {
|
|
685
|
+
"image/jpeg": {
|
|
686
|
+
extensions: ["jpg", "jpeg"],
|
|
687
|
+
decodedFormat: "jpeg"
|
|
688
|
+
},
|
|
689
|
+
"image/png": {
|
|
690
|
+
extensions: ["png"],
|
|
691
|
+
decodedFormat: "png"
|
|
692
|
+
},
|
|
693
|
+
"image/webp": {
|
|
694
|
+
extensions: ["webp"],
|
|
695
|
+
decodedFormat: "webp"
|
|
696
|
+
},
|
|
697
|
+
"image/gif": {
|
|
698
|
+
extensions: ["gif"],
|
|
699
|
+
decodedFormat: "gif"
|
|
700
|
+
},
|
|
701
|
+
"image/avif": {
|
|
702
|
+
extensions: ["avif"],
|
|
703
|
+
decodedFormat: "heif"
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
var uploadExtension = (filename) => filename.split(".").pop()?.toLowerCase() || "";
|
|
678
707
|
async function readJson(request) {
|
|
679
708
|
try {
|
|
680
709
|
const parsed = await request.json();
|
|
@@ -735,22 +764,36 @@ async function readJsonLimited(request, limits) {
|
|
|
735
764
|
return parsed;
|
|
736
765
|
}
|
|
737
766
|
var hashedClientKey = (ip) => createHash2("sha256").update(ip).digest("hex").slice(0, 24);
|
|
767
|
+
var requestPagePath = (request) => {
|
|
768
|
+
const referrer = request.headers.get("referer");
|
|
769
|
+
if (!referrer) return "";
|
|
770
|
+
try {
|
|
771
|
+
const page = new URL(referrer);
|
|
772
|
+
if (page.origin !== new URL(request.url).origin) return "";
|
|
773
|
+
return PATH_PATTERN.test(page.pathname) ? page.pathname : "";
|
|
774
|
+
} catch {
|
|
775
|
+
return "";
|
|
776
|
+
}
|
|
777
|
+
};
|
|
738
778
|
function createDurableRateLimitStore(getClient, options) {
|
|
739
779
|
const max = options?.max ?? 5;
|
|
740
780
|
const windowSeconds = Math.max(1, Math.round((options?.windowMs ?? 6e4) / 1e3));
|
|
741
781
|
const bucket = options?.bucket ?? "default";
|
|
782
|
+
const failClosed = options?.failClosed === true;
|
|
742
783
|
return {
|
|
743
|
-
async isLimited(key) {
|
|
784
|
+
async isLimited(key, _now, cost = 1) {
|
|
744
785
|
try {
|
|
745
|
-
const
|
|
786
|
+
const args = {
|
|
746
787
|
p_key: `${bucket}:${key}`,
|
|
747
788
|
p_max: max,
|
|
748
789
|
p_window_seconds: windowSeconds
|
|
749
|
-
}
|
|
750
|
-
if (
|
|
751
|
-
|
|
790
|
+
};
|
|
791
|
+
if (cost !== 1) args.p_cost = cost;
|
|
792
|
+
const { data, error } = await getClient().rpc("cms_rate_limit_consume", args);
|
|
793
|
+
if (error) return failClosed;
|
|
794
|
+
return failClosed ? data !== true : data === false;
|
|
752
795
|
} catch {
|
|
753
|
-
return
|
|
796
|
+
return failClosed;
|
|
754
797
|
}
|
|
755
798
|
}
|
|
756
799
|
};
|
|
@@ -766,7 +809,86 @@ function createCmsRoutes(options) {
|
|
|
766
809
|
if (hostedMemoryMode) return "";
|
|
767
810
|
return options.previewSecret || process.env.SUPABASE_SERVICE_ROLE_KEY || (options.memoryMode ? "orion-memory-preview-secret" : "");
|
|
768
811
|
};
|
|
769
|
-
const
|
|
812
|
+
const analyticsLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
|
|
813
|
+
const autoReplyLimit = (max, windowMs, bucket) => options.memoryMode ? createMemoryRateLimitStore({ max, windowMs }) : createDurableRateLimitStore(db, { max, windowMs, bucket, failClosed: true });
|
|
814
|
+
const boundedAutoReplyMax = (name, fallback) => {
|
|
815
|
+
const candidate = options.autoReplyLimits?.[name];
|
|
816
|
+
return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate > 0 ? Math.min(candidate, fallback) : fallback;
|
|
817
|
+
};
|
|
818
|
+
const autoReplyLimits = {
|
|
819
|
+
recipientPerHour: autoReplyLimit(
|
|
820
|
+
boundedAutoReplyMax("recipientPerHour", 3),
|
|
821
|
+
36e5,
|
|
822
|
+
"auto-reply-recipient-hour"
|
|
823
|
+
),
|
|
824
|
+
sitePerMinute: autoReplyLimit(
|
|
825
|
+
boundedAutoReplyMax("sitePerMinute", 10),
|
|
826
|
+
6e4,
|
|
827
|
+
"auto-reply-site-minute"
|
|
828
|
+
),
|
|
829
|
+
sitePerDay: autoReplyLimit(
|
|
830
|
+
boundedAutoReplyMax("sitePerDay", 200),
|
|
831
|
+
864e5,
|
|
832
|
+
"auto-reply-site-day"
|
|
833
|
+
)
|
|
834
|
+
};
|
|
835
|
+
const autoReplyRecipientKey = (email) => createHmac3(
|
|
836
|
+
"sha256",
|
|
837
|
+
previewSecret() || syncToken || "orion-memory-auto-reply-secret"
|
|
838
|
+
).update(`recipient:${email}`).digest("hex").slice(0, 24);
|
|
839
|
+
const maySendAutoReply = async (config, data) => {
|
|
840
|
+
if (config.notify?.autoReply !== true) return false;
|
|
841
|
+
const recipient = resolveSubmitterEmail(config, data);
|
|
842
|
+
if (!recipient) return false;
|
|
843
|
+
const now = Date.now();
|
|
844
|
+
if (await autoReplyLimits.recipientPerHour.isLimited(autoReplyRecipientKey(recipient), now)) {
|
|
845
|
+
return false;
|
|
846
|
+
}
|
|
847
|
+
if (await autoReplyLimits.sitePerMinute.isLimited("all", now)) return false;
|
|
848
|
+
if (await autoReplyLimits.sitePerDay.isLimited("all", now)) return false;
|
|
849
|
+
return true;
|
|
850
|
+
};
|
|
851
|
+
const boundedAnalyticsMax = (name, fallback) => {
|
|
852
|
+
const candidate = options.analyticsLimits?.[name];
|
|
853
|
+
return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate > 0 ? Math.min(candidate, fallback) : fallback;
|
|
854
|
+
};
|
|
855
|
+
const analyticsLimits = {
|
|
856
|
+
siteRequestsPerMinute: analyticsLimit(
|
|
857
|
+
boundedAnalyticsMax("siteRequestsPerMinute", 300),
|
|
858
|
+
6e4,
|
|
859
|
+
"events-site-request-minute"
|
|
860
|
+
),
|
|
861
|
+
siteRequestsPerDay: analyticsLimit(
|
|
862
|
+
boundedAnalyticsMax("siteRequestsPerDay", 2e4),
|
|
863
|
+
864e5,
|
|
864
|
+
"events-site-request-day"
|
|
865
|
+
),
|
|
866
|
+
requestsPerMinute: analyticsLimit(
|
|
867
|
+
boundedAnalyticsMax("requestsPerMinute", 60),
|
|
868
|
+
6e4,
|
|
869
|
+
"events-request-minute"
|
|
870
|
+
),
|
|
871
|
+
clientEventsPerMinute: analyticsLimit(
|
|
872
|
+
boundedAnalyticsMax("clientEventsPerMinute", 120),
|
|
873
|
+
6e4,
|
|
874
|
+
"events-client-minute"
|
|
875
|
+
),
|
|
876
|
+
clientEventsPerDay: analyticsLimit(
|
|
877
|
+
boundedAnalyticsMax("clientEventsPerDay", 1e3),
|
|
878
|
+
864e5,
|
|
879
|
+
"events-client-day"
|
|
880
|
+
),
|
|
881
|
+
siteEventsPerMinute: analyticsLimit(
|
|
882
|
+
boundedAnalyticsMax("siteEventsPerMinute", 2e3),
|
|
883
|
+
6e4,
|
|
884
|
+
"events-site-minute"
|
|
885
|
+
),
|
|
886
|
+
siteEventsPerDay: analyticsLimit(
|
|
887
|
+
boundedAnalyticsMax("siteEventsPerDay", 5e4),
|
|
888
|
+
864e5,
|
|
889
|
+
"events-site-day"
|
|
890
|
+
)
|
|
891
|
+
};
|
|
770
892
|
const analyticsRetentionDays = options.analyticsRetentionDays ?? 90;
|
|
771
893
|
const requestSessionKey = (request) => {
|
|
772
894
|
const secret = previewSecret() || "orion-analytics";
|
|
@@ -1050,27 +1172,16 @@ function createCmsRoutes(options) {
|
|
|
1050
1172
|
const { data } = await db().from("cms_pages").select("id, path").eq("id", id).maybeSingle();
|
|
1051
1173
|
if (!data) return errors.notFound();
|
|
1052
1174
|
const token = createPreviewToken(id, secret);
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
if (!data) return errors.notFound();
|
|
1064
|
-
return json({
|
|
1065
|
-
page: {
|
|
1066
|
-
id: data.id,
|
|
1067
|
-
slug: data.slug,
|
|
1068
|
-
path: data.path,
|
|
1069
|
-
title: data.title,
|
|
1070
|
-
seo: data.seo ?? {},
|
|
1071
|
-
layout: data.draft_layout ?? []
|
|
1072
|
-
}
|
|
1073
|
-
});
|
|
1175
|
+
const previewUrl = `/cms-preview/${encodeURIComponent(String(data.id))}`;
|
|
1176
|
+
const response = json({ path: data.path, url: previewUrl });
|
|
1177
|
+
const secure = process.env.NODE_ENV === "production" || new URL(request.url).protocol === "https:";
|
|
1178
|
+
response.headers.set("cache-control", "private, no-store");
|
|
1179
|
+
response.headers.set("referrer-policy", "no-referrer");
|
|
1180
|
+
response.headers.append(
|
|
1181
|
+
"set-cookie",
|
|
1182
|
+
`${PREVIEW_SESSION_COOKIE}=${token}; Path=${previewUrl}; HttpOnly; SameSite=Strict; Max-Age=${Math.floor(PREVIEW_TOKEN_TTL_MS / 1e3)}${secure ? "; Secure" : ""}`
|
|
1183
|
+
);
|
|
1184
|
+
return response;
|
|
1074
1185
|
};
|
|
1075
1186
|
const listVersions = async (request, id) => {
|
|
1076
1187
|
const auth = await guard(request, "pages.restore");
|
|
@@ -1158,15 +1269,56 @@ function createCmsRoutes(options) {
|
|
|
1158
1269
|
if (error) return errors.badRequest(error.message);
|
|
1159
1270
|
return json({ media: data });
|
|
1160
1271
|
};
|
|
1161
|
-
const
|
|
1272
|
+
const prepareUpload = async (file, target) => {
|
|
1162
1273
|
if (file.size > maxUploadBytes) {
|
|
1163
1274
|
return `File is too large (max ${Math.round(maxUploadBytes / 1024 / 1024)} MB).`;
|
|
1164
1275
|
}
|
|
1165
1276
|
const type = file.type || "";
|
|
1166
|
-
|
|
1167
|
-
|
|
1277
|
+
const rule = UPLOAD_RULES[type];
|
|
1278
|
+
if (!rule) {
|
|
1279
|
+
return "Unsupported file type. Allowed: JPEG, PNG, WebP, GIF, and AVIF images.";
|
|
1280
|
+
}
|
|
1281
|
+
const extension = uploadExtension(file.name);
|
|
1282
|
+
if (!rule.extensions.includes(extension)) {
|
|
1283
|
+
return "File extension does not match its declared type.";
|
|
1284
|
+
}
|
|
1285
|
+
if (target) {
|
|
1286
|
+
const knownTargetType = Boolean(UPLOAD_RULES[target.mimeType]);
|
|
1287
|
+
const pathMatches = target.path.startsWith("data:") || rule.extensions.includes(uploadExtension(target.path));
|
|
1288
|
+
if (knownTargetType && target.mimeType !== type || !knownTargetType && !pathMatches) {
|
|
1289
|
+
return "Replacement files must use the same format as the stored file.";
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
try {
|
|
1293
|
+
const { default: sharp } = await import("sharp");
|
|
1294
|
+
const input = Buffer.from(await file.arrayBuffer());
|
|
1295
|
+
const image = sharp(input, {
|
|
1296
|
+
animated: type === "image/gif",
|
|
1297
|
+
failOn: "error",
|
|
1298
|
+
limitInputPixels: 4e7
|
|
1299
|
+
});
|
|
1300
|
+
const metadata = await image.metadata();
|
|
1301
|
+
const width = metadata.width || 0;
|
|
1302
|
+
const height = metadata.height || 0;
|
|
1303
|
+
const pages = metadata.pages || 1;
|
|
1304
|
+
if (metadata.format !== rule.decodedFormat || width < 1 || height < 1 || width * height * pages > 4e7) {
|
|
1305
|
+
return "File contents do not match its declared type.";
|
|
1306
|
+
}
|
|
1307
|
+
const normalized = type === "image/jpeg" ? await image.rotate().jpeg({ quality: 90 }).toBuffer() : type === "image/png" ? await image.rotate().png().toBuffer() : type === "image/webp" ? await image.rotate().webp({ quality: 90 }).toBuffer() : type === "image/gif" ? await image.rotate().gif().toBuffer() : await image.rotate().avif({ quality: 60 }).toBuffer();
|
|
1308
|
+
if (normalized.byteLength > maxUploadBytes) {
|
|
1309
|
+
return `Normalized file is too large (max ${Math.round(maxUploadBytes / 1024 / 1024)} MB).`;
|
|
1310
|
+
}
|
|
1311
|
+
return {
|
|
1312
|
+
file: new File([Uint8Array.from(normalized)], file.name, {
|
|
1313
|
+
type,
|
|
1314
|
+
lastModified: file.lastModified
|
|
1315
|
+
}),
|
|
1316
|
+
width,
|
|
1317
|
+
height
|
|
1318
|
+
};
|
|
1319
|
+
} catch {
|
|
1320
|
+
return "File contents do not match its declared type.";
|
|
1168
1321
|
}
|
|
1169
|
-
return null;
|
|
1170
1322
|
};
|
|
1171
1323
|
const uploadMedia = async (request) => {
|
|
1172
1324
|
const auth = await guard(request, "media.upload");
|
|
@@ -1174,35 +1326,32 @@ function createCmsRoutes(options) {
|
|
|
1174
1326
|
const form = await request.formData().catch(() => null);
|
|
1175
1327
|
const file = form?.get("file");
|
|
1176
1328
|
if (!form || !(file instanceof File)) return errors.badRequest("file is required.");
|
|
1177
|
-
const
|
|
1178
|
-
if (
|
|
1329
|
+
const prepared = await prepareUpload(file);
|
|
1330
|
+
if (typeof prepared === "string") return errors.badRequest(prepared);
|
|
1331
|
+
const normalizedFile = prepared.file;
|
|
1179
1332
|
const safeName = file.name.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
1180
1333
|
let storagePath = `${Date.now().toString(36)}-${safeName}`;
|
|
1181
1334
|
if (options.memoryMode) {
|
|
1182
|
-
const buffer = Buffer.from(await
|
|
1183
|
-
storagePath = `data:${
|
|
1184
|
-
} else {
|
|
1185
|
-
const { error: storageError } = await db().storage.from("media").upload(storagePath, file, { contentType: file.type || "application/octet-stream" });
|
|
1186
|
-
if (storageError) return errors.badRequest(storageError.message);
|
|
1335
|
+
const buffer = Buffer.from(await normalizedFile.arrayBuffer());
|
|
1336
|
+
storagePath = `data:${normalizedFile.type};base64,${buffer.toString("base64")}`;
|
|
1187
1337
|
}
|
|
1188
|
-
const uploadedToStorage = !options.memoryMode;
|
|
1189
|
-
const width = Number(form.get("width")) || null;
|
|
1190
|
-
const height = Number(form.get("height")) || null;
|
|
1191
1338
|
const { data, error } = await db().from("cms_media").insert({
|
|
1192
1339
|
storage_path: storagePath,
|
|
1193
1340
|
filename: safeName,
|
|
1194
1341
|
alt: String(form.get("alt") || ""),
|
|
1195
1342
|
caption: String(form.get("caption") || ""),
|
|
1196
|
-
mime_type:
|
|
1197
|
-
width,
|
|
1198
|
-
height,
|
|
1199
|
-
filesize:
|
|
1343
|
+
mime_type: normalizedFile.type,
|
|
1344
|
+
width: prepared.width,
|
|
1345
|
+
height: prepared.height,
|
|
1346
|
+
filesize: normalizedFile.size
|
|
1200
1347
|
}).select("*").single();
|
|
1201
|
-
if (error)
|
|
1202
|
-
|
|
1203
|
-
|
|
1348
|
+
if (error) return errors.badRequest(error.message);
|
|
1349
|
+
if (!options.memoryMode) {
|
|
1350
|
+
const { error: storageError } = await db().storage.from("media").upload(storagePath, normalizedFile, { contentType: normalizedFile.type });
|
|
1351
|
+
if (storageError) {
|
|
1352
|
+
await db().from("cms_media").delete().eq("id", data.id);
|
|
1353
|
+
return errors.badRequest(storageError.message);
|
|
1204
1354
|
}
|
|
1205
|
-
return errors.badRequest(error.message);
|
|
1206
1355
|
}
|
|
1207
1356
|
await logActivity(auth.user, "media.upload", safeName);
|
|
1208
1357
|
return json({ media: data }, 201);
|
|
@@ -1248,27 +1397,29 @@ function createCmsRoutes(options) {
|
|
|
1248
1397
|
const form = await request.formData().catch(() => null);
|
|
1249
1398
|
const file = form?.get("file");
|
|
1250
1399
|
if (!form || !(file instanceof File)) return errors.badRequest("file is required.");
|
|
1251
|
-
const
|
|
1252
|
-
|
|
1400
|
+
const prepared = await prepareUpload(file, {
|
|
1401
|
+
path: String(doc.storage_path),
|
|
1402
|
+
mimeType: String(doc.mime_type || "")
|
|
1403
|
+
});
|
|
1404
|
+
if (typeof prepared === "string") return errors.badRequest(prepared);
|
|
1405
|
+
const normalizedFile = prepared.file;
|
|
1253
1406
|
let storagePath = String(doc.storage_path);
|
|
1254
1407
|
if (options.memoryMode) {
|
|
1255
|
-
const buffer = Buffer.from(await
|
|
1256
|
-
storagePath = `data:${
|
|
1408
|
+
const buffer = Buffer.from(await normalizedFile.arrayBuffer());
|
|
1409
|
+
storagePath = `data:${normalizedFile.type};base64,${buffer.toString("base64")}`;
|
|
1257
1410
|
} else {
|
|
1258
|
-
const { error: storageError } = await db().storage.from("media").upload(storagePath,
|
|
1259
|
-
contentType:
|
|
1411
|
+
const { error: storageError } = await db().storage.from("media").upload(storagePath, normalizedFile, {
|
|
1412
|
+
contentType: normalizedFile.type,
|
|
1260
1413
|
upsert: true
|
|
1261
1414
|
});
|
|
1262
1415
|
if (storageError) return errors.badRequest(storageError.message);
|
|
1263
1416
|
}
|
|
1264
|
-
const width = Number(form.get("width")) || null;
|
|
1265
|
-
const height = Number(form.get("height")) || null;
|
|
1266
1417
|
const { data, error } = await db().from("cms_media").update({
|
|
1267
1418
|
storage_path: storagePath,
|
|
1268
|
-
mime_type:
|
|
1269
|
-
filesize:
|
|
1270
|
-
width,
|
|
1271
|
-
height,
|
|
1419
|
+
mime_type: normalizedFile.type,
|
|
1420
|
+
filesize: normalizedFile.size,
|
|
1421
|
+
width: prepared.width,
|
|
1422
|
+
height: prepared.height,
|
|
1272
1423
|
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1273
1424
|
}).eq("id", id).select("*").single();
|
|
1274
1425
|
if (error) return errors.badRequest(error.message);
|
|
@@ -1286,11 +1437,17 @@ function createCmsRoutes(options) {
|
|
|
1286
1437
|
if (usage.length > 0 && !force) {
|
|
1287
1438
|
return errors.conflict("This file is used on pages.", { usage });
|
|
1288
1439
|
}
|
|
1440
|
+
const storagePath = String(doc.storage_path);
|
|
1441
|
+
if (!storagePath.startsWith("data:") && !storagePath.startsWith("/")) {
|
|
1442
|
+
try {
|
|
1443
|
+
const { error: storageError } = await db().storage.from("media").remove([storagePath]);
|
|
1444
|
+
if (storageError) return errors.badRequest(storageError.message);
|
|
1445
|
+
} catch (error2) {
|
|
1446
|
+
return errors.badRequest(error2 instanceof Error ? error2.message : "Unable to remove stored file.");
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1289
1449
|
const { error } = await db().from("cms_media").delete().eq("id", id);
|
|
1290
1450
|
if (error) return errors.badRequest(error.message);
|
|
1291
|
-
if (!String(doc.storage_path).startsWith("data:")) {
|
|
1292
|
-
await db().storage.from("media").remove([doc.storage_path]).catch(() => void 0);
|
|
1293
|
-
}
|
|
1294
1451
|
await logActivity(auth.user, "media.delete", String(doc.filename));
|
|
1295
1452
|
return json({ success: true });
|
|
1296
1453
|
};
|
|
@@ -1356,8 +1513,32 @@ function createCmsRoutes(options) {
|
|
|
1356
1513
|
const body = await readJson(request);
|
|
1357
1514
|
if (!body) return errors.badRequest("Invalid body.");
|
|
1358
1515
|
const config = isRecord2(body.config) ? { ...body.config } : {};
|
|
1359
|
-
|
|
1516
|
+
if (body.notify !== void 0 && !isRecord2(body.notify)) {
|
|
1517
|
+
return errors.badRequest("Notification settings must be an object.");
|
|
1518
|
+
}
|
|
1519
|
+
if (body.notify === void 0 && config.notify !== void 0 && !isRecord2(config.notify)) {
|
|
1520
|
+
return errors.badRequest("Notification settings must be an object.");
|
|
1521
|
+
}
|
|
1522
|
+
const rawNotify = isRecord2(body.notify) ? body.notify : isRecord2(config.notify) ? config.notify : {};
|
|
1360
1523
|
delete config.notify;
|
|
1524
|
+
if (rawNotify.autoReply !== void 0 && typeof rawNotify.autoReply !== "boolean") {
|
|
1525
|
+
return errors.badRequest("autoReply must be a boolean.");
|
|
1526
|
+
}
|
|
1527
|
+
if (rawNotify.autoReplyEmailField !== void 0 && (typeof rawNotify.autoReplyEmailField !== "string" || !rawNotify.autoReplyEmailField.trim())) {
|
|
1528
|
+
return errors.badRequest("autoReplyEmailField must name a declared email field.");
|
|
1529
|
+
}
|
|
1530
|
+
const emailFields = getAutoReplyEmailFields(config);
|
|
1531
|
+
const selectedEmailField = typeof rawNotify.autoReplyEmailField === "string" ? rawNotify.autoReplyEmailField.trim() : "";
|
|
1532
|
+
if (selectedEmailField && !emailFields.includes(selectedEmailField)) {
|
|
1533
|
+
return errors.badRequest("autoReplyEmailField must name a declared email field.");
|
|
1534
|
+
}
|
|
1535
|
+
if (rawNotify.autoReply === true && emailFields.length !== 1 && !selectedEmailField) {
|
|
1536
|
+
return errors.badRequest("Auto-reply requires one selected declared email field.");
|
|
1537
|
+
}
|
|
1538
|
+
const notify = {
|
|
1539
|
+
...rawNotify,
|
|
1540
|
+
...selectedEmailField ? { autoReplyEmailField: selectedEmailField } : {}
|
|
1541
|
+
};
|
|
1361
1542
|
const { data, error } = await db().from("cms_forms").upsert(
|
|
1362
1543
|
{
|
|
1363
1544
|
slug,
|
|
@@ -1420,7 +1601,7 @@ function createCmsRoutes(options) {
|
|
|
1420
1601
|
const csvEscape = (value) => {
|
|
1421
1602
|
let text = Array.isArray(value) ? value.join("; ") : typeof value === "string" ? value : value === null || value === void 0 ? "" : JSON.stringify(value);
|
|
1422
1603
|
if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`;
|
|
1423
|
-
return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
1604
|
+
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
1424
1605
|
};
|
|
1425
1606
|
const exportSubmissions = async (request) => {
|
|
1426
1607
|
const auth = await guard(request, "submissions.read");
|
|
@@ -1520,18 +1701,36 @@ function createCmsRoutes(options) {
|
|
|
1520
1701
|
session_key: requestSessionKey(request)
|
|
1521
1702
|
}).select("id").single();
|
|
1522
1703
|
if (error) return errors.badRequest(error.message);
|
|
1704
|
+
try {
|
|
1705
|
+
const userAgent = request.headers.get("user-agent") || "";
|
|
1706
|
+
await db().from("cms_events").insert({
|
|
1707
|
+
session_key: requestSessionKey(request),
|
|
1708
|
+
visitor_key: "",
|
|
1709
|
+
type: "form",
|
|
1710
|
+
name: `${form.slug}:submit`,
|
|
1711
|
+
path: requestPagePath(request),
|
|
1712
|
+
referrer: "",
|
|
1713
|
+
utm: {},
|
|
1714
|
+
device: deviceFrom(userAgent),
|
|
1715
|
+
...geoFrom(request),
|
|
1716
|
+
meta: {},
|
|
1717
|
+
server_verified: true
|
|
1718
|
+
});
|
|
1719
|
+
} catch {
|
|
1720
|
+
}
|
|
1523
1721
|
if (sendEmail) {
|
|
1722
|
+
const notificationConfig = {
|
|
1723
|
+
...form.config || {},
|
|
1724
|
+
...isRecord2(form.notify) && Object.keys(form.notify).length > 0 ? { notify: form.notify } : {}
|
|
1725
|
+
};
|
|
1524
1726
|
await notifySubmission({
|
|
1525
1727
|
sendEmail,
|
|
1526
1728
|
formTitle: String(form.title || form.slug),
|
|
1527
|
-
|
|
1528
|
-
config: {
|
|
1529
|
-
...form.config || {},
|
|
1530
|
-
...isRecord2(form.notify) && Object.keys(form.notify).length > 0 ? { notify: form.notify } : {}
|
|
1531
|
-
},
|
|
1729
|
+
config: notificationConfig,
|
|
1532
1730
|
successMessage: String(form.success_message || ""),
|
|
1533
1731
|
data: result.normalizedData,
|
|
1534
|
-
siteName: options.siteName
|
|
1732
|
+
siteName: options.siteName,
|
|
1733
|
+
autoReplyAllowed: await maySendAutoReply(notificationConfig, result.normalizedData)
|
|
1535
1734
|
});
|
|
1536
1735
|
}
|
|
1537
1736
|
return json({ success: true, id: created.id });
|
|
@@ -1571,11 +1770,17 @@ function createCmsRoutes(options) {
|
|
|
1571
1770
|
if (!isOriginAllowed(request, allowedOrigins)) return errors.forbidden();
|
|
1572
1771
|
const userAgent = request.headers.get("user-agent") || "";
|
|
1573
1772
|
if (isBotRequest(userAgent)) return json({ success: true });
|
|
1773
|
+
const now = Date.now();
|
|
1774
|
+
if (await analyticsLimits.siteRequestsPerMinute.isLimited("all", now) || await analyticsLimits.siteRequestsPerDay.isLimited("all", now)) {
|
|
1775
|
+
return json({ success: true });
|
|
1776
|
+
}
|
|
1574
1777
|
const key = hashedClientKey(clientKey(request));
|
|
1575
|
-
if (await
|
|
1778
|
+
if (await analyticsLimits.requestsPerMinute.isLimited(key, now)) {
|
|
1779
|
+
return json({ success: true });
|
|
1780
|
+
}
|
|
1576
1781
|
const body = await readJsonLimited(request, { maxBytes: 128 * 1024, maxDepth: 8 });
|
|
1577
1782
|
if (!body) return json({ success: true });
|
|
1578
|
-
const
|
|
1783
|
+
const parsed = parseEventBatch(body, {
|
|
1579
1784
|
sessionKey: requestSessionKey(request),
|
|
1580
1785
|
visitorKey: visitorKeyFor(
|
|
1581
1786
|
body?.visitorId,
|
|
@@ -1584,9 +1789,25 @@ function createCmsRoutes(options) {
|
|
|
1584
1789
|
device: deviceFrom(userAgent),
|
|
1585
1790
|
...geoFrom(request)
|
|
1586
1791
|
});
|
|
1792
|
+
const rows = parsed.rows.filter(
|
|
1793
|
+
(row) => !(row.type === "form" && row.name.endsWith(":submit"))
|
|
1794
|
+
);
|
|
1587
1795
|
if (rows.length > 0) {
|
|
1796
|
+
const limitChecks = [
|
|
1797
|
+
[analyticsLimits.clientEventsPerMinute, key],
|
|
1798
|
+
[analyticsLimits.clientEventsPerDay, key],
|
|
1799
|
+
[analyticsLimits.siteEventsPerMinute, "all"],
|
|
1800
|
+
[analyticsLimits.siteEventsPerDay, "all"]
|
|
1801
|
+
];
|
|
1802
|
+
for (const [limit, limitKey] of limitChecks) {
|
|
1803
|
+
if (await limit.isLimited(limitKey, now, rows.length)) {
|
|
1804
|
+
return json({ success: true });
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1588
1807
|
try {
|
|
1589
|
-
await db().from("cms_events").insert(
|
|
1808
|
+
await db().from("cms_events").insert(
|
|
1809
|
+
rows.map((row) => ({ ...row, server_verified: false }))
|
|
1810
|
+
);
|
|
1590
1811
|
} catch {
|
|
1591
1812
|
}
|
|
1592
1813
|
}
|
|
@@ -1596,7 +1817,7 @@ function createCmsRoutes(options) {
|
|
|
1596
1817
|
const all = [];
|
|
1597
1818
|
let cursor = 0;
|
|
1598
1819
|
for (let page = 0; page < 60; page += 1) {
|
|
1599
|
-
const { data, error } = await db().from("cms_events").select("id, session_key, type, name, path, referrer, utm, device, region, city, meta, created_at").gte("created_at", fromIso).lte("created_at", toIso).gt("id", cursor).order("id", { ascending: true }).limit(1e3);
|
|
1820
|
+
const { data, error } = await db().from("cms_events").select("id, session_key, type, name, path, referrer, utm, device, region, city, meta, server_verified, created_at").gte("created_at", fromIso).lte("created_at", toIso).gt("id", cursor).order("id", { ascending: true }).limit(1e3);
|
|
1600
1821
|
if (error || !data || data.length === 0) break;
|
|
1601
1822
|
all.push(...data);
|
|
1602
1823
|
cursor = Number(data[data.length - 1].id);
|
|
@@ -1623,8 +1844,10 @@ function createCmsRoutes(options) {
|
|
|
1623
1844
|
};
|
|
1624
1845
|
const pruneEvents = async () => {
|
|
1625
1846
|
const cutoff = new Date(Date.now() - analyticsRetentionDays * 864e5).toISOString();
|
|
1847
|
+
const limitCutoff = new Date(Date.now() - 2 * 864e5).toISOString();
|
|
1626
1848
|
try {
|
|
1627
1849
|
await db().from("cms_events").delete().lt("created_at", cutoff);
|
|
1850
|
+
await db().rpc("cms_prune_rate_limits", { p_before: limitCutoff });
|
|
1628
1851
|
} catch {
|
|
1629
1852
|
}
|
|
1630
1853
|
};
|
|
@@ -1872,7 +2095,6 @@ function createCmsRoutes(options) {
|
|
|
1872
2095
|
return listVersions(request, second);
|
|
1873
2096
|
}
|
|
1874
2097
|
}
|
|
1875
|
-
if (head === "preview" && !second && method === "GET") return previewPage(request);
|
|
1876
2098
|
if (head === "versions" && second) {
|
|
1877
2099
|
if (third === "restore" && method === "POST") return restoreVersion(request, second);
|
|
1878
2100
|
if (!third && method === "GET") return getVersion(request, second);
|
|
@@ -2262,7 +2484,11 @@ function runRpc(store, fn, args = {}) {
|
|
|
2262
2484
|
const limits = store.table("cms_rate_limits");
|
|
2263
2485
|
const key = String(args.p_key);
|
|
2264
2486
|
const max = Number(args.p_max);
|
|
2487
|
+
const cost = args.p_cost === void 0 ? 1 : Number(args.p_cost);
|
|
2265
2488
|
const windowMs = Number(args.p_window_seconds) * 1e3;
|
|
2489
|
+
if (!Number.isSafeInteger(cost) || cost < 1 || cost > max) {
|
|
2490
|
+
return { data: false, error: null };
|
|
2491
|
+
}
|
|
2266
2492
|
const nowMs = Date.now();
|
|
2267
2493
|
let row = limits.find((r) => r.key === key);
|
|
2268
2494
|
if (!row) {
|
|
@@ -2271,12 +2497,22 @@ function runRpc(store, fn, args = {}) {
|
|
|
2271
2497
|
}
|
|
2272
2498
|
if (nowMs - Number(row.window_start) >= windowMs) {
|
|
2273
2499
|
row.window_start = nowMs;
|
|
2274
|
-
row.count =
|
|
2500
|
+
row.count = cost;
|
|
2275
2501
|
} else {
|
|
2276
|
-
row.count = Number(row.count) +
|
|
2502
|
+
row.count = Number(row.count) + cost;
|
|
2277
2503
|
}
|
|
2278
2504
|
return { data: Number(row.count) <= max, error: null };
|
|
2279
2505
|
}
|
|
2506
|
+
case "cms_prune_rate_limits": {
|
|
2507
|
+
const before = Date.parse(String(args.p_before));
|
|
2508
|
+
const limits = store.table("cms_rate_limits");
|
|
2509
|
+
const kept = limits.filter((row) => {
|
|
2510
|
+
const started = typeof row.window_start === "number" ? row.window_start : Date.parse(String(row.window_start));
|
|
2511
|
+
return !Number.isFinite(before) || !Number.isFinite(started) || started >= before;
|
|
2512
|
+
});
|
|
2513
|
+
store.setTable("cms_rate_limits", kept);
|
|
2514
|
+
return { data: limits.length - kept.length, error: null };
|
|
2515
|
+
}
|
|
2280
2516
|
case "cms_publish_due_pages": {
|
|
2281
2517
|
const nowMs = Date.now();
|
|
2282
2518
|
const published = [];
|
|
@@ -2409,6 +2645,7 @@ function getMemoryCms() {
|
|
|
2409
2645
|
export {
|
|
2410
2646
|
CONTENT_CACHE_TAG,
|
|
2411
2647
|
MEMORY_DEV_TOKEN,
|
|
2648
|
+
PREVIEW_SESSION_COOKIE,
|
|
2412
2649
|
PREVIEW_TOKEN_TTL_MS,
|
|
2413
2650
|
aggregateAnalytics,
|
|
2414
2651
|
can,
|