domma-cms 1.15.0 → 1.16.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/admin/css/admin.css +1 -1
  3. package/admin/js/lib/admin-brand.js +1 -1
  4. package/admin/js/lib/google-analytics-panel.js +9 -0
  5. package/admin/js/lib/google-business-callback.js +1 -0
  6. package/admin/js/lib/google-business-import.js +3 -0
  7. package/admin/js/lib/google-business-panel.js +32 -0
  8. package/admin/js/lib/seo-defaults.js +1 -0
  9. package/admin/js/lib/sidebar-renderer.js +5 -5
  10. package/admin/js/lib/site-search-section.js +1 -0
  11. package/admin/js/lib/site-settings-arrange.js +3 -3
  12. package/admin/js/lib/topbar-account.js +1 -1
  13. package/admin/js/lib/topbar-cache.js +6 -0
  14. package/admin/js/lib/topbar-notifications.js +4 -4
  15. package/admin/js/templates/docs/usage-site-settings.html +7 -5
  16. package/admin/js/templates/settings.html +8 -41
  17. package/admin/js/views/analytics.js +8 -6
  18. package/admin/js/views/search.js +2 -2
  19. package/admin/js/views/seo.js +50 -42
  20. package/admin/js/views/settings.js +3 -3
  21. package/docs/DommaFeatures.md +1 -1
  22. package/docs/google-business-profile.md +44 -0
  23. package/docs/screenshots/google-business-profile-review.png +0 -0
  24. package/docs/screenshots/notifications-cache.png +0 -0
  25. package/docs/screenshots/site-settings-search.png +0 -0
  26. package/package.json +1 -1
  27. package/public/js/site.js +1 -1
  28. package/server/routes/api/analytics.js +19 -0
  29. package/server/routes/api/google-business.js +64 -0
  30. package/server/routes/api/seo.js +14 -3
  31. package/server/services/adminBrand.js +1 -0
  32. package/server/services/googleBusiness.js +225 -0
  33. package/server/services/renderer.js +2 -0
  34. package/server/templates/page.html +1 -1
