@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,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Sheets delivery.
|
|
3
|
+
*
|
|
4
|
+
* The spreadsheet does not have to be public: a service account is granted
|
|
5
|
+
* access the same way a colleague would be, by sharing the sheet with its email
|
|
6
|
+
* address. Everything here runs on the server — the browser never sees a
|
|
7
|
+
* credential, and no Google key is needed in any client bundle.
|
|
8
|
+
*
|
|
9
|
+
* `testGoogleSheetConnection` verifies exactly what a real submission needs
|
|
10
|
+
* (credentials, spreadsheet, worksheet, write permission) using a metadata
|
|
11
|
+
* read, so testing never leaves a junk row behind.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
GOOGLE_SA_EMAIL_ENV,
|
|
16
|
+
GOOGLE_SA_KEY_ENV,
|
|
17
|
+
GoogleAuthError,
|
|
18
|
+
googleAccessToken,
|
|
19
|
+
googleErrorDetail,
|
|
20
|
+
googleRequestSignal,
|
|
21
|
+
googleServiceAccountEmail,
|
|
22
|
+
resolveGoogleCredentials,
|
|
23
|
+
SHEETS_SCOPE,
|
|
24
|
+
SHEETS_TEST_SCOPE,
|
|
25
|
+
type GoogleFetch,
|
|
26
|
+
} from "./google-service-account";
|
|
27
|
+
import type { Env } from "./provider-secrets";
|
|
28
|
+
|
|
29
|
+
export const SHEETS_API = "https://sheets.googleapis.com/v4/spreadsheets";
|
|
30
|
+
|
|
31
|
+
/** Default worksheet name Google gives a brand new spreadsheet. */
|
|
32
|
+
export const DEFAULT_WORKSHEET = "Sheet1";
|
|
33
|
+
|
|
34
|
+
/** Every distinct way a Sheets call can fail, so the admin UI can be specific. */
|
|
35
|
+
export type SheetsErrorCode =
|
|
36
|
+
| "credentials-missing"
|
|
37
|
+
| "credentials-invalid"
|
|
38
|
+
| "invalid-spreadsheet-id"
|
|
39
|
+
| "spreadsheet-not-found"
|
|
40
|
+
| "permission-denied"
|
|
41
|
+
| "worksheet-not-found"
|
|
42
|
+
| "write-unconfirmed"
|
|
43
|
+
| "api-unavailable";
|
|
44
|
+
|
|
45
|
+
export type SheetsFailure = {
|
|
46
|
+
ok: false;
|
|
47
|
+
code: SheetsErrorCode;
|
|
48
|
+
/** Safe to show an administrator: never contains a key, token or row data. */
|
|
49
|
+
error: string;
|
|
50
|
+
/** The address to share the sheet with, when the server has one. */
|
|
51
|
+
serviceAccountEmail: string | null;
|
|
52
|
+
/** Worksheet names that do exist, when the tab was the problem. */
|
|
53
|
+
worksheets?: string[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type SheetsTestSuccess = {
|
|
57
|
+
ok: true;
|
|
58
|
+
serviceAccountEmail: string;
|
|
59
|
+
spreadsheetTitle: string;
|
|
60
|
+
worksheetTitle: string;
|
|
61
|
+
worksheets: string[];
|
|
62
|
+
/**
|
|
63
|
+
* Always `true` here. A connection is only called successful once Drive has
|
|
64
|
+
* confirmed the account may edit the file — write access that could not be
|
|
65
|
+
* checked is {@link SheetsTestUnconfirmed}, not a success with a caveat.
|
|
66
|
+
*/
|
|
67
|
+
canWrite: true;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The spreadsheet and tab are right, and write access could not be verified.
|
|
72
|
+
*
|
|
73
|
+
* Its own outcome rather than a footnote on a success, because the difference
|
|
74
|
+
* matters to the person reading the screen: "this will work" and "this may
|
|
75
|
+
* fail on the first submission" are not the same sentence, and the second one
|
|
76
|
+
* printed under a green "Connection works." is the one nobody acts on. `ok` is
|
|
77
|
+
* false so no caller can mistake it for a guarantee; the spreadsheet details
|
|
78
|
+
* still come back so the screen stays useful.
|
|
79
|
+
*/
|
|
80
|
+
export type SheetsTestUnconfirmed = {
|
|
81
|
+
ok: false;
|
|
82
|
+
code: "write-unconfirmed";
|
|
83
|
+
error: string;
|
|
84
|
+
serviceAccountEmail: string;
|
|
85
|
+
spreadsheetTitle: string;
|
|
86
|
+
worksheetTitle: string;
|
|
87
|
+
worksheets: string[];
|
|
88
|
+
canWrite: null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export type SheetsTestResult = SheetsTestSuccess | SheetsTestUnconfirmed | SheetsFailure;
|
|
92
|
+
export type SheetsAppendResult = { ok: true } | SheetsFailure;
|
|
93
|
+
|
|
94
|
+
/** The part of Google's append response that says what was actually written. */
|
|
95
|
+
type AppendResponse = { updates?: { updatedRows?: unknown } };
|
|
96
|
+
|
|
97
|
+
export type SheetTarget = {
|
|
98
|
+
/** Spreadsheet ID or a full Google Sheets URL. */
|
|
99
|
+
spreadsheetId: string;
|
|
100
|
+
/** Worksheet (tab) name. Empty means `Sheet1`. */
|
|
101
|
+
tab?: string | null;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Google's own ID shape: base64url-ish, always well over 20 characters. */
|
|
105
|
+
const ID_RE = /^[A-Za-z0-9_-]{20,}$/;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Accept either a bare ID or a pasted URL — editors copy the address bar far
|
|
109
|
+
* more often than they dig the ID out of it.
|
|
110
|
+
*/
|
|
111
|
+
export function normalizeSpreadsheetId(input: string | null | undefined): string {
|
|
112
|
+
const raw = (input ?? "").trim();
|
|
113
|
+
if (!raw) return "";
|
|
114
|
+
const fromUrl = /\/spreadsheets\/d\/([A-Za-z0-9_-]+)/.exec(raw);
|
|
115
|
+
const candidate = fromUrl ? fromUrl[1]! : raw;
|
|
116
|
+
return ID_RE.test(candidate) ? candidate : "";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Worksheet name to use, with Google's default for an empty setting. */
|
|
120
|
+
export function normalizeWorksheet(tab: string | null | undefined): string {
|
|
121
|
+
return (tab ?? "").trim() || DEFAULT_WORKSHEET;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A1 range covering a whole worksheet.
|
|
126
|
+
*
|
|
127
|
+
* The name is always single-quoted, with internal apostrophes doubled. Google
|
|
128
|
+
* requires quoting for names containing spaces or anything that could read as a
|
|
129
|
+
* cell reference, and quoting a plain name is harmless — so quote every time
|
|
130
|
+
* rather than trying to decide which names need it. Google's own default tab,
|
|
131
|
+
* "Form Responses 1", contains spaces.
|
|
132
|
+
*/
|
|
133
|
+
export function sheetRange(tab: string): string {
|
|
134
|
+
return `'${tab.replace(/'/g, "''")}'!A:Z`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function credentialsMissing(): SheetsFailure {
|
|
138
|
+
return {
|
|
139
|
+
ok: false,
|
|
140
|
+
code: "credentials-missing",
|
|
141
|
+
error: `No Google service account is configured on this server. Set ${GOOGLE_SA_EMAIL_ENV} and ${GOOGLE_SA_KEY_ENV}, then restart.`,
|
|
142
|
+
serviceAccountEmail: null,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function fromAuthError(err: GoogleAuthError, email: string | null): SheetsFailure {
|
|
147
|
+
return { ok: false, code: err.code, error: err.message, serviceAccountEmail: email };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Turn one Google HTTP response into a classified failure.
|
|
152
|
+
*
|
|
153
|
+
* `detail` comes from {@link googleErrorDetail}, which only ever returns
|
|
154
|
+
* Google's own truncated status text.
|
|
155
|
+
*/
|
|
156
|
+
async function classify(res: Response, email: string | null): Promise<SheetsFailure> {
|
|
157
|
+
const detail = await googleErrorDetail(res);
|
|
158
|
+
if (res.status === 401) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
code: "credentials-invalid",
|
|
162
|
+
error: `Google rejected the service account credentials${detail ? ` (${detail})` : ""}.`,
|
|
163
|
+
serviceAccountEmail: email,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
if (res.status === 403) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
code: "permission-denied",
|
|
170
|
+
error: email
|
|
171
|
+
? `The service account cannot open this spreadsheet. Share it with ${email} and give it Editor access.`
|
|
172
|
+
: "The service account cannot open this spreadsheet. Share it with the service account and give it Editor access.",
|
|
173
|
+
serviceAccountEmail: email,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
if (res.status === 404) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
code: "spreadsheet-not-found",
|
|
180
|
+
error: "No spreadsheet with that ID exists. Check the Spreadsheet ID.",
|
|
181
|
+
serviceAccountEmail: email,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (res.status === 400 && /unable to parse range|not found/i.test(detail)) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
code: "worksheet-not-found",
|
|
188
|
+
error: "That worksheet (tab) does not exist in the spreadsheet.",
|
|
189
|
+
serviceAccountEmail: email,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (res.status >= 500) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
code: "api-unavailable",
|
|
196
|
+
error: `Google Sheets is unavailable right now (HTTP ${res.status}). Try again shortly.`,
|
|
197
|
+
serviceAccountEmail: email,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
code: "api-unavailable",
|
|
203
|
+
error: `Google Sheets returned HTTP ${res.status}${detail ? `: ${detail}` : ""}.`,
|
|
204
|
+
serviceAccountEmail: email,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
type Metadata = {
|
|
209
|
+
properties?: { title?: string };
|
|
210
|
+
sheets?: { properties?: { title?: string } }[];
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Verify a form's Sheets configuration end to end without writing anything.
|
|
215
|
+
*
|
|
216
|
+
* Three outcomes, deliberately: the sheet is reachable and writable, the sheet
|
|
217
|
+
* is reachable and write access could not be established, or something is
|
|
218
|
+
* wrong. Only the first is `ok`. A test that cannot see the difference between
|
|
219
|
+
* the first two is worth very little, since the whole point of running it is
|
|
220
|
+
* to find out whether submissions will land.
|
|
221
|
+
*/
|
|
222
|
+
export async function testGoogleSheetConnection(
|
|
223
|
+
target: SheetTarget,
|
|
224
|
+
env: Env = process.env,
|
|
225
|
+
fetchImpl: GoogleFetch = fetch,
|
|
226
|
+
): Promise<SheetsTestResult> {
|
|
227
|
+
const credentials = resolveGoogleCredentials(env);
|
|
228
|
+
if (!credentials) return credentialsMissing();
|
|
229
|
+
const email = credentials.clientEmail;
|
|
230
|
+
|
|
231
|
+
const spreadsheetId = normalizeSpreadsheetId(target.spreadsheetId);
|
|
232
|
+
if (!spreadsheetId) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
code: "invalid-spreadsheet-id",
|
|
236
|
+
error: (target.spreadsheetId ?? "").trim()
|
|
237
|
+
? "That is not a Google Spreadsheet ID. Paste the long ID from the sheet's URL, between /d/ and /edit."
|
|
238
|
+
: "Enter the Spreadsheet ID first.",
|
|
239
|
+
serviceAccountEmail: email,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const tab = normalizeWorksheet(target.tab);
|
|
243
|
+
|
|
244
|
+
/*
|
|
245
|
+
* Two tokens, in order of preference. The Drive metadata scope is what makes
|
|
246
|
+
* the write probe answer definitively, so the test asks for it — but a
|
|
247
|
+
* project that has not granted it must still be able to check its
|
|
248
|
+
* spreadsheet id and tab name, so a refusal falls back to the Sheets scope
|
|
249
|
+
* alone and the probe simply comes back unknown.
|
|
250
|
+
*/
|
|
251
|
+
let token: string;
|
|
252
|
+
let probeToken: string | null = null;
|
|
253
|
+
try {
|
|
254
|
+
probeToken = await googleAccessToken(credentials, SHEETS_TEST_SCOPE, fetchImpl);
|
|
255
|
+
token = probeToken;
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (!(err instanceof GoogleAuthError)) throw err;
|
|
258
|
+
try {
|
|
259
|
+
token = await googleAccessToken(credentials, SHEETS_SCOPE, fetchImpl);
|
|
260
|
+
} catch (fallbackErr) {
|
|
261
|
+
if (fallbackErr instanceof GoogleAuthError) return fromAuthError(fallbackErr, email);
|
|
262
|
+
throw fallbackErr;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const url = `${SHEETS_API}/${encodeURIComponent(spreadsheetId)}?fields=properties.title,sheets.properties.title`;
|
|
267
|
+
let res: Response;
|
|
268
|
+
try {
|
|
269
|
+
res = await fetchImpl(url, {
|
|
270
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
271
|
+
signal: googleRequestSignal(),
|
|
272
|
+
});
|
|
273
|
+
} catch {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
code: "api-unavailable",
|
|
277
|
+
error: "Google could not be reached. Check the server's outbound network access.",
|
|
278
|
+
serviceAccountEmail: email,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
if (!res.ok) return classify(res, email);
|
|
282
|
+
|
|
283
|
+
const meta = (await res.json().catch(() => null)) as Metadata | null;
|
|
284
|
+
const worksheets = (meta?.sheets ?? [])
|
|
285
|
+
.map((s) => s.properties?.title)
|
|
286
|
+
.filter((t): t is string => typeof t === "string" && t.length > 0);
|
|
287
|
+
|
|
288
|
+
const match = worksheets.find((t) => t === tab) ?? worksheets.find((t) => t.toLowerCase() === tab.toLowerCase());
|
|
289
|
+
if (!match) {
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
code: "worksheet-not-found",
|
|
293
|
+
error: worksheets.length
|
|
294
|
+
? `The spreadsheet has no tab named "${tab}". It has: ${worksheets.join(", ")}.`
|
|
295
|
+
: `The spreadsheet has no tab named "${tab}".`,
|
|
296
|
+
serviceAccountEmail: email,
|
|
297
|
+
worksheets,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// A read alone cannot prove the account may append, so ask Google what this
|
|
302
|
+
// caller is allowed to do. Drive's capabilities are authoritative here.
|
|
303
|
+
const canWrite = probeToken
|
|
304
|
+
? await probeWriteAccess(spreadsheetId, probeToken, fetchImpl)
|
|
305
|
+
: null;
|
|
306
|
+
const spreadsheetTitle = meta?.properties?.title ?? "Untitled spreadsheet";
|
|
307
|
+
if (canWrite === false) {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
code: "permission-denied",
|
|
311
|
+
error: `${email} can read this spreadsheet but not write to it. Change its access to Editor.`,
|
|
312
|
+
serviceAccountEmail: email,
|
|
313
|
+
worksheets,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (canWrite !== true) {
|
|
318
|
+
return {
|
|
319
|
+
ok: false,
|
|
320
|
+
code: "write-unconfirmed",
|
|
321
|
+
error: `Found “${spreadsheetTitle}” and its “${match}” tab, but Google would not say whether ${email} may write to it.`,
|
|
322
|
+
serviceAccountEmail: email,
|
|
323
|
+
spreadsheetTitle,
|
|
324
|
+
worksheetTitle: match,
|
|
325
|
+
worksheets,
|
|
326
|
+
canWrite: null,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
ok: true,
|
|
332
|
+
serviceAccountEmail: email,
|
|
333
|
+
spreadsheetTitle,
|
|
334
|
+
worksheetTitle: match,
|
|
335
|
+
worksheets,
|
|
336
|
+
canWrite,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Ask Drive whether this caller may edit the file.
|
|
342
|
+
*
|
|
343
|
+
* Returns `null` when Drive cannot answer (its API may not be enabled on the
|
|
344
|
+
* project), which is treated as "unknown" rather than as a failure — the append
|
|
345
|
+
* itself still reports a real permission error if there is one.
|
|
346
|
+
*/
|
|
347
|
+
async function probeWriteAccess(
|
|
348
|
+
spreadsheetId: string,
|
|
349
|
+
/** A token carrying the Drive metadata scope; without it Drive says 403. */
|
|
350
|
+
token: string,
|
|
351
|
+
fetchImpl: GoogleFetch,
|
|
352
|
+
): Promise<boolean | null> {
|
|
353
|
+
try {
|
|
354
|
+
const res = await fetchImpl(
|
|
355
|
+
`https://www.googleapis.com/drive/v3/files/${encodeURIComponent(spreadsheetId)}?fields=capabilities/canEdit&supportsAllDrives=true`,
|
|
356
|
+
{ headers: { Authorization: `Bearer ${token}` }, signal: googleRequestSignal() },
|
|
357
|
+
);
|
|
358
|
+
if (!res.ok) return null;
|
|
359
|
+
const body = (await res.json().catch(() => null)) as {
|
|
360
|
+
capabilities?: { canEdit?: unknown };
|
|
361
|
+
} | null;
|
|
362
|
+
const canEdit = body?.capabilities?.canEdit;
|
|
363
|
+
return typeof canEdit === "boolean" ? canEdit : null;
|
|
364
|
+
} catch {
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Append one row. Uses the same credential resolution and the same worksheet
|
|
371
|
+
* rules as the test, so a passing test is a real guarantee.
|
|
372
|
+
*
|
|
373
|
+
* Never throws: the submission itself is already stored, and a Sheets outage
|
|
374
|
+
* must not turn into a lost enquiry.
|
|
375
|
+
*/
|
|
376
|
+
export async function appendGoogleSheetRow(
|
|
377
|
+
target: SheetTarget & { values: (string | number)[] },
|
|
378
|
+
env: Env = process.env,
|
|
379
|
+
fetchImpl: GoogleFetch = fetch,
|
|
380
|
+
): Promise<SheetsAppendResult> {
|
|
381
|
+
const credentials = resolveGoogleCredentials(env);
|
|
382
|
+
if (!credentials) return credentialsMissing();
|
|
383
|
+
const email = credentials.clientEmail;
|
|
384
|
+
|
|
385
|
+
const spreadsheetId = normalizeSpreadsheetId(target.spreadsheetId);
|
|
386
|
+
if (!spreadsheetId) {
|
|
387
|
+
return {
|
|
388
|
+
ok: false,
|
|
389
|
+
code: "invalid-spreadsheet-id",
|
|
390
|
+
error: "The form's Google Spreadsheet ID is not a valid spreadsheet ID.",
|
|
391
|
+
serviceAccountEmail: email,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
const tab = normalizeWorksheet(target.tab);
|
|
395
|
+
|
|
396
|
+
let token: string;
|
|
397
|
+
try {
|
|
398
|
+
token = await googleAccessToken(credentials, SHEETS_SCOPE, fetchImpl);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
if (err instanceof GoogleAuthError) return fromAuthError(err, email);
|
|
401
|
+
throw err;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const url =
|
|
405
|
+
`${SHEETS_API}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(sheetRange(tab))}` +
|
|
406
|
+
// RAW, never USER_ENTERED: these are visitor-supplied values. USER_ENTERED
|
|
407
|
+
// parses them as though a person had typed them into the grid, so a message
|
|
408
|
+
// beginning with "=", "+", "-" or "@" would be stored as a live formula.
|
|
409
|
+
`:append?valueInputOption=RAW&insertDataOption=INSERT_ROWS`;
|
|
410
|
+
|
|
411
|
+
let res: Response;
|
|
412
|
+
try {
|
|
413
|
+
res = await fetchImpl(url, {
|
|
414
|
+
method: "POST",
|
|
415
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
416
|
+
body: JSON.stringify({ values: [target.values] }),
|
|
417
|
+
signal: googleRequestSignal(),
|
|
418
|
+
});
|
|
419
|
+
} catch {
|
|
420
|
+
return {
|
|
421
|
+
ok: false,
|
|
422
|
+
code: "api-unavailable",
|
|
423
|
+
error: "Google could not be reached in time while appending the row.",
|
|
424
|
+
serviceAccountEmail: email,
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
if (!res.ok) return classify(res, email);
|
|
428
|
+
|
|
429
|
+
/*
|
|
430
|
+
* A 2xx is not delivery. Google answers an append with what it actually
|
|
431
|
+
* wrote, and reporting `ok` without reading that turns a row that never
|
|
432
|
+
* landed into a submission the admin screen calls delivered — the one state
|
|
433
|
+
* nobody goes back and checks. Anything other than a confirmed row is a
|
|
434
|
+
* failed delivery, which the caller already knows how to retry.
|
|
435
|
+
*/
|
|
436
|
+
const body = (await res.json().catch(() => null)) as AppendResponse | null;
|
|
437
|
+
const rows = body?.updates?.updatedRows;
|
|
438
|
+
if (typeof rows !== "number" || rows < 1) {
|
|
439
|
+
return {
|
|
440
|
+
ok: false,
|
|
441
|
+
code: "api-unavailable",
|
|
442
|
+
error: "Google accepted the request but did not confirm the row was written.",
|
|
443
|
+
serviceAccountEmail: email,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
return { ok: true };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Whether this server can deliver to Sheets at all, for the admin UI. */
|
|
450
|
+
export function googleSheetsStatus(env: Env = process.env): {
|
|
451
|
+
configured: boolean;
|
|
452
|
+
serviceAccountEmail: string | null;
|
|
453
|
+
emailVariable: string;
|
|
454
|
+
keyVariable: string;
|
|
455
|
+
} {
|
|
456
|
+
const serviceAccountEmail = googleServiceAccountEmail(env);
|
|
457
|
+
return {
|
|
458
|
+
configured: serviceAccountEmail !== null,
|
|
459
|
+
serviceAccountEmail,
|
|
460
|
+
emailVariable: GOOGLE_SA_EMAIL_ENV,
|
|
461
|
+
keyVariable: GOOGLE_SA_KEY_ENV,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* `sitemapIndexXml` builds the index (one sitemap per configured domain);
|
|
5
5
|
* `sitemapUrlsetXml` builds a per-locale urlset with hreflang alternates.
|
|
6
6
|
*/
|
|
7
|
+
import { feedCachePolicy } from "../../http/cache-policy";
|
|
8
|
+
|
|
7
9
|
export type SitemapIndexEntry = { url: string };
|
|
8
10
|
|
|
9
11
|
export type SitemapUrlEntry = {
|
|
@@ -14,10 +16,25 @@ export type SitemapUrlEntry = {
|
|
|
14
16
|
priority?: string;
|
|
15
17
|
};
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* How long this sitemap may be cached.
|
|
21
|
+
*
|
|
22
|
+
* A sitemap is a list of pages a visitor can reach, and that list changes by
|
|
23
|
+
* itself when a publication window opens or closes. `revalidateAt` — the next
|
|
24
|
+
* such instant, which `listPublishedRoutesCore` returns beside the keys — is
|
|
25
|
+
* the ceiling: without it a flat hour would advertise a page that expired at
|
|
26
|
+
* noon until one, and hide one that went live at noon just as long. Omitting it
|
|
27
|
+
* means the caller does not know, and nothing is cached beyond a revalidation.
|
|
28
|
+
*/
|
|
29
|
+
export type SitemapCacheOptions = { revalidateAt?: number | null; now?: number };
|
|
30
|
+
|
|
31
|
+
function xmlHeaders(options: SitemapCacheOptions = {}): Record<string, string> {
|
|
32
|
+
const policy = feedCachePolicy(
|
|
33
|
+
options.revalidateAt === undefined ? 0 : options.revalidateAt,
|
|
34
|
+
options.now,
|
|
35
|
+
);
|
|
36
|
+
return { "Content-Type": "application/xml", "Cache-Control": policy.cacheControl };
|
|
37
|
+
}
|
|
21
38
|
|
|
22
39
|
export function sitemapIndexXml(entries: SitemapIndexEntry[]): string {
|
|
23
40
|
const sitemaps = entries.map((e) => ` <sitemap>\n <loc>${e.url}</loc>\n </sitemap>`);
|
|
@@ -29,8 +46,11 @@ export function sitemapIndexXml(entries: SitemapIndexEntry[]): string {
|
|
|
29
46
|
].join("\n");
|
|
30
47
|
}
|
|
31
48
|
|
|
32
|
-
export function sitemapIndexResponse(
|
|
33
|
-
|
|
49
|
+
export function sitemapIndexResponse(
|
|
50
|
+
entries: SitemapIndexEntry[],
|
|
51
|
+
cache: SitemapCacheOptions = {},
|
|
52
|
+
): Response {
|
|
53
|
+
return new Response(sitemapIndexXml(entries), { headers: xmlHeaders(cache) });
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
export function sitemapUrlsetXml(entries: SitemapUrlEntry[], homeKey = "home"): string {
|
|
@@ -58,6 +78,10 @@ export function sitemapUrlsetXml(entries: SitemapUrlEntry[], homeKey = "home"):
|
|
|
58
78
|
].join("\n");
|
|
59
79
|
}
|
|
60
80
|
|
|
61
|
-
export function sitemapUrlsetResponse(
|
|
62
|
-
|
|
81
|
+
export function sitemapUrlsetResponse(
|
|
82
|
+
entries: SitemapUrlEntry[],
|
|
83
|
+
homeKey = "home",
|
|
84
|
+
cache: SitemapCacheOptions = {},
|
|
85
|
+
): Response {
|
|
86
|
+
return new Response(sitemapUrlsetXml(entries, homeKey), { headers: xmlHeaders(cache) });
|
|
63
87
|
}
|