@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
|
@@ -9,14 +9,19 @@
|
|
|
9
9
|
|
|
10
10
|
import {
|
|
11
11
|
EMPTY_BUNDLE,
|
|
12
|
+
MISSING_CREATED_ID,
|
|
12
13
|
mergeBundles,
|
|
13
14
|
normalizeHostname,
|
|
14
15
|
selectProfiles,
|
|
15
16
|
type InjectionBundle,
|
|
16
17
|
type InjectionProfile,
|
|
17
18
|
} from "../tracking/injections";
|
|
19
|
+
import { resolveRequestLocale } from "../site/locale-domains";
|
|
18
20
|
import { actorEmail, assertRole, type PlatformAuthContext } from "./guards";
|
|
19
21
|
|
|
22
|
+
/** Re-exported so hosts that already import it from here keep working. */
|
|
23
|
+
export { MISSING_CREATED_ID };
|
|
24
|
+
|
|
20
25
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
21
26
|
/** Structural subset of a Supabase-shaped client used by this core. */
|
|
22
27
|
export type InjectionsClient = { from: (table: string) => any };
|
|
@@ -89,11 +94,76 @@ function toRow(input: InjectionInput) {
|
|
|
89
94
|
};
|
|
90
95
|
}
|
|
91
96
|
|
|
97
|
+
/** Where a failure gets reported. Defaults to `console.error`. */
|
|
98
|
+
export type InjectionsLogger = (message: string, error: unknown) => void;
|
|
99
|
+
|
|
100
|
+
const defaultLogger: InjectionsLogger = (message, error) => {
|
|
101
|
+
console.error(message, error);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Report a failure without letting the report become one.
|
|
106
|
+
*
|
|
107
|
+
* Everywhere this is called the outcome has already been decided: the row is
|
|
108
|
+
* written, or the request is about to be served an empty bundle so the page
|
|
109
|
+
* still renders. A logger that throws would undo that decision at the very
|
|
110
|
+
* last step — an injected reporter whose transport is down turns a completed
|
|
111
|
+
* create into a rejection the screen reads as "press Save again", and a
|
|
112
|
+
* degraded render into a 500 on a public page. The report is worth less than
|
|
113
|
+
* either.
|
|
114
|
+
*
|
|
115
|
+
* So the supplied logger is tried, then `console.error` on its own, and then
|
|
116
|
+
* the reporting failure is dropped, because at that point there is nothing
|
|
117
|
+
* left that can be told.
|
|
118
|
+
*/
|
|
119
|
+
function reportBestEffort(logger: InjectionsLogger, message: string, error: unknown): void {
|
|
120
|
+
try {
|
|
121
|
+
logger(message, error);
|
|
122
|
+
} catch (loggerError) {
|
|
123
|
+
try {
|
|
124
|
+
console.error(message, error);
|
|
125
|
+
console.error("[cms] injection logger failed", loggerError);
|
|
126
|
+
} catch {
|
|
127
|
+
// The console is gone too. Nothing here is worth the caller's outcome.
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Write the audit entry, and let the mutation stand if that write fails.
|
|
134
|
+
*
|
|
135
|
+
* By the time this runs the row is already inserted, updated or deleted. A
|
|
136
|
+
* rejection here would hand the caller an ordinary failure for a change that
|
|
137
|
+
* did happen, and the obvious response to an ordinary failure — press Save
|
|
138
|
+
* again — inserts a second profile. Auditing is best-effort across this
|
|
139
|
+
* package for exactly that reason: the trail is worth less than the
|
|
140
|
+
* correctness of what it describes.
|
|
141
|
+
*
|
|
142
|
+
* The failure is reported rather than swallowed, so a host that has lost its
|
|
143
|
+
* audit table finds out from its logs instead of from the empty table.
|
|
144
|
+
*/
|
|
145
|
+
async function auditBestEffort(
|
|
146
|
+
audit: AuditWriter,
|
|
147
|
+
entry: Parameters<AuditWriter>[0],
|
|
148
|
+
logger: InjectionsLogger,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
try {
|
|
151
|
+
await audit(entry);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
reportBestEffort(
|
|
154
|
+
logger,
|
|
155
|
+
`[cms] injection audit failed for ${entry.action} ${entry.entityId}`,
|
|
156
|
+
error,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
92
161
|
/** Admin-only create/update of one profile, with an audit entry. */
|
|
93
162
|
export async function saveInjectionCore(
|
|
94
163
|
context: PlatformAuthContext & { supabase: InjectionsClient },
|
|
95
164
|
input: InjectionInput,
|
|
96
165
|
audit: AuditWriter,
|
|
166
|
+
logger: InjectionsLogger = defaultLogger,
|
|
97
167
|
): Promise<{ id: string }> {
|
|
98
168
|
await assertRole(context, "admin");
|
|
99
169
|
const row = toRow(input);
|
|
@@ -105,14 +175,19 @@ export async function saveInjectionCore(
|
|
|
105
175
|
.update(row)
|
|
106
176
|
.eq("id", input.id);
|
|
107
177
|
if (error) throw new Error(error.message);
|
|
108
|
-
await
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
178
|
+
await auditBestEffort(
|
|
179
|
+
audit,
|
|
180
|
+
{
|
|
181
|
+
actorId: context.userId,
|
|
182
|
+
actorEmail: actorEmail(context),
|
|
183
|
+
action: "injection.update",
|
|
184
|
+
entity: "site_injections",
|
|
185
|
+
entityId: input.id,
|
|
186
|
+
meta: { name: input.name, scope, enabled: input.enabled },
|
|
187
|
+
},
|
|
188
|
+
logger,
|
|
189
|
+
);
|
|
190
|
+
invalidateInjectionCache();
|
|
116
191
|
return { id: input.id };
|
|
117
192
|
}
|
|
118
193
|
|
|
@@ -122,15 +197,35 @@ export async function saveInjectionCore(
|
|
|
122
197
|
.select("id")
|
|
123
198
|
.single();
|
|
124
199
|
if (error) throw new Error(error.message);
|
|
125
|
-
const id = String(inserted?.id ?? "");
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
200
|
+
const id = String(inserted?.id ?? "").trim();
|
|
201
|
+
/*
|
|
202
|
+
* No id means the insert cannot be reported as a success.
|
|
203
|
+
*
|
|
204
|
+
* Whether the row exists is now unknown, and everything below this line
|
|
205
|
+
* states that it does: an audit entry naming no entity, a cache invalidation
|
|
206
|
+
* announcing a change, and a `{ id: "" }` the caller reads as "created".
|
|
207
|
+
* Throwing before any of them leaves one indeterminate insert to reconcile
|
|
208
|
+
* rather than a trail of confident records about a row nobody can find.
|
|
209
|
+
*/
|
|
210
|
+
if (!id) throw new Error(MISSING_CREATED_ID);
|
|
211
|
+
/*
|
|
212
|
+
* The row exists and its id is in hand. Nothing after this point may take
|
|
213
|
+
* that away from the caller: a create reported as failed is a create the
|
|
214
|
+
* administrator will send again, and the second one is a duplicate.
|
|
215
|
+
*/
|
|
216
|
+
await auditBestEffort(
|
|
217
|
+
audit,
|
|
218
|
+
{
|
|
219
|
+
actorId: context.userId,
|
|
220
|
+
actorEmail: actorEmail(context),
|
|
221
|
+
action: "injection.create",
|
|
222
|
+
entity: "site_injections",
|
|
223
|
+
entityId: id,
|
|
224
|
+
meta: { name: input.name, scope },
|
|
225
|
+
},
|
|
226
|
+
logger,
|
|
227
|
+
);
|
|
228
|
+
invalidateInjectionCache();
|
|
134
229
|
return { id };
|
|
135
230
|
}
|
|
136
231
|
|
|
@@ -139,29 +234,76 @@ export async function deleteInjectionCore(
|
|
|
139
234
|
context: PlatformAuthContext & { supabase: InjectionsClient },
|
|
140
235
|
id: string,
|
|
141
236
|
audit: AuditWriter,
|
|
237
|
+
logger: InjectionsLogger = defaultLogger,
|
|
142
238
|
): Promise<{ ok: true }> {
|
|
143
239
|
await assertRole(context, "admin");
|
|
144
240
|
const { error } = await context.supabase.from("site_injections").delete().eq("id", id);
|
|
145
241
|
if (error) throw new Error(error.message);
|
|
146
|
-
await
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
242
|
+
await auditBestEffort(
|
|
243
|
+
audit,
|
|
244
|
+
{
|
|
245
|
+
actorId: context.userId,
|
|
246
|
+
actorEmail: actorEmail(context),
|
|
247
|
+
action: "injection.delete",
|
|
248
|
+
entity: "site_injections",
|
|
249
|
+
entityId: id,
|
|
250
|
+
meta: {},
|
|
251
|
+
},
|
|
252
|
+
logger,
|
|
253
|
+
);
|
|
254
|
+
invalidateInjectionCache();
|
|
154
255
|
return { ok: true };
|
|
155
256
|
}
|
|
156
257
|
|
|
157
258
|
/* ---------------- Request-time resolution ---------------- */
|
|
158
259
|
|
|
159
260
|
const CACHE_TTL_MS = 30_000;
|
|
261
|
+
/**
|
|
262
|
+
* Bound on distinct hosts held at once. The cache key is a request header, so
|
|
263
|
+
* an unbounded map is something a stranger can grow; past the cap the oldest
|
|
264
|
+
* entries go, which for a real site is never reached.
|
|
265
|
+
*/
|
|
266
|
+
const CACHE_MAX_ENTRIES = 64;
|
|
160
267
|
const cache = new Map<string, { at: number; bundle: InjectionBundle }>();
|
|
161
268
|
|
|
269
|
+
/** Drop cached bundles — all of them, or just one host's. */
|
|
270
|
+
export function invalidateInjectionCache(host?: string): void {
|
|
271
|
+
if (host === undefined) {
|
|
272
|
+
cache.clear();
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
cache.delete(normalizeHostname(host));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function remember(key: string, bundle: InjectionBundle): void {
|
|
279
|
+
const now = Date.now();
|
|
280
|
+
for (const [host, entry] of cache) {
|
|
281
|
+
if (now - entry.at >= CACHE_TTL_MS) cache.delete(host);
|
|
282
|
+
}
|
|
283
|
+
while (cache.size >= CACHE_MAX_ENTRIES) {
|
|
284
|
+
const oldest = cache.keys().next();
|
|
285
|
+
if (oldest.done) break;
|
|
286
|
+
cache.delete(oldest.value);
|
|
287
|
+
}
|
|
288
|
+
cache.set(key, { at: now, bundle });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export type ResolveInjectionsOptions = {
|
|
292
|
+
defaultLocale?: string;
|
|
293
|
+
/** Reports a backend failure. Without one, failures reach `console.error`. */
|
|
294
|
+
logger?: InjectionsLogger;
|
|
295
|
+
};
|
|
296
|
+
|
|
162
297
|
/**
|
|
163
|
-
* Resolve the merged bundle for a request host.
|
|
164
|
-
*
|
|
298
|
+
* Resolve the merged bundle for a request host.
|
|
299
|
+
*
|
|
300
|
+
* Never throws: a backend hiccup degrades to "no injection" so the site still
|
|
301
|
+
* renders. It is not, however, allowed to do that *quietly* — a failed read is
|
|
302
|
+
* reported and is NOT cached, so the next request retries instead of serving
|
|
303
|
+
* a fabricated empty bundle for the rest of the TTL. A query that returns an
|
|
304
|
+
* error rather than throwing counts as a failure; that is the shape this
|
|
305
|
+
* backend uses, and treating it as "no profiles" is what made a broken
|
|
306
|
+
* deployment indistinguishable from an unconfigured one.
|
|
165
307
|
*
|
|
166
308
|
* `client` must be a privileged (service-role) client — `site_injections` and
|
|
167
309
|
* `locale_domains` are not readable anonymously.
|
|
@@ -169,55 +311,94 @@ const cache = new Map<string, { at: number; bundle: InjectionBundle }>();
|
|
|
169
311
|
export async function resolveInjectionsCore(
|
|
170
312
|
client: InjectionsClient,
|
|
171
313
|
host: string,
|
|
172
|
-
options:
|
|
314
|
+
options: ResolveInjectionsOptions = {},
|
|
173
315
|
): Promise<InjectionBundle> {
|
|
174
316
|
const key = normalizeHostname(host);
|
|
175
317
|
const hit = cache.get(key);
|
|
176
318
|
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.bundle;
|
|
177
319
|
|
|
178
|
-
|
|
320
|
+
const log = options.logger ?? defaultLogger;
|
|
179
321
|
try {
|
|
180
|
-
const [
|
|
322
|
+
const [profileResult, domainResult] = await Promise.all([
|
|
181
323
|
client.from("site_injections").select("*").eq("enabled", true),
|
|
182
324
|
client.from("locale_domains").select("locale, domain, is_default"),
|
|
183
325
|
]);
|
|
184
326
|
|
|
185
|
-
if (
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
327
|
+
if (profileResult?.error) throw new Error(String(profileResult.error.message ?? profileResult.error));
|
|
328
|
+
if (domainResult?.error) throw new Error(String(domainResult.error.message ?? domainResult.error));
|
|
329
|
+
|
|
330
|
+
const rows = (profileResult?.data ?? []) as Record<string, unknown>[];
|
|
331
|
+
if (rows.length === 0) {
|
|
332
|
+
remember(key, EMPTY_BUNDLE);
|
|
333
|
+
return EMPTY_BUNDLE;
|
|
192
334
|
}
|
|
193
|
-
|
|
194
|
-
|
|
335
|
+
|
|
336
|
+
const list = ((domainResult?.data ?? []) as {
|
|
337
|
+
locale: string;
|
|
338
|
+
domain: string;
|
|
339
|
+
is_default: boolean;
|
|
340
|
+
}[]).map((d) => ({ locale: d.locale, domain: d.domain, isDefault: d.is_default }));
|
|
341
|
+
|
|
342
|
+
const { locale } = resolveRequestLocale(list, host, options.defaultLocale ?? "en");
|
|
343
|
+
const bundle = mergeBundles(selectProfiles(rows.map(rowToInjectionProfile), locale, host));
|
|
344
|
+
remember(key, bundle);
|
|
345
|
+
return bundle;
|
|
346
|
+
} catch (error) {
|
|
347
|
+
reportBestEffort(log, "site code injection: could not resolve tracking profiles", error);
|
|
348
|
+
return EMPTY_BUNDLE;
|
|
195
349
|
}
|
|
350
|
+
}
|
|
196
351
|
|
|
197
|
-
|
|
198
|
-
|
|
352
|
+
/** A path reduced to `/a/b`: leading slash, no trailing one, no query. */
|
|
353
|
+
function normalizePath(value: string): string {
|
|
354
|
+
const path = (value ?? "").split(/[?#]/)[0] ?? "";
|
|
355
|
+
const withSlash = path.startsWith("/") ? path : `/${path}`;
|
|
356
|
+
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Whether `pathname` is `prefix` itself or something beneath it.
|
|
361
|
+
*
|
|
362
|
+
* Compared segment by segment rather than by string prefix, because
|
|
363
|
+
* `/administrator` is a public page and `startsWith("/admin")` called it part
|
|
364
|
+
* of the backoffice — a site whose backoffice lives at `/admin` then silently
|
|
365
|
+
* lost tracking on every page whose path merely began with those letters.
|
|
366
|
+
*/
|
|
367
|
+
function isPathWithin(pathname: string, prefix: string): boolean {
|
|
368
|
+
const path = normalizePath(pathname);
|
|
369
|
+
const base = normalizePath(prefix);
|
|
370
|
+
if (base === "/") return true;
|
|
371
|
+
return path === base || path.startsWith(`${base}/`);
|
|
199
372
|
}
|
|
200
373
|
|
|
201
374
|
/** True for paths that must never receive tracking code. */
|
|
202
375
|
export function isInjectablePath(pathname: string, backofficePath = "/kontoret"): boolean {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (pathname.startsWith("/api")) return false;
|
|
206
|
-
if (pathname.startsWith("/_serverFn")) return false;
|
|
207
|
-
return true;
|
|
376
|
+
const excluded = [backofficePath, "/preview", "/api", "/_serverFn"];
|
|
377
|
+
return !excluded.some((prefix) => isPathWithin(pathname, prefix));
|
|
208
378
|
}
|
|
209
379
|
|
|
210
|
-
/**
|
|
380
|
+
/**
|
|
381
|
+
* Insert the bundle's three chunks into a rendered HTML document.
|
|
382
|
+
*
|
|
383
|
+
* Every insertion goes through a replacement *function*, never a replacement
|
|
384
|
+
* string. A string replacement is not a literal: `String.prototype.replace`
|
|
385
|
+
* reads `$&`, `` $` ``, `$'` and `$$` out of it and substitutes parts of the
|
|
386
|
+
* match. Administrator code is raw by design — a snippet containing `$&`
|
|
387
|
+
* (jQuery, a currency template, a regex in a tracker) would be silently
|
|
388
|
+
* rewritten into the matched tag, shipping something the administrator never
|
|
389
|
+
* wrote. A function's return value is used verbatim, so the injected bytes are
|
|
390
|
+
* exactly the bytes that were saved.
|
|
391
|
+
*/
|
|
211
392
|
export function applyInjections(html: string, bundle: InjectionBundle): string {
|
|
212
393
|
let out = html;
|
|
213
394
|
if (bundle.head && out.includes("</head>")) {
|
|
214
|
-
out = out.replace("</head>", `${bundle.head}</head>`);
|
|
395
|
+
out = out.replace("</head>", () => `${bundle.head}</head>`);
|
|
215
396
|
}
|
|
216
397
|
if (bundle.bodyStart) {
|
|
217
398
|
out = out.replace(/<body([^>]*)>/i, (m) => `${m}${bundle.bodyStart}`);
|
|
218
399
|
}
|
|
219
400
|
if (bundle.bodyEnd && out.includes("</body>")) {
|
|
220
|
-
out = out.replace("</body>", `${bundle.bodyEnd}</body>`);
|
|
401
|
+
out = out.replace("</body>", () => `${bundle.bodyEnd}</body>`);
|
|
221
402
|
}
|
|
222
403
|
return out;
|
|
223
404
|
}
|
|
@@ -14,6 +14,12 @@ import type { SupabaseClient } from "@supabase/supabase-js";
|
|
|
14
14
|
import { getMediaStorage } from "../media/storage";
|
|
15
15
|
import { isSecondaryVariant, variantSiblings } from "../media/srcset";
|
|
16
16
|
import { uniqueFileName } from "../media/filename";
|
|
17
|
+
import {
|
|
18
|
+
assertMediaFolder,
|
|
19
|
+
joinMediaPath,
|
|
20
|
+
mediaBaseName,
|
|
21
|
+
monthFolder,
|
|
22
|
+
} from "../media/folders";
|
|
17
23
|
import { auditWriter } from "./audit-core";
|
|
18
24
|
import type { PlatformAuthContext } from "./guards";
|
|
19
25
|
import {
|
|
@@ -331,10 +337,22 @@ export function signMediaUrlCore(
|
|
|
331
337
|
*/
|
|
332
338
|
export async function uploadMediaBundleCore(
|
|
333
339
|
deps: MediaLibraryDeps,
|
|
334
|
-
input: {
|
|
335
|
-
|
|
340
|
+
input: {
|
|
341
|
+
files: UploadMediaFile[];
|
|
342
|
+
upsert?: boolean;
|
|
343
|
+
/**
|
|
344
|
+
* Destination folder. Omitted or empty means the automatic `YYYY-MM`
|
|
345
|
+
* folder; a value is sanitized here (never trusted from the browser) and
|
|
346
|
+
* rejected outright if nothing safe is left of it.
|
|
347
|
+
*/
|
|
348
|
+
folder?: string | null;
|
|
349
|
+
/** Site timezone, so the automatic folder rolls over at local midnight. */
|
|
350
|
+
timezone?: string | null;
|
|
351
|
+
},
|
|
352
|
+
): Promise<{ urls: string[]; folder: string }> {
|
|
336
353
|
const storage = getMediaStorage(deps.context.supabase);
|
|
337
354
|
const upsert = input.upsert ?? false;
|
|
355
|
+
const folder = assertMediaFolder(input.folder) || monthFolder(Date.now(), input.timezone);
|
|
338
356
|
|
|
339
357
|
const withSuffix = (name: string, n: number) => {
|
|
340
358
|
if (n <= 1) return name;
|
|
@@ -348,9 +366,11 @@ export async function uploadMediaBundleCore(
|
|
|
348
366
|
uploaded = [];
|
|
349
367
|
try {
|
|
350
368
|
for (const f of input.files) {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
369
|
+
// Any directory part in the client-supplied name is dropped: the folder
|
|
370
|
+
// is the only thing that decides where a file lands.
|
|
371
|
+
const path = joinMediaPath(folder, withSuffix(mediaBaseName(f.name), attempt));
|
|
372
|
+
await storage.upload(path, new Uint8Array(f.bytes), f.contentType, { upsert });
|
|
373
|
+
uploaded.push(path);
|
|
354
374
|
}
|
|
355
375
|
break;
|
|
356
376
|
} catch (err) {
|
|
@@ -365,8 +385,9 @@ export async function uploadMediaBundleCore(
|
|
|
365
385
|
await audit(deps, "media.upload", uploaded[0] ?? "upload", {
|
|
366
386
|
paths: uploaded,
|
|
367
387
|
count: uploaded.length,
|
|
388
|
+
folder,
|
|
368
389
|
});
|
|
369
|
-
return { urls: uploaded.map((n) => storage.publicUrl(n)) };
|
|
390
|
+
return { urls: uploaded.map((n) => storage.publicUrl(n)), folder };
|
|
370
391
|
}
|
|
371
392
|
|
|
372
393
|
/**
|