@@ -0,0 +1,225 @@
1
+ /** Google Business Profile: one site connection, encrypted outside authored content.
2
+ * Remote business data is fetched on demand. Applying a preview is an explicit SEO form edit.
3
+ */
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual} from 'node:crypto';
7
+
8
+ export const BUSINESS_SCOPE = 'https://www.googleapis.com/auth/business.manage';
9
+ const TOKEN_URL = 'https://oauth2.googleapis.com/token';
10
+ const ACCOUNT_URL = 'https://mybusinessaccountmanagement.googleapis.com/v1/accounts';
11
+ const LOCATION_URL = 'https://mybusinessbusinessinformation.googleapis.com/v1/';
12
+ const READ_MASK = 'name,title,storefrontAddress,phoneNumbers,websiteUri,metadata';
13
+ const TTL = 600_000;
14
+ const hash = value => createHash('sha256').update(String(value)).digest();
15
+ const text = (v, max = 300) => String(v ?? '').trim().slice(0, max);
16
+ const webAddress = v => /^https?:\/\/[^\s\\]+$/i.test(text(v, 500)) ? text(v, 500) : '';
17
+
18
+ export class GoogleBusinessError extends Error {
19
+ constructor(message, statusCode = 400) { super(message); this.statusCode = statusCode; }
20
+ }
21
+
22
+ /** Only fields supported by the SEO organisation form; absent Google fields never clear site data. */
23
+ export function businessPreview(location = {}) {
24
+ const a = location.storefrontAddress || {};
25
+ return {name: text(location.name), title: text(location.title),
26
+ organisation: {type: 'LocalBusiness', name: text(location.title), url: webAddress(location.websiteUri),
27
+ phone: text(location.phoneNumbers?.primaryPhone, 80),
28
+ sameAs: [webAddress(location.metadata?.mapsUri)].filter(Boolean),
29
+ address: {street: (Array.isArray(a.addressLines) ? a.addressLines : []).map(v => text(v, 200)).filter(Boolean).join(', ').slice(0, 200),
30
+ locality: text(a.locality, 200), region: text(a.administrativeArea, 200), postcode: text(a.postalCode, 200), country: text(a.regionCode, 200)}}};
31
+ }
32
+
33
+ export function createGoogleBusiness({directory = path.resolve('content/sessions/google-business'), fetch: fetcher = globalThis.fetch, now = Date.now} = {}) {
34
+ const file = path.join(directory, 'connection.json');
35
+ const keyFile = path.join(directory, '.key');
36
+ const pending = new Map();
37
+ let generation = 0;
38
+ let refreshing = null;
39
+
40
+ function key() {
41
+ fs.mkdirSync(directory, {recursive: true, mode: 0o700});
42
+ try { return fs.readFileSync(keyFile); }
43
+ catch (e) {
44
+ if (e.code !== 'ENOENT') throw e;
45
+ const value = randomBytes(32);
46
+ try { fs.writeFileSync(keyFile, value, {mode: 0o600, flag: 'wx'}); return value; }
47
+ catch (err) { if (err.code !== 'EEXIST') throw err; return fs.readFileSync(keyFile); }
48
+ }
49
+ }
50
+ function seal(value) {
51
+ const iv = randomBytes(12);
52
+ const cipher = createCipheriv('aes-256-gcm', key(), iv);
53
+ const bytes = Buffer.concat([cipher.update(JSON.stringify(value), 'utf8'), cipher.final()]);
54
+ return [iv, cipher.getAuthTag(), bytes].map(b => b.toString('base64url')).join('.');
55
+ }
56
+ function open(value) {
57
+ const [iv, tag, bytes] = value.split('.').map(b => Buffer.from(b, 'base64url'));
58
+ const cipher = createDecipheriv('aes-256-gcm', key(), iv);
59
+ cipher.setAuthTag(tag);
60
+ return JSON.parse(Buffer.concat([cipher.update(bytes), cipher.final()]).toString('utf8'));
61
+ }
62
+ function read() {
63
+ try { return open(JSON.parse(fs.readFileSync(file, 'utf8')).sealed); }
64
+ catch (e) {
65
+ if (e.code === 'ENOENT') return null;
66
+ throw new GoogleBusinessError('The saved Google connection could not be opened. Disconnect it and connect again.', 409);
67
+ }
68
+ }
69
+ function write(value) {
70
+ const temporary = `${file}.${randomBytes(6).toString('hex')}.tmp`;
71
+ try {
72
+ fs.writeFileSync(temporary, JSON.stringify({version: 1, sealed: seal(value)}) + '\n', {mode: 0o600});
73
+ fs.renameSync(temporary, file);
74
+ } finally { fs.rmSync(temporary, {force: true}); }
75
+ }
76
+ function status() {
77
+ let c;
78
+ try { c = read(); }
79
+ catch {
80
+ return {connected: false, needsReconnect: true, clientId: '', hasClientSecret: false, location: null};
81
+ }
82
+ return {connected: Boolean(c?.refreshToken), needsReconnect: c?.needsReconnect === true,
83
+ clientId: c?.clientId || '', hasClientSecret: Boolean(c?.clientSecret), location: c?.location || null};
84
+ }
85
+ function assertGeneration(expected) {
86
+ if (generation !== expected) throw new GoogleBusinessError('The Google connection changed. Open its settings again.', 409);
87
+ }
88
+ async function remote(url, options = {}) {
89
+ let response;
90
+ try { response = await fetcher(url, {...options, redirect: 'error', signal: AbortSignal.timeout(15_000)}); }
91
+ catch { throw new GoogleBusinessError('Google could not be reached. Try again.', 502); }
92
+ const body = await response.json().catch(() => ({}));
93
+ if (!response.ok) {
94
+ if (body.error === 'invalid_grant' || response.status === 401) throw new GoogleBusinessError('Google access has expired or was revoked. Connect again.', 401);
95
+ if (body.error === 'invalid_client') throw new GoogleBusinessError('Google rejected the client ID or secret. Check the app credentials.');
96
+ if (response.status === 403) throw new GoogleBusinessError('Google refused access. Check Business Profile API approval, enabled APIs and permission to manage this business.', 403);
97
+ if (response.status === 429) throw new GoogleBusinessError('Google Business Profile has reached its request limit. Try again later.', 429);
98
+ throw new GoogleBusinessError('Google could not complete the request. Check the selected business and app setup.', 502);
99
+ }
100
+ return body;
101
+ }
102
+ const tokenRequest = params => remote(TOKEN_URL, {method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams(params).toString()});
103
+ function begin({clientId, clientSecret, redirectUri, userId}) {
104
+ let previous = null;
105
+ try { previous = read(); } catch { /* new credentials can repair an unreadable connection */ }
106
+ clientId = text(clientId, 300);
107
+ clientSecret = text(clientSecret, 2000) || (previous?.clientId === clientId ? previous.clientSecret : '');
108
+ if (!clientId || !clientSecret) throw new GoogleBusinessError('Enter the Google app client ID and client secret first.');
109
+ const uri = new URL(redirectUri);
110
+ if (uri.protocol !== 'https:' && !(uri.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(uri.hostname))) {
111
+ throw new GoogleBusinessError('Google sign-in needs an HTTPS site address, or localhost for development.');
112
+ }
113
+ for (const [k, t] of pending) if (t.expires <= now() || t.userId === userId) pending.delete(k);
114
+ if (pending.size >= 200) throw new GoogleBusinessError('Too many sign-ins are pending. Try again in a few minutes.', 429);
115
+ const state = randomBytes(32).toString('base64url');
116
+ const binding = randomBytes(32).toString('base64url');
117
+ const verifier = randomBytes(32).toString('base64url');
118
+ pending.set(state, {userId, redirectUri, credentials: seal({clientId, clientSecret}), verifier, bindingHash: hash(binding), expires: now() + TTL, generation});
119
+ const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
120
+ url.search = new URLSearchParams({client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: BUSINESS_SCOPE,
121
+ state, access_type: 'offline', prompt: 'consent', code_challenge: hash(verifier).toString('base64url'), code_challenge_method: 'S256'}).toString();
122
+ return {url: url.href, state, binding};
123
+ }
124
+ function take(state, binding) {
125
+ const t = pending.get(state);
126
+ pending.delete(state);
127
+ if (!t || t.expires <= now()) throw new GoogleBusinessError('This sign-in expired or was already used. Press Connect again.');
128
+ if (!binding || !timingSafeEqual(hash(binding), t.bindingHash)) throw new GoogleBusinessError('Finish sign-in in the browser that pressed Connect.');
129
+ assertGeneration(t.generation);
130
+ return t;
131
+ }
132
+ async function complete(ticket, code) {
133
+ if (!text(code, 4000)) throw new GoogleBusinessError('Google did not return a sign-in code. Connect again.');
134
+ const credentials = open(ticket.credentials);
135
+ const t = await tokenRequest({client_id: credentials.clientId, client_secret: credentials.clientSecret,
136
+ grant_type: 'authorization_code', code: text(code, 4000), redirect_uri: ticket.redirectUri, code_verifier: ticket.verifier});
137
+ if (!t.access_token || !t.refresh_token || !String(t.scope || '').split(' ').includes(BUSINESS_SCOPE)) {
138
+ throw new GoogleBusinessError('Google did not grant Business Profile access for this connection. Connect again and allow access.');
139
+ }
140
+ assertGeneration(ticket.generation);
141
+ write({...credentials, accessToken: t.access_token, refreshToken: t.refresh_token,
142
+ expiresAt: now() + (Number(t.expires_in) || 3600) * 1000, needsReconnect: false, location: null});
143
+ generation++;
144
+ pending.clear();
145
+ refreshing = null;
146
+ return status();
147
+ }
148
+ async function accessToken() {
149
+ const c = read();
150
+ if (!c?.refreshToken || c.needsReconnect) throw new GoogleBusinessError('Connect Google Business Profile first.', 409);
151
+ if (c.expiresAt > now() + 60_000) return c.accessToken;
152
+ if (refreshing) return refreshing;
153
+ const expected = generation;
154
+ const task = (async () => {
155
+ try {
156
+ const t = await tokenRequest({client_id: c.clientId, client_secret: c.clientSecret, refresh_token: c.refreshToken, grant_type: 'refresh_token'});
157
+ assertGeneration(expected);
158
+ if (!t.access_token) throw new GoogleBusinessError('Google did not renew access. Connect again.', 502);
159
+ write({...read(), accessToken: t.access_token, refreshToken: t.refresh_token || c.refreshToken,
160
+ expiresAt: now() + (Number(t.expires_in) || 3600) * 1000});
161
+ return t.access_token;
162
+ } catch (e) {
163
+ if (e.statusCode === 401 && generation === expected) write({...read(), needsReconnect: true});
164
+ throw e;
165
+ }
166
+ })();
167
+ refreshing = task;
168
+ try { return await task; } finally { if (refreshing === task) refreshing = null; }
169
+ }
170
+ async function api(url) {
171
+ const expected = generation;
172
+ const token = await accessToken();
173
+ assertGeneration(expected);
174
+ try {
175
+ const result = await remote(url, {headers: {Authorization: `Bearer ${token}`}});
176
+ assertGeneration(expected);
177
+ return result;
178
+ } catch (e) {
179
+ if (e.statusCode === 401 && generation === expected) write({...read(), needsReconnect: true});
180
+ throw e;
181
+ }
182
+ }
183
+ function listUrl(base, pageToken, extra = {}) {
184
+ const url = new URL(base);
185
+ url.search = new URLSearchParams({...extra, ...(pageToken ? {pageToken: text(pageToken, 2000)} : {})}).toString();
186
+ return url.href;
187
+ }
188
+ async function accounts(pageToken) {
189
+ const r = await api(listUrl(ACCOUNT_URL, pageToken, {pageSize: '20'}));
190
+ return {accounts: (r.accounts || []).filter(a => /^accounts\/[A-Za-z0-9_-]+$/.test(a.name)).map(a => ({name: a.name, title: text(a.accountName) || a.name})), nextPageToken: r.nextPageToken || ''};
191
+ }
192
+ async function locations(account, pageToken) {
193
+ if (!/^accounts\/[A-Za-z0-9_-]+$/.test(account || '')) throw new GoogleBusinessError('Choose a business account.');
194
+ const r = await api(listUrl(`${LOCATION_URL}${account}/locations`, pageToken, {pageSize: '100', readMask: READ_MASK}));
195
+ return {locations: (r.locations || []).filter(l => /^locations\/[A-Za-z0-9_-]+$/.test(l.name)).map(businessPreview), nextPageToken: r.nextPageToken || ''};
196
+ }
197
+ async function select(name) {
198
+ if (!/^locations\/[A-Za-z0-9_-]+$/.test(name || '')) throw new GoogleBusinessError('Choose a business location.');
199
+ const expected = generation;
200
+ const location = await api(listUrl(`${LOCATION_URL}${name}`, '', {readMask: READ_MASK}));
201
+ if (location.name !== name) throw new GoogleBusinessError('Google returned a different business location.', 502);
202
+ assertGeneration(expected);
203
+ const preview = businessPreview(location);
204
+ write({...read(), location: {name: preview.name, title: preview.title}});
205
+ return preview;
206
+ }
207
+ async function disconnect() {
208
+ generation++;
209
+ pending.clear();
210
+ refreshing = null;
211
+ let c;
212
+ try { c = read(); } catch { /* forgetting an unreadable connection still works */ }
213
+ fs.rmSync(file, {force: true});
214
+ let revoked = false;
215
+ if (c?.refreshToken) {
216
+ try {
217
+ const r = await fetcher('https://oauth2.googleapis.com/revoke', {method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15_000),
218
+ headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams({token: c.refreshToken}).toString()});
219
+ revoked = r.ok;
220
+ } catch { /* the site's credential is already removed */ }
221
+ }
222
+ return {connected: false, revoked};
223
+ }
224
+ return {status, begin, take, complete, accounts, locations, select, disconnect};
225
+ }
@@ -574,6 +574,7 @@ export async function renderPage(page, opts = {}) {
574
574
  siteJson: JSON.stringify(Object.assign(
575
575
  {
576
576
  title: site.title,
577
+ ...(site.brand?.enabled === false ? {brandingEnabled: false} : {}),
577
578
  footer: site.footer
578
579
  ? {
579
580
  ...site.footer,
@@ -1369,6 +1370,7 @@ export async function renderBlogPage(templatePath, data = {}, seoMeta = {}) {
1369
1370
  // Every `<` escaped, as on the page path above.
1370
1371
  siteJson: JSON.stringify({
1371
1372
  title: site.title,
1373
+ ...(site.brand?.enabled === false ? {brandingEnabled: false} : {}),
1372
1374
  footer: site.footer
1373
1375
  ? {
1374
1376
  ...site.footer,
@@ -188,7 +188,7 @@
188
188
  <script src="/public/js/form-logic-engine.js?v=20261025-dropzone-disable" defer></script>
189
189
 
190
190
  <!-- Site initialisation -->
191
- <script src="/public/js/site.js?v=20261029-reduce-motion" type="module"></script>
191
+ <script src="/public/js/site.js?v=20261029-site-branding" type="module"></script>
192
192
  <script src="/public/js/collection-context.js?v=20261010-utf8-payloads" type="module"></script>
193
193
  {{ctxMenusModule}}
194
194