@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
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google service-account authentication.
|
|
3
|
+
*
|
|
4
|
+
* A service account is the only Google credential this platform accepts: the
|
|
5
|
+
* editor shares a private spreadsheet with the account's email address and
|
|
6
|
+
* nothing has to be published to the web. The key itself lives in the server
|
|
7
|
+
* environment exactly like every other provider credential (see
|
|
8
|
+
* `provider-secrets.ts`) — never in PostgreSQL, never in page content, never in
|
|
9
|
+
* anything sent to a browser.
|
|
10
|
+
*
|
|
11
|
+
* Signing is done with the platform's Web Crypto API, so there is no new
|
|
12
|
+
* dependency and nothing Google-specific to install.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Env } from "./provider-secrets";
|
|
16
|
+
|
|
17
|
+
/** Environment variables that hold the credential. */
|
|
18
|
+
export const GOOGLE_SA_EMAIL_ENV = "GOOGLE_SERVICE_ACCOUNT_EMAIL";
|
|
19
|
+
export const GOOGLE_SA_KEY_ENV = "GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY";
|
|
20
|
+
/** Optional single-variable form: the whole downloaded JSON key file. */
|
|
21
|
+
export const GOOGLE_SA_JSON_ENV = "GOOGLE_SERVICE_ACCOUNT_JSON";
|
|
22
|
+
|
|
23
|
+
export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
24
|
+
|
|
25
|
+
/** Read/write access to the spreadsheets the account has been shared on. */
|
|
26
|
+
export const SHEETS_SCOPE = "https://www.googleapis.com/auth/spreadsheets";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read-only access to file *metadata* — enough to ask Drive whether this
|
|
30
|
+
* account may edit a file, and not enough to read one byte of its contents.
|
|
31
|
+
*
|
|
32
|
+
* Only the connection test asks for it. Appends stay on {@link SHEETS_SCOPE}
|
|
33
|
+
* alone, so the credential that runs on every submission carries no Drive
|
|
34
|
+
* access at all.
|
|
35
|
+
*/
|
|
36
|
+
export const DRIVE_METADATA_SCOPE = "https://www.googleapis.com/auth/drive.metadata.readonly";
|
|
37
|
+
|
|
38
|
+
/** The two scopes the connection test asks for, in one token. */
|
|
39
|
+
export const SHEETS_TEST_SCOPE = `${SHEETS_SCOPE} ${DRIVE_METADATA_SCOPE}`;
|
|
40
|
+
|
|
41
|
+
export type GoogleCredentials = {
|
|
42
|
+
clientEmail: string;
|
|
43
|
+
/** PKCS#8 PEM. Never logged, never returned to a client. */
|
|
44
|
+
privateKey: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Why a Google call could not even be attempted. */
|
|
48
|
+
export type GoogleAuthErrorCode = "credentials-missing" | "credentials-invalid" | "api-unavailable";
|
|
49
|
+
|
|
50
|
+
export class GoogleAuthError extends Error {
|
|
51
|
+
readonly code: GoogleAuthErrorCode;
|
|
52
|
+
constructor(code: GoogleAuthErrorCode, message: string) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "GoogleAuthError";
|
|
55
|
+
this.code = code;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Accept both real newlines and the `\n` escapes used inside .env files. */
|
|
60
|
+
function normalizePrivateKey(raw: string): string {
|
|
61
|
+
return raw
|
|
62
|
+
.trim()
|
|
63
|
+
.replace(/^"(.*)"$/s, "$1")
|
|
64
|
+
.replace(/^'(.*)'$/s, "$1")
|
|
65
|
+
.replace(/\\r/g, "")
|
|
66
|
+
.replace(/\\n/g, "\n")
|
|
67
|
+
.trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function fromJsonKeyFile(raw: string | undefined): GoogleCredentials | null {
|
|
71
|
+
if (!raw?.trim()) return null;
|
|
72
|
+
let text = raw.trim();
|
|
73
|
+
// Some deployments paste the key file base64-encoded to dodge newline rules.
|
|
74
|
+
if (!text.startsWith("{")) {
|
|
75
|
+
try {
|
|
76
|
+
text = atob(text);
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const parsed = JSON.parse(text) as Record<string, unknown>;
|
|
83
|
+
const email = typeof parsed["client_email"] === "string" ? parsed["client_email"] : "";
|
|
84
|
+
const key = typeof parsed["private_key"] === "string" ? parsed["private_key"] : "";
|
|
85
|
+
if (!email || !key) return null;
|
|
86
|
+
return { clientEmail: email.trim(), privateKey: normalizePrivateKey(key) };
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the configured service account, or `null` when the server has none.
|
|
94
|
+
*
|
|
95
|
+
* The JSON key file wins when both forms are present: it is the artefact Google
|
|
96
|
+
* hands out, so a deployment that pastes it verbatim should not be silently
|
|
97
|
+
* overridden by a half-filled pair of variables.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveGoogleCredentials(env: Env = process.env): GoogleCredentials | null {
|
|
100
|
+
const fromJson = fromJsonKeyFile(env[GOOGLE_SA_JSON_ENV]);
|
|
101
|
+
if (fromJson) return fromJson;
|
|
102
|
+
|
|
103
|
+
const email = env[GOOGLE_SA_EMAIL_ENV]?.trim();
|
|
104
|
+
const key = env[GOOGLE_SA_KEY_ENV];
|
|
105
|
+
if (!email || !key?.trim()) return null;
|
|
106
|
+
return { clientEmail: email, privateKey: normalizePrivateKey(key) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The address an editor has to share a spreadsheet with. Safe to show in the
|
|
111
|
+
* backoffice and to put in an error message — it is an identifier, not a
|
|
112
|
+
* secret, and it is the one thing an editor cannot guess.
|
|
113
|
+
*/
|
|
114
|
+
export function googleServiceAccountEmail(env: Env = process.env): string | null {
|
|
115
|
+
return resolveGoogleCredentials(env)?.clientEmail ?? null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** True when the server can talk to Google at all. */
|
|
119
|
+
export function hasGoogleServiceAccount(env: Env = process.env): boolean {
|
|
120
|
+
return resolveGoogleCredentials(env) !== null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function base64Url(bytes: ArrayBuffer | Uint8Array): string {
|
|
124
|
+
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
|
125
|
+
let binary = "";
|
|
126
|
+
for (const byte of view) binary += String.fromCharCode(byte);
|
|
127
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function base64UrlText(value: string): string {
|
|
131
|
+
return base64Url(new TextEncoder().encode(value));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** PEM → DER. Throws `credentials-invalid` rather than leaking the key text. */
|
|
135
|
+
function pemToDer(pem: string): Uint8Array {
|
|
136
|
+
const match = /-----BEGIN (?:RSA )?PRIVATE KEY-----([\s\S]+?)-----END (?:RSA )?PRIVATE KEY-----/.exec(
|
|
137
|
+
pem,
|
|
138
|
+
);
|
|
139
|
+
if (!match) {
|
|
140
|
+
throw new GoogleAuthError(
|
|
141
|
+
"credentials-invalid",
|
|
142
|
+
`${GOOGLE_SA_KEY_ENV} is not a PEM private key. Paste the "private_key" value from the service account's JSON key file, newlines included.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const body = match[1]!.replace(/\s+/g, "");
|
|
146
|
+
let binary: string;
|
|
147
|
+
try {
|
|
148
|
+
binary = atob(body);
|
|
149
|
+
} catch {
|
|
150
|
+
throw new GoogleAuthError("credentials-invalid", `${GOOGLE_SA_KEY_ENV} is not valid base64.`);
|
|
151
|
+
}
|
|
152
|
+
const out = new Uint8Array(binary.length);
|
|
153
|
+
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function signJwt(credentials: GoogleCredentials, scope: string): Promise<string> {
|
|
158
|
+
const subtle = globalThis.crypto?.subtle;
|
|
159
|
+
if (!subtle) {
|
|
160
|
+
throw new GoogleAuthError(
|
|
161
|
+
"api-unavailable",
|
|
162
|
+
"This server has no Web Crypto implementation, so a Google request cannot be signed.",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const now = Math.floor(Date.now() / 1000);
|
|
166
|
+
const header = base64UrlText(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
167
|
+
const claim = base64UrlText(
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
iss: credentials.clientEmail,
|
|
170
|
+
scope,
|
|
171
|
+
aud: GOOGLE_TOKEN_URL,
|
|
172
|
+
iat: now,
|
|
173
|
+
exp: now + 3600,
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
const input = `${header}.${claim}`;
|
|
177
|
+
|
|
178
|
+
let key: CryptoKey;
|
|
179
|
+
try {
|
|
180
|
+
const der = pemToDer(credentials.privateKey);
|
|
181
|
+
key = await subtle.importKey(
|
|
182
|
+
"pkcs8",
|
|
183
|
+
der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength) as ArrayBuffer,
|
|
184
|
+
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
185
|
+
false,
|
|
186
|
+
["sign"],
|
|
187
|
+
);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
if (err instanceof GoogleAuthError) throw err;
|
|
190
|
+
throw new GoogleAuthError(
|
|
191
|
+
"credentials-invalid",
|
|
192
|
+
`${GOOGLE_SA_KEY_ENV} could not be read as an RSA private key. Copy it again from the service account's JSON key file.`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const signature = await subtle.sign(
|
|
197
|
+
"RSASSA-PKCS1-v1_5",
|
|
198
|
+
key,
|
|
199
|
+
new TextEncoder().encode(input) as unknown as ArrayBuffer,
|
|
200
|
+
);
|
|
201
|
+
return `${input}.${base64Url(signature)}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
type CachedToken = { token: string; expiresAt: number };
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Access tokens last an hour; minting one costs an RSA signature and a round
|
|
208
|
+
* trip, so a busy form does not pay for it on every submission.
|
|
209
|
+
*/
|
|
210
|
+
const tokenCache = new Map<string, CachedToken>();
|
|
211
|
+
|
|
212
|
+
/** Test seam — drop cached tokens so a credential change takes effect. */
|
|
213
|
+
export function clearGoogleTokenCache(): void {
|
|
214
|
+
tokenCache.clear();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export type GoogleFetch = typeof fetch;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* How long any single outbound Google call may take.
|
|
221
|
+
*
|
|
222
|
+
* A visitor submitting a form waits on this. Without a deadline, a connection
|
|
223
|
+
* Google accepts and then never answers holds the submission open until some
|
|
224
|
+
* proxy further out gives up — the visitor watches a spinner, and the server
|
|
225
|
+
* holds the socket. Ten seconds is well past a healthy Sheets append and well
|
|
226
|
+
* short of anybody's patience.
|
|
227
|
+
*/
|
|
228
|
+
export const GOOGLE_REQUEST_TIMEOUT_MS = 10_000;
|
|
229
|
+
|
|
230
|
+
/** A fresh deadline for one outbound Google request. */
|
|
231
|
+
export function googleRequestSignal(ms: number = GOOGLE_REQUEST_TIMEOUT_MS): AbortSignal {
|
|
232
|
+
return AbortSignal.timeout(ms);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Exchange the service account's signed JWT for an OAuth access token.
|
|
237
|
+
*
|
|
238
|
+
* Errors carry a code and a message safe to show an administrator: they name
|
|
239
|
+
* the environment variable or the HTTP status, never the key or the token.
|
|
240
|
+
*/
|
|
241
|
+
export async function googleAccessToken(
|
|
242
|
+
credentials: GoogleCredentials,
|
|
243
|
+
scope: string = SHEETS_SCOPE,
|
|
244
|
+
fetchImpl: GoogleFetch = fetch,
|
|
245
|
+
): Promise<string> {
|
|
246
|
+
const cacheKey = `${credentials.clientEmail}|${scope}`;
|
|
247
|
+
const cached = tokenCache.get(cacheKey);
|
|
248
|
+
// 60s of slack so a token cannot expire between here and the API call.
|
|
249
|
+
if (cached && cached.expiresAt - 60_000 > Date.now()) return cached.token;
|
|
250
|
+
|
|
251
|
+
const assertion = await signJwt(credentials, scope);
|
|
252
|
+
let res: Response;
|
|
253
|
+
try {
|
|
254
|
+
res = await fetchImpl(GOOGLE_TOKEN_URL, {
|
|
255
|
+
method: "POST",
|
|
256
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
257
|
+
body: new URLSearchParams({
|
|
258
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
259
|
+
assertion,
|
|
260
|
+
}).toString(),
|
|
261
|
+
signal: googleRequestSignal(),
|
|
262
|
+
});
|
|
263
|
+
} catch {
|
|
264
|
+
throw new GoogleAuthError(
|
|
265
|
+
"api-unavailable",
|
|
266
|
+
"Google could not be reached. Check the server's outbound network access and try again.",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!res.ok) {
|
|
271
|
+
const detail = await googleErrorDetail(res);
|
|
272
|
+
if (res.status >= 500) {
|
|
273
|
+
throw new GoogleAuthError(
|
|
274
|
+
"api-unavailable",
|
|
275
|
+
`Google's token service is unavailable (HTTP ${res.status}). Try again shortly.`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
throw new GoogleAuthError(
|
|
279
|
+
"credentials-invalid",
|
|
280
|
+
`Google rejected the service account credentials${detail ? ` (${detail})` : ""}. Check ${GOOGLE_SA_EMAIL_ENV} and ${GOOGLE_SA_KEY_ENV}.`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const body = (await res.json().catch(() => null)) as {
|
|
285
|
+
access_token?: unknown;
|
|
286
|
+
expires_in?: unknown;
|
|
287
|
+
} | null;
|
|
288
|
+
const token = typeof body?.access_token === "string" ? body.access_token : "";
|
|
289
|
+
if (!token) {
|
|
290
|
+
throw new GoogleAuthError(
|
|
291
|
+
"credentials-invalid",
|
|
292
|
+
"Google returned no access token for the service account.",
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const ttl = typeof body?.expires_in === "number" ? body.expires_in : 3600;
|
|
296
|
+
tokenCache.set(cacheKey, { token, expiresAt: Date.now() + ttl * 1000 });
|
|
297
|
+
return token;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Short, safe description of a Google error response.
|
|
302
|
+
*
|
|
303
|
+
* Google echoes the request in some errors, so only the documented status text
|
|
304
|
+
* is used and it is truncated — nothing here can grow into a credential dump.
|
|
305
|
+
*/
|
|
306
|
+
export async function googleErrorDetail(res: Response): Promise<string> {
|
|
307
|
+
const text = await res.text().catch(() => "");
|
|
308
|
+
if (!text) return "";
|
|
309
|
+
try {
|
|
310
|
+
const parsed = JSON.parse(text) as {
|
|
311
|
+
error?: unknown;
|
|
312
|
+
error_description?: unknown;
|
|
313
|
+
};
|
|
314
|
+
const err = parsed.error;
|
|
315
|
+
if (err && typeof err === "object" && "message" in err) {
|
|
316
|
+
const message = (err as { message?: unknown }).message;
|
|
317
|
+
if (typeof message === "string") return message.slice(0, 200);
|
|
318
|
+
}
|
|
319
|
+
if (typeof parsed.error_description === "string") return parsed.error_description.slice(0, 200);
|
|
320
|
+
if (typeof err === "string") return err.slice(0, 200);
|
|
321
|
+
} catch {
|
|
322
|
+
/* fall through to the truncated raw body */
|
|
323
|
+
}
|
|
324
|
+
return text.slice(0, 200);
|
|
325
|
+
}
|