@bygghammar/cms-platform 0.47.0 → 0.49.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 +15 -0
- package/package.json +26 -3
- package/scripts/create-cms-site.mjs +0 -0
- package/src/auth/browser.ts +154 -0
- package/src/auth/middleware.ts +36 -0
- package/src/cms/page-draft-store.ts +121 -0
- package/src/media/FocalImage.tsx +152 -0
- package/src/media/LazyVideo.tsx +202 -0
- package/src/media/MediaMetaProvider.tsx +33 -0
- package/src/media/use-media-upload.ts +90 -0
- package/src/runtime/error-capture.ts +84 -0
- package/src/runtime/error-page.ts +31 -0
- package/src/runtime/error-reporting.ts +61 -0
- package/src/server/auth-context.ts +58 -0
- package/src/server/clients.ts +64 -0
- package/src/server/env.ts +74 -0
- package/src/server/http/backup.ts +152 -0
- package/src/server/http/cms-content.ts +26 -0
- package/src/server/http/fonts.ts +38 -0
- package/src/server/http/health.ts +38 -0
- package/src/server/http/robots.ts +53 -0
- package/src/server/http/sitemap.ts +63 -0
- package/src/site/PageRenderer.tsx +151 -0
- package/src/site/routes.ts +350 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { useT } from "../i18n/context";
|
|
4
|
+
import { resolveMediaSrc } from "./urls";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bandwidth-safe, mobile-safe <video> wrapper.
|
|
8
|
+
*
|
|
9
|
+
* Mobile browsers (iOS Safari in particular) only start an inline autoplaying
|
|
10
|
+
* video when `muted`, `playsinline` and `autoplay` are present as real
|
|
11
|
+
* attributes on the element that owns the media, and when at least one source
|
|
12
|
+
* is present when the element is parsed. Sources are therefore always
|
|
13
|
+
* rendered — the download itself stays cheap because `preload` is `none`
|
|
14
|
+
* until the element is near the viewport.
|
|
15
|
+
*
|
|
16
|
+
* - the poster paints first (cheap image, correct visual design),
|
|
17
|
+
* - `preload` is upgraded to the caller's hint once near the viewport,
|
|
18
|
+
* - a timeout fallback guarantees the video is never left permanently
|
|
19
|
+
* unloaded when IntersectionObserver never fires,
|
|
20
|
+
* - `play()` is called explicitly and its promise is always caught: a refused
|
|
21
|
+
* autoplay never crashes the page, it reveals an accessible tap-to-play
|
|
22
|
+
* button over the poster,
|
|
23
|
+
* - reduced motion keeps the poster and the tap-to-play button instead of
|
|
24
|
+
* removing the media.
|
|
25
|
+
*/
|
|
26
|
+
export type LazyVideoProps = {
|
|
27
|
+
src?: string;
|
|
28
|
+
/** Optional alternative sources (e.g. WebM) tried before `src`. */
|
|
29
|
+
sources?: Array<{ src: string; type: string }>;
|
|
30
|
+
poster?: string;
|
|
31
|
+
className?: string;
|
|
32
|
+
autoPlay?: boolean;
|
|
33
|
+
loop?: boolean;
|
|
34
|
+
muted?: boolean;
|
|
35
|
+
controls?: boolean;
|
|
36
|
+
/** Preload hint used once the element is near the viewport. */
|
|
37
|
+
preload?: "none" | "metadata" | "auto";
|
|
38
|
+
ariaLabel?: string;
|
|
39
|
+
/** Localized accessible label for the tap-to-play fallback control. */
|
|
40
|
+
playLabel?: { sv: string } & Record<string, string>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function prefersReducedMotion(): boolean {
|
|
44
|
+
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
45
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Order the sources so the broadly compatible MP4/H.264 rendition is always
|
|
50
|
+
* offered. Safari picks the first source it claims to support, and some iOS
|
|
51
|
+
* versions claim WebM support they cannot actually decode, so MP4 goes first.
|
|
52
|
+
*/
|
|
53
|
+
export function orderedSources(
|
|
54
|
+
rawSrc: string | undefined,
|
|
55
|
+
rawSources: Array<{ src: string; type: string }> | undefined,
|
|
56
|
+
): Array<{ src: string; type: string }> {
|
|
57
|
+
const src = resolveMediaSrc(rawSrc);
|
|
58
|
+
const sources = rawSources?.map((s) => ({ ...s, src: resolveMediaSrc(s.src) }));
|
|
59
|
+
const list =
|
|
60
|
+
sources && sources.length ? [...sources] : src ? [{ src, type: guessType(src) }] : [];
|
|
61
|
+
const seen = new Set<string>();
|
|
62
|
+
const unique = list.filter((s) => {
|
|
63
|
+
if (!s.src || seen.has(s.src)) return false;
|
|
64
|
+
seen.add(s.src);
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
if (src && !seen.has(src)) unique.push({ src, type: guessType(src) });
|
|
68
|
+
const rank = (type: string) => (/mp4/i.test(type) ? 0 : /webm/i.test(type) ? 1 : 2);
|
|
69
|
+
return unique.sort((a, b) => rank(a.type) - rank(b.type));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function guessType(url: string): string {
|
|
73
|
+
const clean = url.split("?")[0]!.toLowerCase();
|
|
74
|
+
if (clean.endsWith(".webm")) return "video/webm";
|
|
75
|
+
if (clean.endsWith(".mov")) return "video/quicktime";
|
|
76
|
+
return "video/mp4";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function LazyVideo({
|
|
80
|
+
src,
|
|
81
|
+
sources,
|
|
82
|
+
poster,
|
|
83
|
+
className,
|
|
84
|
+
autoPlay = true,
|
|
85
|
+
loop = true,
|
|
86
|
+
muted = true,
|
|
87
|
+
controls = false,
|
|
88
|
+
preload = "metadata",
|
|
89
|
+
ariaLabel,
|
|
90
|
+
playLabel,
|
|
91
|
+
}: LazyVideoProps) {
|
|
92
|
+
const { t } = useT();
|
|
93
|
+
const ref = useRef<HTMLVideoElement>(null);
|
|
94
|
+
const [active, setActive] = useState(false);
|
|
95
|
+
const [reduced, setReduced] = useState(false);
|
|
96
|
+
const [blocked, setBlocked] = useState(false);
|
|
97
|
+
|
|
98
|
+
const list = orderedSources(src, sources);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
setReduced(prefersReducedMotion());
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (active) return;
|
|
106
|
+
const el = ref.current;
|
|
107
|
+
if (!el) return;
|
|
108
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
109
|
+
setActive(true);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const io = new IntersectionObserver(
|
|
113
|
+
(entries) => {
|
|
114
|
+
if (entries.some((e) => e.isIntersecting)) {
|
|
115
|
+
setActive(true);
|
|
116
|
+
io.disconnect();
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
{ rootMargin: "200px" },
|
|
120
|
+
);
|
|
121
|
+
io.observe(el);
|
|
122
|
+
// Never leave the video permanently unloaded (mobile browsers can skip
|
|
123
|
+
// observer callbacks for elements inside transformed/animated ancestors).
|
|
124
|
+
const timer = setTimeout(() => setActive(true), 3000);
|
|
125
|
+
return () => {
|
|
126
|
+
io.disconnect();
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
};
|
|
129
|
+
}, [active]);
|
|
130
|
+
|
|
131
|
+
// Explicit playback attempt: a refused promise must never propagate.
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const el = ref.current;
|
|
134
|
+
if (!el || !active) return;
|
|
135
|
+
if (!autoPlay || reduced) return;
|
|
136
|
+
let cancelled = false;
|
|
137
|
+
el.muted = muted;
|
|
138
|
+
const attempt = el.play();
|
|
139
|
+
if (attempt && typeof attempt.catch === "function") {
|
|
140
|
+
attempt
|
|
141
|
+
.then(() => {
|
|
142
|
+
if (!cancelled) setBlocked(false);
|
|
143
|
+
})
|
|
144
|
+
.catch(() => {
|
|
145
|
+
if (!cancelled) setBlocked(true);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return () => {
|
|
149
|
+
cancelled = true;
|
|
150
|
+
};
|
|
151
|
+
}, [active, autoPlay, muted, reduced]);
|
|
152
|
+
|
|
153
|
+
const play = useCallback(() => {
|
|
154
|
+
const el = ref.current;
|
|
155
|
+
if (!el) return;
|
|
156
|
+
setActive(true);
|
|
157
|
+
el.muted = muted;
|
|
158
|
+
const attempt = el.play();
|
|
159
|
+
if (attempt && typeof attempt.catch === "function") {
|
|
160
|
+
attempt.then(() => setBlocked(false)).catch(() => setBlocked(true));
|
|
161
|
+
}
|
|
162
|
+
}, [muted]);
|
|
163
|
+
|
|
164
|
+
const showPlayButton = !controls && (blocked || (reduced && autoPlay));
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<>
|
|
168
|
+
<video
|
|
169
|
+
ref={ref}
|
|
170
|
+
className={className}
|
|
171
|
+
poster={resolveMediaSrc(poster) || undefined}
|
|
172
|
+
preload={active ? preload : "none"}
|
|
173
|
+
autoPlay={autoPlay && !reduced}
|
|
174
|
+
loop={loop}
|
|
175
|
+
muted={muted}
|
|
176
|
+
controls={controls}
|
|
177
|
+
playsInline
|
|
178
|
+
aria-label={ariaLabel}
|
|
179
|
+
>
|
|
180
|
+
{list.map((s) => (
|
|
181
|
+
<source key={s.src} src={s.src} type={s.type} />
|
|
182
|
+
))}
|
|
183
|
+
</video>
|
|
184
|
+
{showPlayButton ? (
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
onClick={play}
|
|
188
|
+
aria-label={t(
|
|
189
|
+
playLabel ?? { sv: ariaLabel ? `Spela upp ${ariaLabel}` : "Spela upp video" },
|
|
190
|
+
)}
|
|
191
|
+
className="absolute inset-0 z-10 grid place-items-center bg-transparent"
|
|
192
|
+
>
|
|
193
|
+
<span className="grid size-14 place-items-center rounded-full bg-white/85 text-black shadow-lg">
|
|
194
|
+
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true">
|
|
195
|
+
<path d="M8 5v14l11-7z" />
|
|
196
|
+
</svg>
|
|
197
|
+
</span>
|
|
198
|
+
</button>
|
|
199
|
+
) : null}
|
|
200
|
+
</>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
|
|
4
|
+
import { lookupMediaMeta, type MediaMeta, type MediaMetaMap } from "./media-meta";
|
|
5
|
+
|
|
6
|
+
const MediaMetaContext = createContext<MediaMetaMap>({});
|
|
7
|
+
|
|
8
|
+
/** Structural shape of a site's `queryOptions(...)` for its media metadata. */
|
|
9
|
+
export type MediaMetaQueryOptions = {
|
|
10
|
+
queryKey: readonly unknown[];
|
|
11
|
+
queryFn: () => Promise<MediaMetaMap>;
|
|
12
|
+
staleTime?: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function MediaMetaProvider({
|
|
16
|
+
queryOptions,
|
|
17
|
+
children,
|
|
18
|
+
}: {
|
|
19
|
+
queryOptions: MediaMetaQueryOptions;
|
|
20
|
+
children: ReactNode;
|
|
21
|
+
}) {
|
|
22
|
+
const { data } = useQuery({ ...queryOptions, placeholderData: {} as MediaMetaMap });
|
|
23
|
+
return <MediaMetaContext.Provider value={data ?? {}}>{children}</MediaMetaContext.Provider>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useMediaMetaMap(): MediaMetaMap {
|
|
27
|
+
return useContext(MediaMetaContext);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Saved metadata (alt / OG title / description / credit) for one image URL. */
|
|
31
|
+
export function useMediaMeta(src: string | undefined): MediaMeta {
|
|
32
|
+
return lookupMediaMeta(useMediaMetaMap(), src);
|
|
33
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
|
|
4
|
+
import { optimizeForUpload } from "./optimize";
|
|
5
|
+
import { pickVideoPoster } from "./pick-video-poster";
|
|
6
|
+
import { cleanFileName, splitExt, uniqueFileName } from "./filename";
|
|
7
|
+
|
|
8
|
+
export type UploadProgress = { label: string; percent: number } | null;
|
|
9
|
+
|
|
10
|
+
/** One file in a site's media library, as far as uploading is concerned. */
|
|
11
|
+
export type UploadExistingItem = { name: string };
|
|
12
|
+
|
|
13
|
+
/** The site's server call that stores an already-optimized bundle. */
|
|
14
|
+
export type UploadBundleFn = (input: {
|
|
15
|
+
data: {
|
|
16
|
+
files: Array<{ name: string; contentType: string; bytes: number[] }>;
|
|
17
|
+
};
|
|
18
|
+
}) => Promise<{ urls: string[] }>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Shared browser-side media upload (optimize → storage → cache invalidate)
|
|
22
|
+
* with a coarse percentage so callers can render a progress bar.
|
|
23
|
+
*
|
|
24
|
+
* The storage call itself is site infrastructure and is injected.
|
|
25
|
+
*/
|
|
26
|
+
export function useMediaUploadWith(
|
|
27
|
+
uploadBundle: UploadBundleFn,
|
|
28
|
+
existing: UploadExistingItem[] = [],
|
|
29
|
+
) {
|
|
30
|
+
const queryClient = useQueryClient();
|
|
31
|
+
const [progress, setProgress] = useState<UploadProgress>(null);
|
|
32
|
+
const [uploading, setUploading] = useState(false);
|
|
33
|
+
|
|
34
|
+
const upload = useCallback(
|
|
35
|
+
async (files: FileList | File[] | null): Promise<string[]> => {
|
|
36
|
+
const arr = files ? Array.from(files as ArrayLike<File>) : [];
|
|
37
|
+
if (!arr.length) return [];
|
|
38
|
+
setUploading(true);
|
|
39
|
+
const urls: string[] = [];
|
|
40
|
+
const taken = new Set<string>(
|
|
41
|
+
existing.flatMap((i) => {
|
|
42
|
+
const { base } = splitExt(i.name);
|
|
43
|
+
return [i.name, base, base.replace(/-(?:480|768|1200|1920)$/, "")];
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
try {
|
|
47
|
+
for (let idx = 0; idx < arr.length; idx++) {
|
|
48
|
+
const file = arr[idx]!;
|
|
49
|
+
const step = (frac: number, label: string) =>
|
|
50
|
+
setProgress({
|
|
51
|
+
label: `${label} ${idx + 1}/${arr.length}: ${file.name}`,
|
|
52
|
+
percent: Math.round(((idx + frac) / arr.length) * 100),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Videos: let the editor pick the poster frame before we transcode.
|
|
56
|
+
const posterTime = file.type.startsWith("video/") ? await pickVideoPoster(file) : null;
|
|
57
|
+
|
|
58
|
+
step(0.05, "Optimizing");
|
|
59
|
+
const { base } = splitExt(cleanFileName(file.name));
|
|
60
|
+
const baseSlug = uniqueFileName(base, taken);
|
|
61
|
+
taken.add(baseSlug);
|
|
62
|
+
const bundle = await optimizeForUpload(file, baseSlug, {
|
|
63
|
+
posterTime: posterTime ?? undefined,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
step(0.4, "Uploading");
|
|
67
|
+
const all = [bundle.primary, ...bundle.extras];
|
|
68
|
+
const payload = await Promise.all(
|
|
69
|
+
all.map(async (part) => ({
|
|
70
|
+
name: part.name,
|
|
71
|
+
contentType: part.contentType,
|
|
72
|
+
bytes: Array.from(new Uint8Array(await part.blob.arrayBuffer())),
|
|
73
|
+
})),
|
|
74
|
+
);
|
|
75
|
+
const { urls: uploaded } = await uploadBundle({ data: { files: payload } });
|
|
76
|
+
urls.push(uploaded[0]!);
|
|
77
|
+
step(1, "Uploading");
|
|
78
|
+
}
|
|
79
|
+
await queryClient.invalidateQueries({ queryKey: ["media-library"] });
|
|
80
|
+
return urls;
|
|
81
|
+
} finally {
|
|
82
|
+
setUploading(false);
|
|
83
|
+
setProgress(null);
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
[existing, queryClient, uploadBundle],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return { upload, uploading, progress };
|
|
90
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Captures the original Error out-of-band so the server entry can recover the
|
|
3
|
+
* stack when the HTTP layer has already swallowed the throw into a generic 500.
|
|
4
|
+
*
|
|
5
|
+
* Import this once from the server entry (side-effecting).
|
|
6
|
+
*/
|
|
7
|
+
let lastCapturedError: { error: unknown; at: number } | undefined;
|
|
8
|
+
const TTL_MS = 5_000;
|
|
9
|
+
|
|
10
|
+
function record(error: unknown) {
|
|
11
|
+
lastCapturedError = { error, at: Date.now() };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// h3's HTTPError serializes to {"status":500,"unhandled":true,"message":"HTTPError"} —
|
|
15
|
+
// no stack, no cause — so a plain console.error(error) reaches the log pipeline with
|
|
16
|
+
// the failure detail stripped. Expand Error-like args into a string that keeps the
|
|
17
|
+
// message, stack, and the full cause chain.
|
|
18
|
+
const CAUSE_DEPTH_LIMIT = 5;
|
|
19
|
+
const DESCRIPTION_LENGTH_LIMIT = 8_000;
|
|
20
|
+
|
|
21
|
+
export function describeError(error: unknown): string {
|
|
22
|
+
const parts: string[] = [];
|
|
23
|
+
let current: unknown = error;
|
|
24
|
+
for (let depth = 0; depth < CAUSE_DEPTH_LIMIT && current != null; depth++) {
|
|
25
|
+
if (!(current instanceof Error)) {
|
|
26
|
+
parts.push(typeof current === "string" ? current : safeStringify(current));
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
const label = depth === 0 ? "" : "caused by: ";
|
|
30
|
+
const status = describeStatus(current);
|
|
31
|
+
parts.push(`${label}${current.stack ?? `${current.name}: ${current.message}`}${status}`);
|
|
32
|
+
current = current.cause;
|
|
33
|
+
}
|
|
34
|
+
return parts.join("\n").slice(0, DESCRIPTION_LENGTH_LIMIT);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function describeStatus(error: Error): string {
|
|
38
|
+
const { status, statusCode } = error as { status?: unknown; statusCode?: unknown };
|
|
39
|
+
const value = status ?? statusCode;
|
|
40
|
+
return typeof value === "number" ? ` (status ${value})` : "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function safeStringify(value: unknown): string {
|
|
44
|
+
try {
|
|
45
|
+
return JSON.stringify(value) ?? String(value);
|
|
46
|
+
} catch {
|
|
47
|
+
return String(value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isErrorLike(value: unknown): value is Error {
|
|
52
|
+
return value instanceof Error;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Wrap console.error so errors logged by any layer — including the HTTP layer's
|
|
56
|
+
// internal unhandled-error logging, which cannot be hooked directly — are both
|
|
57
|
+
// recorded for consumeLastCapturedError and expanded before serialization.
|
|
58
|
+
const originalConsoleError = console.error.bind(console);
|
|
59
|
+
console.error = (...args: unknown[]) => {
|
|
60
|
+
const expanded = args.map((arg) => {
|
|
61
|
+
if (!isErrorLike(arg)) return arg;
|
|
62
|
+
record(arg);
|
|
63
|
+
return describeError(arg);
|
|
64
|
+
});
|
|
65
|
+
originalConsoleError(...expanded);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (typeof globalThis.addEventListener === "function") {
|
|
69
|
+
globalThis.addEventListener("error", (event) => record((event as ErrorEvent).error ?? event));
|
|
70
|
+
globalThis.addEventListener("unhandledrejection", (event) =>
|
|
71
|
+
record((event as PromiseRejectionEvent).reason),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function consumeLastCapturedError(): unknown {
|
|
76
|
+
if (!lastCapturedError) return undefined;
|
|
77
|
+
if (Date.now() - lastCapturedError.at > TTL_MS) {
|
|
78
|
+
lastCapturedError = undefined;
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const { error } = lastCapturedError;
|
|
82
|
+
lastCapturedError = undefined;
|
|
83
|
+
return error;
|
|
84
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Minimal, dependency-free HTML shown when SSR itself failed. */
|
|
2
|
+
export function renderErrorPage(): string {
|
|
3
|
+
return `<!doctype html>
|
|
4
|
+
<html lang="en">
|
|
5
|
+
<head>
|
|
6
|
+
<meta charset="utf-8" />
|
|
7
|
+
<title>This page didn't load</title>
|
|
8
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
9
|
+
<style>
|
|
10
|
+
body { font: 15px/1.5 system-ui, -apple-system, sans-serif; background: #fafafa; color: #111; display: grid; place-items: center; min-height: 100vh; margin: 0; padding: 1.5rem; }
|
|
11
|
+
.card { max-width: 28rem; width: 100%; text-align: center; padding: 2rem; }
|
|
12
|
+
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; }
|
|
13
|
+
p { color: #4b5563; margin: 0 0 1.5rem; }
|
|
14
|
+
.actions { display: flex; gap: 0.5rem; justify-content: center; flex-wrap: wrap; }
|
|
15
|
+
a, button { padding: 0.5rem 1rem; border-radius: 0.375rem; font: inherit; cursor: pointer; text-decoration: none; border: 1px solid transparent; }
|
|
16
|
+
.primary { background: #111; color: #fff; }
|
|
17
|
+
.secondary { background: #fff; color: #111; border-color: #d1d5db; }
|
|
18
|
+
</style>
|
|
19
|
+
</head>
|
|
20
|
+
<body>
|
|
21
|
+
<div class="card">
|
|
22
|
+
<h1>This page didn't load</h1>
|
|
23
|
+
<p>Something went wrong on our end. You can try refreshing or head back home.</p>
|
|
24
|
+
<div class="actions">
|
|
25
|
+
<button class="primary" onclick="location.reload()">Try again</button>
|
|
26
|
+
<a class="secondary" href="/">Go home</a>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
</body>
|
|
30
|
+
</html>`;
|
|
31
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forwards React error-boundary failures to the Lovable editor's telemetry
|
|
3
|
+
* hooks when present. A no-op outside the browser and outside the editor.
|
|
4
|
+
*/
|
|
5
|
+
type LovableErrorOptions = {
|
|
6
|
+
mechanism?: "manual" | "onerror" | "unhandledrejection" | "react_error_boundary";
|
|
7
|
+
handled?: boolean;
|
|
8
|
+
severity?: "error" | "warning" | "info";
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type LovableEvents = {
|
|
12
|
+
captureException?: (
|
|
13
|
+
error: unknown,
|
|
14
|
+
context?: Record<string, unknown>,
|
|
15
|
+
options?: LovableErrorOptions,
|
|
16
|
+
) => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
declare global {
|
|
20
|
+
interface Window {
|
|
21
|
+
__lovableEvents?: LovableEvents;
|
|
22
|
+
__lovableReportRuntimeError?: (payload: {
|
|
23
|
+
message: string;
|
|
24
|
+
stack?: string;
|
|
25
|
+
filename?: string;
|
|
26
|
+
}) => void;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function reportLovableError(error: unknown, context: Record<string, unknown> = {}) {
|
|
31
|
+
if (typeof window === "undefined") return;
|
|
32
|
+
window.__lovableEvents?.captureException?.(
|
|
33
|
+
error,
|
|
34
|
+
{
|
|
35
|
+
source: "react_error_boundary",
|
|
36
|
+
route: window.location.pathname,
|
|
37
|
+
...context,
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
mechanism: "react_error_boundary",
|
|
41
|
+
handled: false,
|
|
42
|
+
severity: "error",
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
// Prod React does not rethrow boundary-caught errors to window.onerror, so the
|
|
46
|
+
// editor's telemetry never sees them. Forward to lovable.js's reporting hook,
|
|
47
|
+
// which is present only inside the editor preview.
|
|
48
|
+
// Loaders and server fns commonly throw a raw Response; String(it) is the
|
|
49
|
+
// opaque "[object Response]", so pull out the status and URL instead.
|
|
50
|
+
const message =
|
|
51
|
+
error instanceof Response
|
|
52
|
+
? `Response ${error.status}${error.url ? ` at ${error.url}` : ""}`
|
|
53
|
+
: error instanceof Error
|
|
54
|
+
? error.message
|
|
55
|
+
: String(error);
|
|
56
|
+
window.__lovableReportRuntimeError?.({
|
|
57
|
+
message,
|
|
58
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
59
|
+
filename: window.location.pathname,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request authentication for protected server functions (site-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* Validates the Supabase bearer token on the incoming request and returns
|
|
5
|
+
* `{ supabase, userId, claims }`. The returned client acts as the signed-in
|
|
6
|
+
* user, so RLS applies normally.
|
|
7
|
+
*/
|
|
8
|
+
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
|
9
|
+
|
|
10
|
+
import { resolveSupabaseServerEnvironment } from "./env";
|
|
11
|
+
|
|
12
|
+
export type PlatformAuthContext<DB = unknown> = {
|
|
13
|
+
supabase: SupabaseClient<DB>;
|
|
14
|
+
userId: string;
|
|
15
|
+
claims: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the auth context from a request's `Authorization: Bearer` header.
|
|
20
|
+
* Throws on any missing/invalid token.
|
|
21
|
+
*/
|
|
22
|
+
export async function resolveAuthContextCore<DB = unknown>(
|
|
23
|
+
request: Request | undefined,
|
|
24
|
+
): Promise<PlatformAuthContext<DB>> {
|
|
25
|
+
if (!request?.headers) throw new Error("Unauthorized: No request headers available");
|
|
26
|
+
|
|
27
|
+
const { url, publishableKey: key } = resolveSupabaseServerEnvironment();
|
|
28
|
+
if (!url || !key) throw new Error("Missing Supabase environment variables.");
|
|
29
|
+
|
|
30
|
+
const header = request.headers.get("authorization");
|
|
31
|
+
if (!header?.startsWith("Bearer "))
|
|
32
|
+
throw new Error("Unauthorized: No authorization header provided");
|
|
33
|
+
const token = header.slice(7);
|
|
34
|
+
if (token.split(".").length !== 3) throw new Error("Unauthorized: Invalid token");
|
|
35
|
+
|
|
36
|
+
const supabase = createClient<DB>(url, key, {
|
|
37
|
+
global: {
|
|
38
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
39
|
+
// Opaque sb_ keys are not JWTs: send only `apikey`.
|
|
40
|
+
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
|
|
41
|
+
const h = new Headers(init?.headers);
|
|
42
|
+
if (key.startsWith("sb_") && h.get("Authorization") === `Bearer ${key}`)
|
|
43
|
+
h.delete("Authorization");
|
|
44
|
+
h.set("apikey", key);
|
|
45
|
+
return fetch(input, { ...init, headers: h });
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const { data, error } = await supabase.auth.getClaims(token);
|
|
52
|
+
if (error || !data?.claims?.sub) throw new Error("Unauthorized: Invalid token");
|
|
53
|
+
return {
|
|
54
|
+
supabase,
|
|
55
|
+
userId: data.claims.sub as string,
|
|
56
|
+
claims: data.claims as Record<string, unknown>,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side Supabase clients (site-agnostic).
|
|
3
|
+
*
|
|
4
|
+
* Server-only. Sites call `createBackendClients<Database>()` once and expose the
|
|
5
|
+
* result, so privilege level is explicit at every call site and both current
|
|
6
|
+
* and legacy server keys are supported.
|
|
7
|
+
*/
|
|
8
|
+
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
|
9
|
+
|
|
10
|
+
import { createServerFetch, resolveSupabaseServerEnvironment } from "./env";
|
|
11
|
+
|
|
12
|
+
export type BackendClients<DB> = {
|
|
13
|
+
/** Privileged client: bypasses RLS. Trusted server paths only. */
|
|
14
|
+
admin: SupabaseClient<DB>;
|
|
15
|
+
/** Async accessor for call sites using the dynamic-import pattern. */
|
|
16
|
+
adminClient: () => Promise<SupabaseClient<DB>>;
|
|
17
|
+
/** Anonymous/public client: RLS applies as the anon role. */
|
|
18
|
+
publicClient: () => Promise<SupabaseClient<DB>>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function createBackendClients<DB>(): BackendClients<DB> {
|
|
22
|
+
let resolved: SupabaseClient<DB> | undefined;
|
|
23
|
+
|
|
24
|
+
function build(): SupabaseClient<DB> {
|
|
25
|
+
const { url, secretKey } = resolveSupabaseServerEnvironment();
|
|
26
|
+
if (!url || !secretKey) {
|
|
27
|
+
const missing = [
|
|
28
|
+
...(!url ? ["SUPABASE_URL"] : []),
|
|
29
|
+
...(!secretKey
|
|
30
|
+
? ["SUPABASE_SECRET_KEYS, SUPABASE_SECRET_KEY, or SUPABASE_SERVICE_ROLE_KEY"]
|
|
31
|
+
: []),
|
|
32
|
+
];
|
|
33
|
+
throw new Error(`Missing Supabase server environment: ${missing.join("; ")}.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return createClient<DB>(url, secretKey, {
|
|
37
|
+
global: { fetch: createServerFetch(secretKey) },
|
|
38
|
+
auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Resolved on property access so nothing is initialised at module scope.
|
|
43
|
+
const admin = new Proxy({} as SupabaseClient<DB>, {
|
|
44
|
+
get(_t, prop, receiver) {
|
|
45
|
+
if (!resolved) resolved = build();
|
|
46
|
+
return Reflect.get(resolved as object, prop, receiver);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
admin,
|
|
52
|
+
adminClient: async () => admin,
|
|
53
|
+
publicClient: async () => {
|
|
54
|
+
const { url, publishableKey: key } = resolveSupabaseServerEnvironment();
|
|
55
|
+
if (!url || !key) {
|
|
56
|
+
throw new Error("Missing SUPABASE_URL or Supabase publishable server key.");
|
|
57
|
+
}
|
|
58
|
+
return createClient<DB>(url, key, {
|
|
59
|
+
auth: { storage: undefined, persistSession: false, autoRefreshToken: false },
|
|
60
|
+
global: { fetch: createServerFetch(key) },
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|