@emplugins/emprivacy 1.0.0 → 2.1.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/README.md +119 -63
- package/dist/index.d.mts +257 -6
- package/dist/index.mjs +2335 -12
- package/docs/DEVELOPMENT.md +6 -10
- package/docs/GETTING_STARTED.md +83 -75
- package/docs/PLUGIN_SETTINGS.md +121 -117
- package/docs/RELEASE_NOTES.md +10 -12
- package/docs/TESTING.md +25 -27
- package/package.json +16 -14
- package/src/astro/GatedEmbed.astro +70 -0
- package/src/astro/embed-resolve.ts +227 -0
- package/src/astro/index.ts +24 -0
- package/dist/config-BJ1LHuri.mjs +0 -166
- package/dist/config-CqOtRoQR.d.mts +0 -63
- package/dist/sandbox-entry.d.mts +0 -283
- package/dist/sandbox-entry.mjs +0 -693
package/dist/index.mjs
CHANGED
|
@@ -1,22 +1,2346 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { definePlugin } from "emdash";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
const LOCALE_KEY = /^[a-z]{2,3}(?:-[A-Za-z]{2})?$/;
|
|
4
|
+
const CHROME = {
|
|
5
|
+
en: {
|
|
6
|
+
acceptAll: "Accept all",
|
|
7
|
+
rejectNonEssential: "Reject non-essential",
|
|
8
|
+
customize: "Customize",
|
|
9
|
+
saveChoices: "Save choices",
|
|
10
|
+
close: "Close",
|
|
11
|
+
essential: "Essential",
|
|
12
|
+
functional: "Functional",
|
|
13
|
+
analytics: "Analytics",
|
|
14
|
+
marketing: "Marketing",
|
|
15
|
+
essentialNote: "Always on — needed to run the site and remember this choice.",
|
|
16
|
+
privacyPolicy: "Privacy policy",
|
|
17
|
+
cookiePolicy: "Cookie policy",
|
|
18
|
+
cookieSettings: "Cookie and privacy settings",
|
|
19
|
+
loadEmbed: "Allow embeds and load",
|
|
20
|
+
embedBlocked: "This embed is blocked until you allow the matching cookie category.",
|
|
21
|
+
embedNeedConsent: "To load this embed, allow the matching category in cookie settings.",
|
|
22
|
+
vendorsHeading: "What this site uses",
|
|
23
|
+
whatWeUse: "What we use"
|
|
24
|
+
},
|
|
25
|
+
de: {
|
|
26
|
+
acceptAll: "Alle akzeptieren",
|
|
27
|
+
rejectNonEssential: "Nicht notwendige ablehnen",
|
|
28
|
+
customize: "Anpassen",
|
|
29
|
+
saveChoices: "Auswahl speichern",
|
|
30
|
+
close: "Schließen",
|
|
31
|
+
essential: "Essenziell",
|
|
32
|
+
functional: "Funktional",
|
|
33
|
+
analytics: "Analyse",
|
|
34
|
+
marketing: "Marketing",
|
|
35
|
+
essentialNote: "Immer aktiv — erforderlich für die Website und diese Auswahl.",
|
|
36
|
+
privacyPolicy: "Datenschutzerklärung",
|
|
37
|
+
cookiePolicy: "Cookie-Richtlinie",
|
|
38
|
+
cookieSettings: "Cookie- und Datenschutzeinstellungen",
|
|
39
|
+
loadEmbed: "Einbettungen erlauben und laden",
|
|
40
|
+
embedBlocked: "Diese Einbettung ist gesperrt, bis Sie die passende Kategorie erlauben.",
|
|
41
|
+
embedNeedConsent: "Um diese Einbettung zu laden, erlauben Sie die passende Kategorie in den Cookie-Einstellungen.",
|
|
42
|
+
vendorsHeading: "Was diese Website verwendet",
|
|
43
|
+
whatWeUse: "Was wir verwenden"
|
|
44
|
+
},
|
|
45
|
+
fr: {
|
|
46
|
+
acceptAll: "Tout accepter",
|
|
47
|
+
rejectNonEssential: "Refuser les non essentiels",
|
|
48
|
+
customize: "Personnaliser",
|
|
49
|
+
saveChoices: "Enregistrer",
|
|
50
|
+
close: "Fermer",
|
|
51
|
+
essential: "Essentiels",
|
|
52
|
+
functional: "Fonctionnels",
|
|
53
|
+
analytics: "Mesure d’audience",
|
|
54
|
+
marketing: "Marketing",
|
|
55
|
+
essentialNote: "Toujours actifs — nécessaires au site et à ce choix.",
|
|
56
|
+
privacyPolicy: "Politique de confidentialité",
|
|
57
|
+
cookiePolicy: "Politique cookies",
|
|
58
|
+
cookieSettings: "Paramètres des cookies et de confidentialité",
|
|
59
|
+
loadEmbed: "Autoriser les contenus intégrés",
|
|
60
|
+
embedBlocked: "Ce contenu est bloqué tant que vous n’avez pas autorisé la catégorie correspondante.",
|
|
61
|
+
embedNeedConsent: "Pour charger ce contenu, autorisez la catégorie correspondante dans les paramètres des cookies.",
|
|
62
|
+
vendorsHeading: "Ce que ce site utilise",
|
|
63
|
+
whatWeUse: "Ce que nous utilisons"
|
|
64
|
+
},
|
|
65
|
+
es: {
|
|
66
|
+
acceptAll: "Aceptar todo",
|
|
67
|
+
rejectNonEssential: "Rechazar no esenciales",
|
|
68
|
+
customize: "Personalizar",
|
|
69
|
+
saveChoices: "Guardar",
|
|
70
|
+
close: "Cerrar",
|
|
71
|
+
essential: "Esenciales",
|
|
72
|
+
functional: "Funcionales",
|
|
73
|
+
analytics: "Analítica",
|
|
74
|
+
marketing: "Marketing",
|
|
75
|
+
essentialNote: "Siempre activos — necesarios para el sitio y esta elección.",
|
|
76
|
+
privacyPolicy: "Política de privacidad",
|
|
77
|
+
cookiePolicy: "Política de cookies",
|
|
78
|
+
cookieSettings: "Ajustes de cookies y privacidad",
|
|
79
|
+
loadEmbed: "Permitir incrustaciones y cargar",
|
|
80
|
+
embedBlocked: "Este contenido está bloqueado hasta que permita la categoría correspondiente.",
|
|
81
|
+
embedNeedConsent: "Para cargar este contenido, permita la categoría correspondiente en los ajustes de cookies.",
|
|
82
|
+
vendorsHeading: "Qué usa este sitio",
|
|
83
|
+
whatWeUse: "Qué usamos"
|
|
84
|
+
},
|
|
85
|
+
it: {
|
|
86
|
+
acceptAll: "Accetta tutto",
|
|
87
|
+
rejectNonEssential: "Rifiuta non essenziali",
|
|
88
|
+
customize: "Personalizza",
|
|
89
|
+
saveChoices: "Salva",
|
|
90
|
+
close: "Chiudi",
|
|
91
|
+
essential: "Essenziali",
|
|
92
|
+
functional: "Funzionali",
|
|
93
|
+
analytics: "Statistiche",
|
|
94
|
+
marketing: "Marketing",
|
|
95
|
+
essentialNote: "Sempre attivi — necessari al sito e a questa scelta.",
|
|
96
|
+
privacyPolicy: "Informativa sulla privacy",
|
|
97
|
+
cookiePolicy: "Informativa cookie",
|
|
98
|
+
cookieSettings: "Impostazioni cookie e privacy",
|
|
99
|
+
loadEmbed: "Consenti gli embed e carica",
|
|
100
|
+
embedBlocked: "Questo contenuto è bloccato finché non consenti la categoria corrispondente.",
|
|
101
|
+
embedNeedConsent: "Per caricare questo contenuto, consenti la categoria corrispondente nelle impostazioni cookie.",
|
|
102
|
+
vendorsHeading: "Cosa usa questo sito",
|
|
103
|
+
whatWeUse: "Cosa usiamo"
|
|
104
|
+
},
|
|
105
|
+
nl: {
|
|
106
|
+
acceptAll: "Alles accepteren",
|
|
107
|
+
rejectNonEssential: "Niet-essentieel weigeren",
|
|
108
|
+
customize: "Aanpassen",
|
|
109
|
+
saveChoices: "Opslaan",
|
|
110
|
+
close: "Sluiten",
|
|
111
|
+
essential: "Essentieel",
|
|
112
|
+
functional: "Functioneel",
|
|
113
|
+
analytics: "Statistieken",
|
|
114
|
+
marketing: "Marketing",
|
|
115
|
+
essentialNote: "Altijd aan — nodig voor de site en deze keuze.",
|
|
116
|
+
privacyPolicy: "Privacybeleid",
|
|
117
|
+
cookiePolicy: "Cookiebeleid",
|
|
118
|
+
cookieSettings: "Cookie- en privacyinstellingen",
|
|
119
|
+
loadEmbed: "Embeds toestaan en laden",
|
|
120
|
+
embedBlocked: "Deze embed is geblokkeerd tot u de bijbehorende categorie toestaat.",
|
|
121
|
+
embedNeedConsent: "Om deze embed te laden, sta de bijbehorende categorie toe in de cookie-instellingen.",
|
|
122
|
+
vendorsHeading: "Wat deze site gebruikt",
|
|
123
|
+
whatWeUse: "Wat wij gebruiken"
|
|
124
|
+
},
|
|
125
|
+
pt: {
|
|
126
|
+
acceptAll: "Aceitar tudo",
|
|
127
|
+
rejectNonEssential: "Recusar não essenciais",
|
|
128
|
+
customize: "Personalizar",
|
|
129
|
+
saveChoices: "Guardar",
|
|
130
|
+
close: "Fechar",
|
|
131
|
+
essential: "Essenciais",
|
|
132
|
+
functional: "Funcionais",
|
|
133
|
+
analytics: "Analítica",
|
|
134
|
+
marketing: "Marketing",
|
|
135
|
+
essentialNote: "Sempre ativos — necessários para o site e esta escolha.",
|
|
136
|
+
privacyPolicy: "Política de privacidade",
|
|
137
|
+
cookiePolicy: "Política de cookies",
|
|
138
|
+
cookieSettings: "Definições de cookies e privacidade",
|
|
139
|
+
loadEmbed: "Permitir embeds e carregar",
|
|
140
|
+
embedBlocked: "Este conteúdo está bloqueado até autorizar a categoria correspondente.",
|
|
141
|
+
embedNeedConsent: "Para carregar este conteúdo, autorize a categoria correspondente nas definições de cookies.",
|
|
142
|
+
vendorsHeading: "O que este site usa",
|
|
143
|
+
whatWeUse: "O que usamos"
|
|
144
|
+
},
|
|
145
|
+
pl: {
|
|
146
|
+
acceptAll: "Zaakceptuj wszystkie",
|
|
147
|
+
rejectNonEssential: "Odrzuć niepotrzebne",
|
|
148
|
+
customize: "Dostosuj",
|
|
149
|
+
saveChoices: "Zapisz",
|
|
150
|
+
close: "Zamknij",
|
|
151
|
+
essential: "Niezbędne",
|
|
152
|
+
functional: "Funkcjonalne",
|
|
153
|
+
analytics: "Analityczne",
|
|
154
|
+
marketing: "Marketingowe",
|
|
155
|
+
essentialNote: "Zawsze włączone — potrzebne do działania witryny i zapamiętania wyboru.",
|
|
156
|
+
privacyPolicy: "Polityka prywatności",
|
|
157
|
+
cookiePolicy: "Polityka cookies",
|
|
158
|
+
cookieSettings: "Ustawienia plików cookie i prywatności",
|
|
159
|
+
loadEmbed: "Zezwól na osadzenia i wczytaj",
|
|
160
|
+
embedBlocked: "Ta treść jest zablokowana, dopóki nie zezwolisz na odpowiednią kategorię.",
|
|
161
|
+
embedNeedConsent: "Aby wczytać tę treść, zezwól na odpowiednią kategorię w ustawieniach plików cookie.",
|
|
162
|
+
vendorsHeading: "Czego używa ta witryna",
|
|
163
|
+
whatWeUse: "Czego używamy"
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
function isLocaleKey(s) {
|
|
167
|
+
return LOCALE_KEY.test(s.trim());
|
|
168
|
+
}
|
|
169
|
+
/** `de-DE` → `["de-de", "de"]` (case-normalized). */
|
|
170
|
+
function localeFallbackKeys(locale) {
|
|
171
|
+
if (!locale) return [];
|
|
172
|
+
const t = locale.trim();
|
|
173
|
+
if (!t || t.length > 16) return [];
|
|
174
|
+
const lower = t.toLowerCase();
|
|
175
|
+
const parts = lower.split("-");
|
|
176
|
+
const keys = [];
|
|
177
|
+
if (parts.length >= 2) keys.push(`${parts[0]}-${parts[1]}`);
|
|
178
|
+
keys.push(parts[0] ?? lower);
|
|
179
|
+
return keys.filter((k, i, a) => isLocaleKey(k) && a.indexOf(k) === i);
|
|
180
|
+
}
|
|
181
|
+
function chromeForLocale(locale) {
|
|
182
|
+
for (const key of localeFallbackKeys(locale)) {
|
|
183
|
+
const found = CHROME[key] ?? CHROME[key.split("-")[0] ?? ""];
|
|
184
|
+
if (found) return found;
|
|
185
|
+
}
|
|
186
|
+
return CHROME["en"];
|
|
187
|
+
}
|
|
188
|
+
function parseLocaleOverrides(raw) {
|
|
189
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
190
|
+
const out = {};
|
|
191
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
192
|
+
if (!isLocaleKey(k)) continue;
|
|
193
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) continue;
|
|
194
|
+
const o = v;
|
|
195
|
+
const entry = {};
|
|
196
|
+
if (typeof o.bannerTitle === "string") entry.bannerTitle = o.bannerTitle;
|
|
197
|
+
if (typeof o.bannerMessage === "string") entry.bannerMessage = o.bannerMessage;
|
|
198
|
+
if (entry.bannerTitle !== void 0 || entry.bannerMessage !== void 0) out[k.toLowerCase()] = entry;
|
|
199
|
+
if (Object.keys(out).length >= 32) break;
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
function parseLocaleOverridesText(text) {
|
|
204
|
+
const t = text.trim();
|
|
205
|
+
if (!t) return {};
|
|
206
|
+
let parsed;
|
|
207
|
+
try {
|
|
208
|
+
parsed = JSON.parse(t);
|
|
209
|
+
} catch {
|
|
210
|
+
throw new Error("Translations must be JSON: {\"de\":{\"bannerTitle\":\"…\",\"bannerMessage\":\"…\"}}.");
|
|
211
|
+
}
|
|
212
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("Translations JSON must be an object keyed by locale (e.g. de, fr).");
|
|
213
|
+
for (const key of Object.keys(parsed)) if (!isLocaleKey(key)) throw new Error(`Invalid locale key "${key}". Use codes like en, de, or pt-BR.`);
|
|
214
|
+
return parseLocaleOverrides(parsed);
|
|
215
|
+
}
|
|
216
|
+
function resolveBannerCopy(defaults, overrides, locale) {
|
|
217
|
+
let title = defaults.bannerTitle;
|
|
218
|
+
let message = defaults.bannerMessage;
|
|
219
|
+
for (const key of localeFallbackKeys(locale)) {
|
|
220
|
+
const o = overrides[key] ?? overrides[key.split("-")[0] ?? ""];
|
|
221
|
+
if (!o) continue;
|
|
222
|
+
if (typeof o.bannerTitle === "string" && o.bannerTitle.trim()) title = o.bannerTitle;
|
|
223
|
+
if (typeof o.bannerMessage === "string" && o.bannerMessage.trim()) message = o.bannerMessage;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
bannerTitle: title,
|
|
228
|
+
bannerMessage: message
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/ids.ts
|
|
233
|
+
const HOSTNAME$1 = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
234
|
+
const FATHOM_ID = /^[A-Za-z0-9]{4,32}$/;
|
|
235
|
+
const UMAMI_ID = /^[A-Za-z0-9-]{8,64}$/;
|
|
236
|
+
const GA4_ID = /^G-[A-Z0-9]{4,14}$/;
|
|
237
|
+
const GTM_ID = /^GTM-[A-Z0-9]{4,12}$/;
|
|
238
|
+
function isHostname$1(s) {
|
|
239
|
+
const t = s.trim().toLowerCase();
|
|
240
|
+
if (!t || t.length > 253) return false;
|
|
241
|
+
if (t === "localhost" || t.endsWith(".localhost")) return false;
|
|
242
|
+
return HOSTNAME$1.test(t);
|
|
243
|
+
}
|
|
244
|
+
function isGa4Id(s) {
|
|
245
|
+
return GA4_ID.test(s.trim());
|
|
246
|
+
}
|
|
247
|
+
function isGtmId(s) {
|
|
248
|
+
return GTM_ID.test(s.trim());
|
|
249
|
+
}
|
|
250
|
+
function isFathomId(s) {
|
|
251
|
+
return FATHOM_ID.test(s.trim());
|
|
252
|
+
}
|
|
253
|
+
function isUmamiId(s) {
|
|
254
|
+
return UMAMI_ID.test(s.trim());
|
|
255
|
+
}
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/security.ts
|
|
258
|
+
/** Hosts EmPrivacy may load as <script src> for built-in presets. */
|
|
259
|
+
const PRESET_SCRIPT_HOSTS = /* @__PURE__ */ new Set([
|
|
260
|
+
"static.cloudflareinsights.com",
|
|
261
|
+
"plausible.io",
|
|
262
|
+
"cdn.usefathom.com",
|
|
263
|
+
"scripts.simpleanalyticscdn.com",
|
|
264
|
+
"www.googletagmanager.com",
|
|
265
|
+
"googletagmanager.com"
|
|
266
|
+
]);
|
|
267
|
+
const HOSTNAME = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i;
|
|
268
|
+
function normalizeCookieMaxAgeDays(n, fallback = 180) {
|
|
269
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n < 1) return fallback;
|
|
270
|
+
return Math.min(365, Math.max(1, Math.round(n)));
|
|
271
|
+
}
|
|
272
|
+
function parseCookieMaxAgeDays(s, fallback = 180) {
|
|
273
|
+
const t = s.trim();
|
|
274
|
+
if (!t) return fallback;
|
|
275
|
+
if (!/^\d{1,3}$/.test(t)) throw new Error(`Consent cookie lifetime must be 1–365 days.`);
|
|
276
|
+
const n = Number(t);
|
|
277
|
+
if (n < 1 || n > 365) throw new Error(`Consent cookie lifetime must be 1–365 days.`);
|
|
278
|
+
return n;
|
|
279
|
+
}
|
|
280
|
+
function cookieMaxAgeSeconds(days) {
|
|
281
|
+
return normalizeCookieMaxAgeDays(days) * 24 * 60 * 60;
|
|
282
|
+
}
|
|
283
|
+
function hasUnsafeUrlChars$1(s) {
|
|
284
|
+
return /[\u0000-\u001F\u007F\s]/.test(s);
|
|
285
|
+
}
|
|
286
|
+
function parseStrictHttpsUrl(raw) {
|
|
287
|
+
const t = raw.trim();
|
|
288
|
+
if (!t || t.length > 2048 || hasUnsafeUrlChars$1(t)) return null;
|
|
289
|
+
if (/%0d|%0a|%09|%0b|%0c|%20/i.test(t)) return null;
|
|
290
|
+
try {
|
|
291
|
+
const u = new URL(t);
|
|
292
|
+
if (u.protocol !== "https:") return null;
|
|
293
|
+
if (u.username || u.password) return null;
|
|
294
|
+
const host = u.hostname.toLowerCase();
|
|
295
|
+
if (host === "localhost" || host.endsWith(".localhost")) return null;
|
|
296
|
+
if (host === "127.0.0.1" || host === "::1" || host.endsWith(".local")) return null;
|
|
297
|
+
return u;
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function isHostname(s) {
|
|
303
|
+
const t = s.trim().toLowerCase();
|
|
304
|
+
if (!t || t.length > 253) return false;
|
|
305
|
+
if (t === "localhost" || t.endsWith(".localhost")) return false;
|
|
306
|
+
return HOSTNAME.test(t);
|
|
307
|
+
}
|
|
308
|
+
function parseHostnameAllowlist(text) {
|
|
309
|
+
const lines = text.split(/\r?\n/).map((l) => l.trim().toLowerCase()).filter(Boolean);
|
|
310
|
+
const out = [];
|
|
311
|
+
for (const line of lines) {
|
|
312
|
+
if (!isHostname(line)) throw new Error(`Invalid script host allowlist entry "${line}". Use hostnames like cdn.example.com.`);
|
|
313
|
+
if (!out.includes(line)) out.push(line);
|
|
314
|
+
if (out.length > 50) throw new Error("Script host allowlist is too long (max 50).");
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
function isSafeHttpsScriptUrl(raw) {
|
|
319
|
+
const u = parseStrictHttpsUrl(raw);
|
|
320
|
+
if (!u) return false;
|
|
321
|
+
if (u.hash) return false;
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
function isScriptHostAllowed(raw, opts) {
|
|
325
|
+
const u = parseStrictHttpsUrl(raw);
|
|
326
|
+
if (!u) return false;
|
|
327
|
+
const host = u.hostname.toLowerCase();
|
|
328
|
+
if (opts.allowPresets && PRESET_SCRIPT_HOSTS.has(host)) return true;
|
|
329
|
+
if (opts.allowlist.length === 0) return true;
|
|
330
|
+
return opts.allowlist.includes(host);
|
|
331
|
+
}
|
|
332
|
+
/** Optional SRI: `https://cdn.example/a.js sha384-...` or `... integrity=sha384-...`. */
|
|
333
|
+
function parseScriptUrlWithIntegrity(line) {
|
|
334
|
+
const t = line.trim();
|
|
335
|
+
if (!t) return {
|
|
336
|
+
src: "",
|
|
337
|
+
integrity: null
|
|
338
|
+
};
|
|
339
|
+
const integrityEq = /\sintegrity=(sha(?:256|384|512)-[A-Za-z0-9+/=]+)$/i.exec(t);
|
|
340
|
+
if (integrityEq) return {
|
|
341
|
+
src: t.slice(0, integrityEq.index).trim(),
|
|
342
|
+
integrity: integrityEq[1] ?? null
|
|
343
|
+
};
|
|
344
|
+
const spaced = /^(https:\/\/\S+)\s+(sha(?:256|384|512)-[A-Za-z0-9+/=]+)$/i.exec(t);
|
|
345
|
+
if (spaced) return {
|
|
346
|
+
src: spaced[1] ?? "",
|
|
347
|
+
integrity: spaced[2] ?? null
|
|
348
|
+
};
|
|
349
|
+
return {
|
|
350
|
+
src: t,
|
|
351
|
+
integrity: null
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function isValidIntegrity(s) {
|
|
355
|
+
if (!s) return true;
|
|
356
|
+
return /^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/.test(s);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* Same-origin guard for public state-changing plugin routes.
|
|
360
|
+
* Requires Origin matching the site, or Sec-Fetch-Site: same-origin.
|
|
361
|
+
*/
|
|
362
|
+
function assertSameOriginMutation(request, expectedOrigin) {
|
|
363
|
+
const origin = request.headers.get("origin");
|
|
364
|
+
if (origin) {
|
|
365
|
+
if (origin !== expectedOrigin) return new Response("forbidden", { status: 403 });
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
if ((request.headers.get("sec-fetch-site") ?? "").toLowerCase() === "same-origin") return null;
|
|
369
|
+
return new Response("forbidden", { status: 403 });
|
|
370
|
+
}
|
|
371
|
+
/** Coarse hour bucket for rate limiting (UTC). */
|
|
372
|
+
function rateHourKey(now = /* @__PURE__ */ new Date()) {
|
|
373
|
+
return `${now.getUTCFullYear()}${String(now.getUTCMonth() + 1).padStart(2, "0")}${String(now.getUTCDate()).padStart(2, "0")}${String(now.getUTCHours()).padStart(2, "0")}`;
|
|
374
|
+
}
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/theme.ts
|
|
377
|
+
/** Hex colors only — rejects CSS injection via theme fields. */
|
|
378
|
+
const HEX = /^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
|
|
379
|
+
const DEFAULT_THEME = {
|
|
380
|
+
bg: "#111111",
|
|
381
|
+
text: "#eeeeee",
|
|
382
|
+
accent: "#3b82f6",
|
|
383
|
+
radiusPx: 6
|
|
384
|
+
};
|
|
385
|
+
function isSafeHexColor(s) {
|
|
386
|
+
return HEX.test(s.trim());
|
|
387
|
+
}
|
|
388
|
+
function normalizeHexColor(s, fallback) {
|
|
389
|
+
const t = s.trim();
|
|
390
|
+
return isSafeHexColor(t) ? t : fallback;
|
|
391
|
+
}
|
|
392
|
+
function normalizeRadiusPx(n, fallback) {
|
|
393
|
+
if (typeof n !== "number" || !Number.isFinite(n)) return fallback;
|
|
394
|
+
return Math.min(24, Math.max(0, Math.round(n)));
|
|
395
|
+
}
|
|
396
|
+
function parseRadiusInput(s, fallback) {
|
|
397
|
+
const t = s.trim();
|
|
398
|
+
if (!t) return fallback;
|
|
399
|
+
if (!/^\d{1,2}$/.test(t)) throw new Error("Corner radius must be a whole number from 0 to 24.");
|
|
400
|
+
const n = Number(t);
|
|
401
|
+
if (n > 24) throw new Error("Corner radius must be a whole number from 0 to 24.");
|
|
402
|
+
return normalizeRadiusPx(n, fallback);
|
|
403
|
+
}
|
|
404
|
+
//#endregion
|
|
405
|
+
//#region src/config.ts
|
|
406
|
+
/**
|
|
407
|
+
* EmPrivacy KV document (`consent:config`) and shared helpers.
|
|
408
|
+
*/
|
|
409
|
+
const KV_KEY = "consent:config";
|
|
410
|
+
const COOKIE_NAME = "emprivacy_cc";
|
|
411
|
+
const PLUGIN_ID = "emprivacy";
|
|
412
|
+
const DEFAULT_CONFIG = {
|
|
413
|
+
bannerTitle: "Cookies & privacy",
|
|
414
|
+
bannerMessage: "We use cookies to run this site and optionally for functional embeds, analytics, and marketing. You can accept or customize your choices.",
|
|
415
|
+
privacyPolicyUrl: "/privacy",
|
|
416
|
+
cookiePolicyUrl: "",
|
|
417
|
+
strictDefaults: true,
|
|
418
|
+
policyVersion: "1",
|
|
419
|
+
cloudflareWebAnalyticsToken: "",
|
|
420
|
+
analyticsProvider: "cloudflare",
|
|
421
|
+
analyticsId: "",
|
|
422
|
+
umamiScriptUrl: "",
|
|
423
|
+
analyticsScriptUrls: [],
|
|
424
|
+
marketingScriptUrls: [],
|
|
425
|
+
scriptIntegrity: {},
|
|
426
|
+
scriptHostAllowlist: [],
|
|
427
|
+
cookieMaxAgeDays: 180,
|
|
428
|
+
googleConsentMode: false,
|
|
429
|
+
logConsentToServer: false,
|
|
430
|
+
embedCategory: "marketing",
|
|
431
|
+
gateEmbeds: true,
|
|
432
|
+
hideBannerOnPolicyPages: true,
|
|
433
|
+
defaultLocale: "en",
|
|
434
|
+
localeOverrides: {},
|
|
435
|
+
theme: { ...DEFAULT_THEME }
|
|
436
|
+
};
|
|
437
|
+
const ANALYTICS_PROVIDERS = [
|
|
438
|
+
"cloudflare",
|
|
439
|
+
"none",
|
|
440
|
+
"custom",
|
|
441
|
+
"plausible",
|
|
442
|
+
"fathom",
|
|
443
|
+
"umami",
|
|
444
|
+
"simpleanalytics",
|
|
445
|
+
"ga4",
|
|
446
|
+
"gtm"
|
|
447
|
+
];
|
|
448
|
+
function hasUnsafeUrlChars(s) {
|
|
449
|
+
return /[\u0000-\u001F\u007F\s]/.test(s);
|
|
450
|
+
}
|
|
451
|
+
function isHttpsUrl(s) {
|
|
452
|
+
try {
|
|
453
|
+
const t = s.trim();
|
|
454
|
+
if (!t) return false;
|
|
455
|
+
if (hasUnsafeUrlChars(t)) return false;
|
|
456
|
+
if (/%0d|%0a|%09|%0b|%0c|%20/i.test(t)) return false;
|
|
457
|
+
const u = new URL(t);
|
|
458
|
+
if (u.protocol !== "https:") return false;
|
|
459
|
+
if (u.username || u.password) return false;
|
|
460
|
+
return true;
|
|
461
|
+
} catch {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
function invalidScriptUrlError(kind, u) {
|
|
466
|
+
if (u.includes("<") || u.includes(">")) return `Invalid ${kind} script URL (https only). Paste one full https:// URL per line (the script src only), not a <script> tag or HTML comment. The invalid line was: ${u}`;
|
|
467
|
+
return `Invalid ${kind} script URL (https only): ${u}`;
|
|
468
|
+
}
|
|
469
|
+
function isAnalyticsProvider(s) {
|
|
470
|
+
return ANALYTICS_PROVIDERS.includes(s);
|
|
471
|
+
}
|
|
472
|
+
function isEmbedCategory(s) {
|
|
473
|
+
return s === "functional" || s === "marketing";
|
|
474
|
+
}
|
|
475
|
+
/** Reject characters that would break JSON or attributes; allow typical Cloudflare site token strings. */
|
|
476
|
+
function assertValidCloudflareToken(t) {
|
|
477
|
+
const s = t.trim();
|
|
478
|
+
if (s.length === 0) return;
|
|
479
|
+
if (s.length > 256) throw new Error("Cloudflare site token is too long.");
|
|
480
|
+
if (/[\s<>'"&]/.test(s)) throw new Error("Cloudflare site token contains invalid characters.");
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Root-relative public path (EmDash page route), e.g. `/privacy`.
|
|
484
|
+
* Rejects protocol-relative URLs (`//…`) and whitespace.
|
|
485
|
+
*/
|
|
486
|
+
function isRootRelativeSitePath(s) {
|
|
487
|
+
const t = s.trim();
|
|
488
|
+
if (t.length === 0) return false;
|
|
489
|
+
if (!t.startsWith("/")) return false;
|
|
490
|
+
if (t.startsWith("//")) return false;
|
|
491
|
+
if (hasUnsafeUrlChars(t)) return false;
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
/** For hooks: turn stored path or absolute URL into a public absolute URL. */
|
|
495
|
+
function resolvePolicyHref(stored, ctx) {
|
|
496
|
+
const t = stored.trim();
|
|
497
|
+
if (!t) return t;
|
|
498
|
+
if (isRootRelativeSitePath(t)) return ctx.url(t);
|
|
499
|
+
return t;
|
|
500
|
+
}
|
|
501
|
+
function isValidPolicyHrefInput(s) {
|
|
502
|
+
const t = s.trim();
|
|
503
|
+
if (!t) return false;
|
|
504
|
+
return isHttpsUrl(t) || isRootRelativeSitePath(t);
|
|
505
|
+
}
|
|
506
|
+
function parseUrlList(text) {
|
|
507
|
+
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
508
|
+
return [...new Set(lines)];
|
|
509
|
+
}
|
|
510
|
+
function assertLength(label, s, min, max) {
|
|
511
|
+
const t = s.trim();
|
|
512
|
+
if (t.length < min) throw new Error(`${label} is required.`);
|
|
513
|
+
if (t.length > max) throw new Error(`${label} is too long.`);
|
|
514
|
+
}
|
|
515
|
+
function assertOptionalLength(label, s, max) {
|
|
516
|
+
if (s.trim().length > max) throw new Error(`${label} is too long.`);
|
|
517
|
+
}
|
|
518
|
+
function pathnameFromHref(href) {
|
|
519
|
+
const t = href.trim();
|
|
520
|
+
if (!t) return null;
|
|
521
|
+
if (isRootRelativeSitePath(t)) return t.split(/[?#]/)[0] ?? t;
|
|
522
|
+
try {
|
|
523
|
+
return new URL(t).pathname.replace(/\/+$/, "") || "/";
|
|
524
|
+
} catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
/** True when the public page is the configured privacy or cookie policy. */
|
|
529
|
+
function isPolicyPagePath(pagePath, cfg) {
|
|
530
|
+
const raw = pagePath.trim();
|
|
531
|
+
if (!raw) return false;
|
|
532
|
+
const current = raw.split(/[?#]/)[0]?.replace(/\/+$/, "") || "/";
|
|
533
|
+
for (const stored of [cfg.privacyPolicyUrl, cfg.cookiePolicyUrl]) {
|
|
534
|
+
const pn = pathnameFromHref(stored);
|
|
535
|
+
if (!pn) continue;
|
|
536
|
+
if (current === pn) return true;
|
|
537
|
+
if (current.endsWith(pn) && pn !== "/") return true;
|
|
538
|
+
}
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
function normalizeConfig(raw) {
|
|
542
|
+
if (typeof raw !== "object" || raw === null) return {
|
|
543
|
+
...DEFAULT_CONFIG,
|
|
544
|
+
theme: { ...DEFAULT_THEME }
|
|
545
|
+
};
|
|
546
|
+
const o = raw;
|
|
547
|
+
const analytics = Array.isArray(o.analyticsScriptUrls) ? o.analyticsScriptUrls.filter((x) => typeof x === "string") : DEFAULT_CONFIG.analyticsScriptUrls;
|
|
548
|
+
const marketing = Array.isArray(o.marketingScriptUrls) ? o.marketingScriptUrls.filter((x) => typeof x === "string") : DEFAULT_CONFIG.marketingScriptUrls;
|
|
549
|
+
const cfTok = typeof o.cloudflareWebAnalyticsToken === "string" ? o.cloudflareWebAnalyticsToken : "";
|
|
550
|
+
const apCandidate = typeof o.analyticsProvider === "string" ? o.analyticsProvider.trim() : "";
|
|
551
|
+
let analyticsProvider;
|
|
552
|
+
if (isAnalyticsProvider(apCandidate)) analyticsProvider = apCandidate;
|
|
553
|
+
else if (analytics.length > 0) analyticsProvider = "custom";
|
|
554
|
+
else analyticsProvider = DEFAULT_CONFIG.analyticsProvider;
|
|
555
|
+
const themeRaw = o.theme && typeof o.theme === "object" ? o.theme : {};
|
|
556
|
+
const embedRaw = typeof o.embedCategory === "string" ? o.embedCategory.trim() : "";
|
|
557
|
+
const integrityRaw = o.scriptIntegrity && typeof o.scriptIntegrity === "object" && !Array.isArray(o.scriptIntegrity) ? o.scriptIntegrity : {};
|
|
558
|
+
const scriptIntegrity = {};
|
|
559
|
+
for (const [k, v] of Object.entries(integrityRaw)) {
|
|
560
|
+
if (typeof k === "string" && typeof v === "string" && isValidIntegrity(v) && isSafeHttpsScriptUrl(k)) scriptIntegrity[k] = v;
|
|
561
|
+
if (Object.keys(scriptIntegrity).length >= 100) break;
|
|
562
|
+
}
|
|
563
|
+
const scriptHostAllowlist = (Array.isArray(o.scriptHostAllowlist) ? o.scriptHostAllowlist.filter((x) => typeof x === "string") : []).map((h) => h.trim().toLowerCase()).filter((h) => isHostname$1(h)).slice(0, 50);
|
|
564
|
+
return {
|
|
565
|
+
bannerTitle: typeof o.bannerTitle === "string" ? o.bannerTitle : DEFAULT_CONFIG.bannerTitle,
|
|
566
|
+
bannerMessage: typeof o.bannerMessage === "string" ? o.bannerMessage : DEFAULT_CONFIG.bannerMessage,
|
|
567
|
+
privacyPolicyUrl: typeof o.privacyPolicyUrl === "string" ? o.privacyPolicyUrl : DEFAULT_CONFIG.privacyPolicyUrl,
|
|
568
|
+
cookiePolicyUrl: typeof o.cookiePolicyUrl === "string" ? o.cookiePolicyUrl : DEFAULT_CONFIG.cookiePolicyUrl,
|
|
569
|
+
strictDefaults: typeof o.strictDefaults === "boolean" ? o.strictDefaults : DEFAULT_CONFIG.strictDefaults,
|
|
570
|
+
policyVersion: typeof o.policyVersion === "string" && o.policyVersion.length > 0 ? o.policyVersion : DEFAULT_CONFIG.policyVersion,
|
|
571
|
+
cloudflareWebAnalyticsToken: cfTok,
|
|
572
|
+
analyticsProvider,
|
|
573
|
+
analyticsId: typeof o.analyticsId === "string" ? o.analyticsId : "",
|
|
574
|
+
umamiScriptUrl: typeof o.umamiScriptUrl === "string" ? o.umamiScriptUrl : "",
|
|
575
|
+
analyticsScriptUrls: analytics,
|
|
576
|
+
marketingScriptUrls: marketing,
|
|
577
|
+
scriptIntegrity,
|
|
578
|
+
scriptHostAllowlist,
|
|
579
|
+
cookieMaxAgeDays: normalizeCookieMaxAgeDays(o.cookieMaxAgeDays, DEFAULT_CONFIG.cookieMaxAgeDays),
|
|
580
|
+
googleConsentMode: typeof o.googleConsentMode === "boolean" ? o.googleConsentMode : DEFAULT_CONFIG.googleConsentMode,
|
|
581
|
+
logConsentToServer: typeof o.logConsentToServer === "boolean" ? o.logConsentToServer : DEFAULT_CONFIG.logConsentToServer,
|
|
582
|
+
embedCategory: isEmbedCategory(embedRaw) ? embedRaw : DEFAULT_CONFIG.embedCategory,
|
|
583
|
+
gateEmbeds: typeof o.gateEmbeds === "boolean" ? o.gateEmbeds : DEFAULT_CONFIG.gateEmbeds,
|
|
584
|
+
hideBannerOnPolicyPages: typeof o.hideBannerOnPolicyPages === "boolean" ? o.hideBannerOnPolicyPages : DEFAULT_CONFIG.hideBannerOnPolicyPages,
|
|
585
|
+
defaultLocale: typeof o.defaultLocale === "string" && o.defaultLocale ? o.defaultLocale : DEFAULT_CONFIG.defaultLocale,
|
|
586
|
+
localeOverrides: parseLocaleOverrides(o.localeOverrides),
|
|
587
|
+
theme: {
|
|
588
|
+
bg: normalizeHexColor(typeof themeRaw.bg === "string" ? themeRaw.bg : "", DEFAULT_THEME.bg),
|
|
589
|
+
text: normalizeHexColor(typeof themeRaw.text === "string" ? themeRaw.text : "", DEFAULT_THEME.text),
|
|
590
|
+
accent: normalizeHexColor(typeof themeRaw.accent === "string" ? themeRaw.accent : "", DEFAULT_THEME.accent),
|
|
591
|
+
radiusPx: normalizeRadiusPx(themeRaw.radiusPx, DEFAULT_THEME.radiusPx)
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
function assertAnalyticsId(provider, id, umamiScriptUrl) {
|
|
596
|
+
const t = id.trim();
|
|
597
|
+
if (provider === "plausible") {
|
|
598
|
+
if (!isHostname$1(t)) throw new Error("Plausible domain must be a hostname like example.com (no https://).");
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (provider === "fathom") {
|
|
602
|
+
if (!isFathomId(t)) throw new Error("Fathom site ID must be 4–32 letters or digits.");
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (provider === "umami") {
|
|
606
|
+
if (!isUmamiId(t)) throw new Error("Umami website ID looks invalid.");
|
|
607
|
+
if (!isHttpsUrl(umamiScriptUrl)) throw new Error("Umami requires the https script URL (Cloud or your self-hosted tracker).");
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (provider === "ga4") {
|
|
611
|
+
if (!isGa4Id(t)) throw new Error("GA4 measurement ID must look like G-XXXXXXXX.");
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (provider === "gtm") {
|
|
615
|
+
if (!isGtmId(t)) throw new Error("Google Tag Manager ID must look like GTM-XXXX.");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (provider === "simpleanalytics" && t && !isHostname$1(t)) throw new Error("Simple Analytics hostname must be a domain like example.com, or empty.");
|
|
619
|
+
}
|
|
620
|
+
/** Validate admin-submitted fields; throws an Error with a short message on failure */
|
|
621
|
+
function assertValidSavedConfig(input) {
|
|
622
|
+
assertLength("Policy version", input.policyVersion, 1, 64);
|
|
623
|
+
assertOptionalLength("Banner title", input.bannerTitle, 120);
|
|
624
|
+
assertOptionalLength("Short notice", input.bannerMessage, 600);
|
|
625
|
+
const privacyPolicyUrl = input.privacyPolicyUrl.trim();
|
|
626
|
+
if (!isValidPolicyHrefInput(privacyPolicyUrl)) throw new Error("Privacy policy must be a full https:// URL or a site path to your EmDash Page (e.g. /privacy).");
|
|
627
|
+
if (privacyPolicyUrl.length > 2048) throw new Error("Privacy policy URL/path is too long.");
|
|
628
|
+
let cookiePolicyUrl = input.cookiePolicyUrl.trim();
|
|
629
|
+
if (cookiePolicyUrl && !isValidPolicyHrefInput(cookiePolicyUrl)) throw new Error("Cookie policy must be a valid https:// URL or a root-relative path (e.g. /cookies), or empty.");
|
|
630
|
+
if (cookiePolicyUrl.length > 2048) throw new Error("Cookie policy URL/path is too long.");
|
|
631
|
+
if (!cookiePolicyUrl) cookiePolicyUrl = DEFAULT_CONFIG.cookiePolicyUrl;
|
|
632
|
+
const ap = input.analyticsPlatform.trim() || "cloudflare";
|
|
633
|
+
if (!isAnalyticsProvider(ap)) throw new Error("Analytics: choose a supported platform.");
|
|
634
|
+
const tokenTrim = input.cloudflareToken.trim();
|
|
635
|
+
if (ap === "cloudflare") assertValidCloudflareToken(tokenTrim);
|
|
636
|
+
const umamiScriptUrl = input.umamiScriptUrl.trim();
|
|
637
|
+
if (ap === "umami" && umamiScriptUrl && !isHttpsUrl(umamiScriptUrl)) throw new Error(invalidScriptUrlError("umami", umamiScriptUrl));
|
|
638
|
+
if (ap !== "none" && ap !== "custom" && ap !== "cloudflare" && ap !== "simpleanalytics" && ap !== "gtm") assertAnalyticsId(ap, input.analyticsId, umamiScriptUrl);
|
|
639
|
+
else if (ap === "simpleanalytics" && input.analyticsId.trim()) assertAnalyticsId(ap, input.analyticsId, umamiScriptUrl);
|
|
640
|
+
else if (ap === "gtm") assertAnalyticsId(ap, input.analyticsId, umamiScriptUrl);
|
|
641
|
+
const scriptHostAllowlist = parseHostnameAllowlist(input.scriptHostAllowlistText);
|
|
642
|
+
const cookieMaxAgeDays = parseCookieMaxAgeDays(input.cookieMaxAgeDays, 180);
|
|
643
|
+
const scriptIntegrity = {};
|
|
644
|
+
const assertScriptLine = (kind, line) => {
|
|
645
|
+
const { src, integrity } = parseScriptUrlWithIntegrity(line);
|
|
646
|
+
if (!src || src.length > 2048) throw new Error(invalidScriptUrlError(kind, line));
|
|
647
|
+
if (!isSafeHttpsScriptUrl(src)) throw new Error(invalidScriptUrlError(kind, src));
|
|
648
|
+
if (integrity && !isValidIntegrity(integrity)) throw new Error(`Invalid SRI for ${kind} script (expected sha256-|sha384-|sha512-…).`);
|
|
649
|
+
if (!isScriptHostAllowed(src, {
|
|
650
|
+
allowlist: scriptHostAllowlist,
|
|
651
|
+
allowPresets: false
|
|
652
|
+
})) throw new Error(`${kind} script host is not on the allowlist. Add the hostname or clear the allowlist.`);
|
|
653
|
+
if (integrity) scriptIntegrity[src] = integrity;
|
|
654
|
+
return src;
|
|
655
|
+
};
|
|
656
|
+
if (ap === "umami" && umamiScriptUrl) {
|
|
657
|
+
if (!isScriptHostAllowed(umamiScriptUrl, {
|
|
658
|
+
allowlist: scriptHostAllowlist,
|
|
659
|
+
allowPresets: false
|
|
660
|
+
})) throw new Error("Umami script host is not on the allowlist. Add the hostname or clear the allowlist.");
|
|
661
|
+
}
|
|
662
|
+
const analyticsScriptUrls = [];
|
|
663
|
+
if (ap === "custom") {
|
|
664
|
+
const list = parseUrlList(input.analyticsUrlsText);
|
|
665
|
+
if (list.length > 50) throw new Error("Analytics script URL list is too long (max 50).");
|
|
666
|
+
for (const u of list) analyticsScriptUrls.push(assertScriptLine("analytics", u));
|
|
667
|
+
}
|
|
668
|
+
const marketingScriptUrls = [];
|
|
669
|
+
const mlist = parseUrlList(input.marketingUrlsText);
|
|
670
|
+
if (mlist.length > 50) throw new Error("Marketing script URL list is too long (max 50).");
|
|
671
|
+
for (const u of mlist) marketingScriptUrls.push(assertScriptLine("marketing", u));
|
|
672
|
+
const embedCategory = input.embedCategory.trim() || "marketing";
|
|
673
|
+
if (!isEmbedCategory(embedCategory)) throw new Error("Embeds must require Functional or Marketing consent.");
|
|
674
|
+
const defaultLocale = input.defaultLocale.trim().toLowerCase() || "en";
|
|
675
|
+
if (!/^[a-z]{2,3}(?:-[a-z]{2})?$/.test(defaultLocale)) throw new Error("Default locale must look like en or pt-BR.");
|
|
676
|
+
const localeOverrides = parseLocaleOverridesText(input.localeOverridesText);
|
|
677
|
+
for (const pack of Object.values(localeOverrides)) {
|
|
678
|
+
if (pack.bannerTitle !== void 0) assertOptionalLength("Translated banner title", pack.bannerTitle, 120);
|
|
679
|
+
if (pack.bannerMessage !== void 0) assertOptionalLength("Translated short notice", pack.bannerMessage, 600);
|
|
680
|
+
}
|
|
681
|
+
const themeBg = input.themeBg.trim() || DEFAULT_THEME.bg;
|
|
682
|
+
const themeText = input.themeText.trim() || DEFAULT_THEME.text;
|
|
683
|
+
const themeAccent = input.themeAccent.trim() || DEFAULT_THEME.accent;
|
|
684
|
+
if (input.themeBg.trim() && !/^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(themeBg)) throw new Error("Banner background must be a hex color like #111111.");
|
|
685
|
+
if (input.themeText.trim() && !/^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(themeText)) throw new Error("Banner text color must be a hex color like #eeeeee.");
|
|
686
|
+
if (input.themeAccent.trim() && !/^#(?:[0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/.test(themeAccent)) throw new Error("Banner accent color must be a hex color like #3b82f6.");
|
|
687
|
+
return normalizeConfig({
|
|
688
|
+
bannerTitle: input.bannerTitle.trim() || DEFAULT_CONFIG.bannerTitle,
|
|
689
|
+
bannerMessage: input.bannerMessage.trim() || DEFAULT_CONFIG.bannerMessage,
|
|
690
|
+
privacyPolicyUrl,
|
|
691
|
+
cookiePolicyUrl,
|
|
692
|
+
strictDefaults: input.strictDefaults,
|
|
693
|
+
policyVersion: input.policyVersion.trim(),
|
|
694
|
+
analyticsProvider: ap,
|
|
695
|
+
cloudflareWebAnalyticsToken: ap === "cloudflare" ? tokenTrim : "",
|
|
696
|
+
analyticsId: ap === "cloudflare" || ap === "custom" || ap === "none" ? "" : input.analyticsId.trim(),
|
|
697
|
+
umamiScriptUrl: ap === "umami" ? umamiScriptUrl : "",
|
|
698
|
+
analyticsScriptUrls: ap === "custom" ? analyticsScriptUrls : [],
|
|
699
|
+
marketingScriptUrls,
|
|
700
|
+
scriptIntegrity,
|
|
701
|
+
scriptHostAllowlist,
|
|
702
|
+
cookieMaxAgeDays,
|
|
703
|
+
googleConsentMode: input.googleConsentMode,
|
|
704
|
+
logConsentToServer: input.logConsentToServer,
|
|
705
|
+
embedCategory,
|
|
706
|
+
gateEmbeds: input.gateEmbeds,
|
|
707
|
+
hideBannerOnPolicyPages: input.hideBannerOnPolicyPages,
|
|
708
|
+
defaultLocale,
|
|
709
|
+
localeOverrides,
|
|
710
|
+
theme: {
|
|
711
|
+
bg: themeBg,
|
|
712
|
+
text: themeText,
|
|
713
|
+
accent: themeAccent,
|
|
714
|
+
radiusPx: parseRadiusInput(input.themeRadius, DEFAULT_THEME.radiusPx)
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
/** Safe to embed in `<script type="application/json">` (breakout-safe) */
|
|
719
|
+
function jsonForHtmlScript(value) {
|
|
720
|
+
return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (c) => {
|
|
721
|
+
switch (c) {
|
|
722
|
+
case "<": return "\\u003c";
|
|
723
|
+
case ">": return "\\u003e";
|
|
724
|
+
case "&": return "\\u0026";
|
|
725
|
+
case "\u2028": return "\\u2028";
|
|
726
|
+
case "\u2029": return "\\u2029";
|
|
727
|
+
default: return c;
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
function resolvePublicCopy(cfg, locale) {
|
|
732
|
+
const copy = resolveBannerCopy({
|
|
733
|
+
bannerTitle: cfg.bannerTitle,
|
|
734
|
+
bannerMessage: cfg.bannerMessage
|
|
735
|
+
}, cfg.localeOverrides, locale ?? cfg.defaultLocale);
|
|
736
|
+
return {
|
|
737
|
+
bannerTitle: copy.bannerTitle,
|
|
738
|
+
bannerMessage: copy.bannerMessage,
|
|
739
|
+
ui: chromeForLocale(locale ?? cfg.defaultLocale)
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
//#endregion
|
|
743
|
+
//#region src/npm-version.ts
|
|
744
|
+
/** Public npm package page for this plugin. */
|
|
745
|
+
const NPM_PACKAGE_URL = "https://www.npmjs.com/package/@emplugins/emprivacy";
|
|
746
|
+
/** npm registry metadata for the `latest` dist-tag. */
|
|
747
|
+
const NPM_REGISTRY_LATEST_URL = "https://registry.npmjs.org/@emplugins/emprivacy/latest";
|
|
748
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
749
|
+
const SUCCESS_CACHE_MS = 6e5;
|
|
750
|
+
const FAILURE_CACHE_MS = 6e4;
|
|
751
|
+
const FETCH_TIMEOUT_MS = 2500;
|
|
752
|
+
let cache = null;
|
|
753
|
+
/** Accept only a well-formed semver from untrusted registry JSON. */
|
|
754
|
+
function parseNpmLatestVersion(data) {
|
|
755
|
+
if (!data || typeof data !== "object") return null;
|
|
756
|
+
const version = data.version;
|
|
757
|
+
if (typeof version !== "string") return null;
|
|
758
|
+
const trimmed = version.trim();
|
|
759
|
+
return SEMVER.test(trimmed) ? trimmed : null;
|
|
760
|
+
}
|
|
761
|
+
function parseCore(version) {
|
|
762
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
763
|
+
if (!m) return null;
|
|
764
|
+
return [
|
|
765
|
+
Number(m[1]),
|
|
766
|
+
Number(m[2]),
|
|
767
|
+
Number(m[3])
|
|
768
|
+
];
|
|
769
|
+
}
|
|
770
|
+
/** True when `latest` is a higher MAJOR.MINOR.PATCH than `installed`. */
|
|
771
|
+
function isNewerPublished(latest, installed) {
|
|
772
|
+
const a = parseCore(latest);
|
|
773
|
+
const b = parseCore(installed);
|
|
774
|
+
if (!a || !b) return latest !== installed;
|
|
775
|
+
if (a[0] !== b[0]) return a[0] > b[0];
|
|
776
|
+
if (a[1] !== b[1]) return a[1] > b[1];
|
|
777
|
+
return a[2] > b[2];
|
|
778
|
+
}
|
|
779
|
+
function pluginVersionFields(installed, latest) {
|
|
780
|
+
return [{
|
|
781
|
+
label: "Installed version",
|
|
782
|
+
value: installed
|
|
783
|
+
}, {
|
|
784
|
+
label: "Latest on npm",
|
|
785
|
+
value: latest ?? "Could not reach npm"
|
|
786
|
+
}];
|
|
787
|
+
}
|
|
788
|
+
function pluginVersionNote(installed, latest) {
|
|
789
|
+
if (latest && isNewerPublished(latest, installed)) return `A newer version is published. Update @emplugins/emprivacy to get it. ${NPM_PACKAGE_URL}`;
|
|
790
|
+
return `Published package: ${NPM_PACKAGE_URL}`;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* Read the published `latest` version from the npm registry.
|
|
794
|
+
* Results are cached briefly so admin save/reload does not hit npm every time.
|
|
795
|
+
* Failures return null and never throw — the settings page must still load.
|
|
796
|
+
*/
|
|
797
|
+
async function fetchLatestPublishedVersion(options) {
|
|
798
|
+
const now = options?.now ?? Date.now();
|
|
799
|
+
if (cache && now < cache.expiresAt) return cache.value;
|
|
800
|
+
const doFetch = options?.fetch ?? globalThis.fetch;
|
|
801
|
+
const timeoutMs = options?.timeoutMs ?? FETCH_TIMEOUT_MS;
|
|
802
|
+
try {
|
|
803
|
+
const response = await doFetch(NPM_REGISTRY_LATEST_URL, {
|
|
804
|
+
method: "GET",
|
|
805
|
+
headers: {
|
|
806
|
+
Accept: "application/json",
|
|
807
|
+
"User-Agent": "EmPrivacy (https://github.com/EmPlugins/EmPrivacy)"
|
|
808
|
+
},
|
|
809
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
810
|
+
});
|
|
811
|
+
if (!response.ok) {
|
|
812
|
+
cache = {
|
|
813
|
+
value: null,
|
|
814
|
+
expiresAt: now + FAILURE_CACHE_MS
|
|
815
|
+
};
|
|
816
|
+
return null;
|
|
817
|
+
}
|
|
818
|
+
const version = parseNpmLatestVersion(await response.json());
|
|
819
|
+
cache = {
|
|
820
|
+
value: version,
|
|
821
|
+
expiresAt: now + (version ? SUCCESS_CACHE_MS : FAILURE_CACHE_MS)
|
|
822
|
+
};
|
|
823
|
+
return version;
|
|
824
|
+
} catch {
|
|
825
|
+
cache = {
|
|
826
|
+
value: null,
|
|
827
|
+
expiresAt: now + FAILURE_CACHE_MS
|
|
828
|
+
};
|
|
829
|
+
return null;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
//#endregion
|
|
833
|
+
//#region src/public-bootstrap.ts
|
|
834
|
+
/**
|
|
835
|
+
* Public-site bootstrap. Banner copy is applied with textContent / createTextNode only.
|
|
836
|
+
* Embed/script URLs are re-validated in the browser (never trust DOM data-src alone).
|
|
837
|
+
*/
|
|
838
|
+
function buildBodyBootstrap(pr) {
|
|
839
|
+
return `(function(){
|
|
840
|
+
var C=${jsonForHtmlScript(pr)};
|
|
841
|
+
var CN="${COOKIE_NAME}";
|
|
842
|
+
var listeners=[];
|
|
843
|
+
var PRESET_HOSTS={"static.cloudflareinsights.com":1,"plausible.io":1,"cdn.usefathom.com":1,"scripts.simpleanalyticscdn.com":1,"www.googletagmanager.com":1,"googletagmanager.com":1};
|
|
844
|
+
var YT_IFRAME=/^https:\\/\\/(?:www\\.)?youtube-nocookie\\.com\\/embed\\/[A-Za-z0-9_-]{11}\\/?$/;
|
|
845
|
+
var VIMEO_IFRAME=/^https:\\/\\/player\\.vimeo\\.com\\/video\\/\\d{6,12}\\/?$/;
|
|
846
|
+
function readCookie(){
|
|
847
|
+
try{
|
|
848
|
+
var m=document.cookie.match(new RegExp("(?:^|;\\\\s*)"+CN+"=([^;]*)"));
|
|
849
|
+
var v=m?decodeURIComponent(m[1]):"";
|
|
850
|
+
if(!v||v.length>1024)return"";
|
|
851
|
+
return v;
|
|
852
|
+
}catch(e){return"";}
|
|
853
|
+
}
|
|
854
|
+
function writeCookie(val){
|
|
855
|
+
var secure=document.location.protocol==="https:"?"; Secure":"";
|
|
856
|
+
var maxAge=typeof C.cookieMaxAge==="number"&&C.cookieMaxAge>0?C.cookieMaxAge:15552000;
|
|
857
|
+
document.cookie=CN+"="+encodeURIComponent(val)+"; Path=/; SameSite=Lax"+secure+"; Max-Age="+maxAge;
|
|
858
|
+
}
|
|
859
|
+
function flagOk(x){return x===0||x===1||x===true||x===false;}
|
|
860
|
+
function parseState(s){
|
|
861
|
+
try{
|
|
862
|
+
var o=JSON.parse(s);
|
|
863
|
+
if(!o||typeof o!=="object")return null;
|
|
864
|
+
if(typeof o.v!=="string")return null;
|
|
865
|
+
if(o.v!==C.policyVersion)return null;
|
|
866
|
+
if(!flagOk(o.a)||!flagOk(o.m)||!flagOk(o.f))return null;
|
|
867
|
+
return {v:o.v,essential:true,functional:!!o.f,analytics:!!o.a,marketing:!!o.m};
|
|
868
|
+
}catch(e){return null;}
|
|
869
|
+
}
|
|
870
|
+
function currentState(){return parseState(readCookie());}
|
|
871
|
+
function emit(st){
|
|
872
|
+
listeners.slice().forEach(function(fn){try{fn(st);}catch(e){}});
|
|
873
|
+
try{document.dispatchEvent(new CustomEvent("emprivacy:change",{detail:st}));}catch(e){}
|
|
874
|
+
}
|
|
875
|
+
function needBanner(){
|
|
876
|
+
if(C.hideBanner)return false;
|
|
877
|
+
return !parseState(readCookie());
|
|
878
|
+
}
|
|
879
|
+
function parseHttps(raw){
|
|
880
|
+
try{
|
|
881
|
+
if(!raw||typeof raw!=="string"||raw.length>2048)return null;
|
|
882
|
+
if(/[\\u0000-\\u001F\\u007F\\s]/.test(raw))return null;
|
|
883
|
+
if(/%0d|%0a|%09|%0b|%0c|%20/i.test(raw))return null;
|
|
884
|
+
var u=new URL(raw);
|
|
885
|
+
if(u.protocol!=="https:")return null;
|
|
886
|
+
if(u.username||u.password)return null;
|
|
887
|
+
var h=u.hostname.toLowerCase();
|
|
888
|
+
if(h==="localhost"||h.endsWith(".localhost")||h==="127.0.0.1"||h==="::1")return null;
|
|
889
|
+
return u;
|
|
890
|
+
}catch(e){return null;}
|
|
891
|
+
}
|
|
892
|
+
function hostAllowed(u,allowPresets){
|
|
893
|
+
var h=u.hostname.toLowerCase();
|
|
894
|
+
if(allowPresets&&PRESET_HOSTS[h])return true;
|
|
895
|
+
var list=C.scriptHostAllowlist||[];
|
|
896
|
+
if(!list.length)return true;
|
|
897
|
+
return list.indexOf(h)>=0;
|
|
898
|
+
}
|
|
899
|
+
function safeScriptUrl(raw,allowPresets){
|
|
900
|
+
var u=parseHttps(raw);
|
|
901
|
+
if(!u||u.hash)return null;
|
|
902
|
+
if(!hostAllowed(u,!!allowPresets))return null;
|
|
903
|
+
return u.href;
|
|
904
|
+
}
|
|
905
|
+
function allowedIframe(src){return YT_IFRAME.test(src)||VIMEO_IFRAME.test(src);}
|
|
906
|
+
function allowedLink(src,kind){
|
|
907
|
+
var u=parseHttps(src);
|
|
908
|
+
if(!u)return false;
|
|
909
|
+
var h=u.hostname.toLowerCase();
|
|
910
|
+
if(h==="x.com"||h==="www.x.com"||h==="twitter.com"||h==="www.twitter.com"||h==="mobile.twitter.com"){
|
|
911
|
+
return /\\/(?:i\\/web\\/)?status(?:es)?\\/\\d{5,20}\\/?$/i.test(u.pathname);
|
|
912
|
+
}
|
|
913
|
+
if(h==="bsky.app"||h==="www.bsky.app"){
|
|
914
|
+
return /^\\/profile\\/[^/]+\\/post\\/[^/]+\\/?$/.test(u.pathname);
|
|
915
|
+
}
|
|
916
|
+
if(h==="gist.github.com"){
|
|
917
|
+
return /^\\/[A-Za-z0-9-]{1,39}\\/[a-f0-9]{8,64}\\/?$/i.test(u.pathname);
|
|
918
|
+
}
|
|
919
|
+
if(/^\\/@[^/]+\\/\\d+\\/?$/.test(u.pathname)||/^\\/users\\/[^/]+\\/statuses\\/\\d+\\/?$/.test(u.pathname))return true;
|
|
920
|
+
return kind==="linkPreview";
|
|
921
|
+
}
|
|
922
|
+
function alreadyScriptSrc(u){
|
|
923
|
+
try{
|
|
924
|
+
return Array.prototype.some.call(document.getElementsByTagName("script"),function(s){return s.src===u;});
|
|
925
|
+
}catch(e){return false;}
|
|
926
|
+
}
|
|
927
|
+
function loadScript(src, attrs, integrity, allowPresets){
|
|
928
|
+
var safe=safeScriptUrl(src,allowPresets);
|
|
929
|
+
if(!safe||alreadyScriptSrc(safe))return;
|
|
930
|
+
var e=document.createElement("script");
|
|
931
|
+
e.src=safe;e.async=true;e.referrerPolicy="no-referrer-when-downgrade";
|
|
932
|
+
if(integrity&&/^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/.test(integrity)){
|
|
933
|
+
e.integrity=integrity;
|
|
934
|
+
e.crossOrigin="anonymous";
|
|
935
|
+
}
|
|
936
|
+
if(attrs){
|
|
937
|
+
Object.keys(attrs).forEach(function(k){
|
|
938
|
+
if(/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(k)) e.setAttribute(k,String(attrs[k]));
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
document.head.appendChild(e);
|
|
942
|
+
}
|
|
943
|
+
function loadCloudflareBeacon(token){
|
|
944
|
+
var u="https://static.cloudflareinsights.com/beacon.min.js";
|
|
945
|
+
if(!token||typeof token!=="string"||token.length>256||/[\\s<>'"&]/.test(token))return;
|
|
946
|
+
if(alreadyScriptSrc(u))return;
|
|
947
|
+
var e=document.createElement("script");
|
|
948
|
+
e.src=u;
|
|
949
|
+
e.defer=true;
|
|
950
|
+
e.setAttribute("data-cf-beacon",JSON.stringify({token:token}));
|
|
951
|
+
e.referrerPolicy="no-referrer-when-downgrade";
|
|
952
|
+
document.head.appendChild(e);
|
|
953
|
+
}
|
|
954
|
+
function applyAnalytics(){
|
|
955
|
+
var L=C.loader||{type:"none"};
|
|
956
|
+
if(L.type==="cloudflare") loadCloudflareBeacon(L.token);
|
|
957
|
+
else if(L.type==="custom") (L.scripts||[]).forEach(function(s){loadScript(s.src,null,s.integrity,false);});
|
|
958
|
+
else if(L.type==="plausible") loadScript(L.src,{"data-domain":L.domain},null,true);
|
|
959
|
+
else if(L.type==="fathom") loadScript(L.src,{"data-site":L.siteId},null,true);
|
|
960
|
+
else if(L.type==="umami") loadScript(L.src,{"data-website-id":L.websiteId},null,false);
|
|
961
|
+
else if(L.type==="simpleanalytics") loadScript(L.src,null,null,true);
|
|
962
|
+
else if(L.type==="ga4"){
|
|
963
|
+
window.dataLayer=window.dataLayer||[];
|
|
964
|
+
if(typeof window.gtag!=="function"){window.gtag=function(){window.dataLayer.push(arguments);};}
|
|
965
|
+
loadScript("https://www.googletagmanager.com/gtag/js?id="+encodeURIComponent(L.measurementId),null,null,true);
|
|
966
|
+
window.gtag("js",new Date());
|
|
967
|
+
window.gtag("config",L.measurementId);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
function applyMarketing(){
|
|
971
|
+
var urls=C.marketingScripts||[];
|
|
972
|
+
urls.forEach(function(s){loadScript(s.src,null,s.integrity,false);});
|
|
973
|
+
var L=C.loader||{type:"none"};
|
|
974
|
+
if(L.type==="gtm"){
|
|
975
|
+
window.dataLayer=window.dataLayer||[];
|
|
976
|
+
window.dataLayer.push({"gtm.start":Date.now(),event:"gtm.js"});
|
|
977
|
+
loadScript("https://www.googletagmanager.com/gtm.js?id="+encodeURIComponent(L.containerId),null,null,true);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
function applyScripts(st){
|
|
981
|
+
if(st.analytics) applyAnalytics();
|
|
982
|
+
if(st.marketing) applyMarketing();
|
|
983
|
+
}
|
|
984
|
+
function gtagUpdate(st){
|
|
985
|
+
if(!C.googleConsentMode||!window.gtag)return;
|
|
986
|
+
window.gtag("consent","update",{
|
|
987
|
+
analytics_storage:st.analytics?"granted":"denied",
|
|
988
|
+
ad_storage:st.marketing?"granted":"denied",
|
|
989
|
+
ad_user_data:st.marketing?"granted":"denied",
|
|
990
|
+
ad_personalization:st.marketing?"granted":"denied",
|
|
991
|
+
personalization_storage:st.marketing?"granted":"denied",
|
|
992
|
+
functionality_storage:st.functional?"granted":"denied",
|
|
993
|
+
security_storage:"granted"
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
function logServer(st){
|
|
997
|
+
if(!C.logConsent)return;
|
|
998
|
+
fetch(C.recordPath,{
|
|
999
|
+
method:"POST",
|
|
1000
|
+
credentials:"same-origin",
|
|
1001
|
+
headers:{"Content-Type":"application/json"},
|
|
1002
|
+
body:JSON.stringify({policyVersion:C.policyVersion,functional:!!st.functional,analytics:!!st.analytics,marketing:!!st.marketing})
|
|
1003
|
+
}).catch(function(){});
|
|
1004
|
+
}
|
|
1005
|
+
function hide(el){if(el)el.setAttribute("hidden","");el&&(el.style.display="none");}
|
|
1006
|
+
function show(el){if(el)el.removeAttribute("hidden");el&&(el.style.display="");}
|
|
1007
|
+
function embedAllowed(st){
|
|
1008
|
+
if(!C.gateEmbeds)return false;
|
|
1009
|
+
return C.embedCategory==="functional"?!!st.functional:!!st.marketing;
|
|
1010
|
+
}
|
|
1011
|
+
function mountIframe(box,src){
|
|
1012
|
+
if(!allowedIframe(src))return;
|
|
1013
|
+
var f=document.createElement("iframe");
|
|
1014
|
+
f.src=src;
|
|
1015
|
+
f.setAttribute("loading","lazy");
|
|
1016
|
+
f.setAttribute("referrerpolicy","strict-origin-when-cross-origin");
|
|
1017
|
+
f.setAttribute("allow","accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share");
|
|
1018
|
+
f.setAttribute("allowfullscreen","");
|
|
1019
|
+
f.setAttribute("sandbox","allow-scripts allow-same-origin allow-presentation");
|
|
1020
|
+
f.setAttribute("title",box.getAttribute("data-label")||"Embed");
|
|
1021
|
+
f.style.width="100%";
|
|
1022
|
+
f.style.aspectRatio="16/9";
|
|
1023
|
+
f.style.border="0";
|
|
1024
|
+
box.replaceWith(f);
|
|
1025
|
+
}
|
|
1026
|
+
function mountLink(box,src,label,kind){
|
|
1027
|
+
if(!allowedLink(src,kind))return;
|
|
1028
|
+
var a=document.createElement("a");
|
|
1029
|
+
a.href=src;
|
|
1030
|
+
a.rel="noopener noreferrer nofollow";
|
|
1031
|
+
a.target="_blank";
|
|
1032
|
+
a.appendChild(document.createTextNode(label||src));
|
|
1033
|
+
box.replaceWith(a);
|
|
1034
|
+
}
|
|
1035
|
+
function hydrateEmbeds(st){
|
|
1036
|
+
if(!C.gateEmbeds)return;
|
|
1037
|
+
var nodes=document.querySelectorAll("[data-emprivacy-embed]");
|
|
1038
|
+
Array.prototype.forEach.call(nodes,function(box){
|
|
1039
|
+
var copy=box.querySelector(".emprivacy-embed-copy");
|
|
1040
|
+
var loadBtn=box.querySelector("[data-emprivacy-embed-load]");
|
|
1041
|
+
if(copy) copy.textContent=C.ui.embedBlocked;
|
|
1042
|
+
if(loadBtn) loadBtn.textContent=C.ui.loadEmbed;
|
|
1043
|
+
if(!embedAllowed(st))return;
|
|
1044
|
+
var src=box.getAttribute("data-src")||"";
|
|
1045
|
+
var mode=box.getAttribute("data-mode")||"";
|
|
1046
|
+
var kind=box.getAttribute("data-emprivacy-embed")||"";
|
|
1047
|
+
if(mode==="iframe") mountIframe(box,src);
|
|
1048
|
+
else mountLink(box,src,box.getAttribute("data-label")||"",kind);
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
function persist(a,m,f,opts){
|
|
1052
|
+
var st={v:C.policyVersion,essential:true,functional:!!f,analytics:!!a,marketing:!!m};
|
|
1053
|
+
writeCookie(JSON.stringify({v:st.v,a:st.analytics?1:0,m:st.marketing?1:0,f:st.functional?1:0}));
|
|
1054
|
+
logServer(st);
|
|
1055
|
+
gtagUpdate(st);
|
|
1056
|
+
emit(st);
|
|
1057
|
+
hydrateEmbeds(st);
|
|
1058
|
+
if(!opts||!opts.skipReload){
|
|
1059
|
+
location.reload();
|
|
1060
|
+
return st;
|
|
1061
|
+
}
|
|
1062
|
+
applyScripts(st);
|
|
1063
|
+
return st;
|
|
1064
|
+
}
|
|
1065
|
+
function addPolicyLinks(links){
|
|
1066
|
+
function addOne(href,text){
|
|
1067
|
+
if(!href)return;
|
|
1068
|
+
if(!parseHttps(href))return;
|
|
1069
|
+
var a=document.createElement("a");
|
|
1070
|
+
a.href=href;
|
|
1071
|
+
a.rel="nofollow noopener";
|
|
1072
|
+
a.target="_blank";
|
|
1073
|
+
a.appendChild(document.createTextNode(text));
|
|
1074
|
+
links.appendChild(a);
|
|
1075
|
+
}
|
|
1076
|
+
addOne(C.privacyPolicyUrl,C.ui.privacyPolicy);
|
|
1077
|
+
if(C.cookiePolicyUrl)addOne(C.cookiePolicyUrl,C.ui.cookiePolicy);
|
|
1078
|
+
}
|
|
1079
|
+
function addVendorDisclosure(parent){
|
|
1080
|
+
if(!C.vendors||!C.vendors.length)return;
|
|
1081
|
+
var det=document.createElement("details");
|
|
1082
|
+
det.className="emprivacy-vendors";
|
|
1083
|
+
var sum=document.createElement("summary");
|
|
1084
|
+
sum.appendChild(document.createTextNode(C.ui.vendorsHeading));
|
|
1085
|
+
det.appendChild(sum);
|
|
1086
|
+
var ul=document.createElement("ul");
|
|
1087
|
+
C.vendors.forEach(function(v){
|
|
1088
|
+
var li=document.createElement("li");
|
|
1089
|
+
var strong=document.createElement("strong");
|
|
1090
|
+
strong.appendChild(document.createTextNode(v.name));
|
|
1091
|
+
li.appendChild(strong);
|
|
1092
|
+
li.appendChild(document.createTextNode(" ("+v.category+") — "+v.purpose));
|
|
1093
|
+
if(v.policyUrl&&parseHttps(v.policyUrl)){
|
|
1094
|
+
li.appendChild(document.createTextNode(" "));
|
|
1095
|
+
var a=document.createElement("a");
|
|
1096
|
+
a.href=v.policyUrl;
|
|
1097
|
+
a.rel="nofollow noopener";
|
|
1098
|
+
a.target="_blank";
|
|
1099
|
+
a.appendChild(document.createTextNode(C.ui.whatWeUse));
|
|
1100
|
+
li.appendChild(a);
|
|
1101
|
+
}
|
|
1102
|
+
ul.appendChild(li);
|
|
1103
|
+
});
|
|
1104
|
+
det.appendChild(ul);
|
|
1105
|
+
parent.appendChild(det);
|
|
1106
|
+
}
|
|
1107
|
+
function mkRow(label,id,on,locked){
|
|
1108
|
+
var w=document.createElement("label");
|
|
1109
|
+
w.className="emprivacy-switch";
|
|
1110
|
+
var cb=document.createElement("input");
|
|
1111
|
+
cb.type="checkbox";
|
|
1112
|
+
cb.id=id;
|
|
1113
|
+
cb.checked=on;
|
|
1114
|
+
if(locked){cb.checked=true;cb.disabled=true;}
|
|
1115
|
+
w.appendChild(cb);
|
|
1116
|
+
w.appendChild(document.createTextNode(" "+label));
|
|
1117
|
+
return {wrap:w,box:cb};
|
|
1118
|
+
}
|
|
1119
|
+
var openPanel=function(){};
|
|
1120
|
+
function installApi(){
|
|
1121
|
+
var api=Object.freeze({
|
|
1122
|
+
get:function(){return currentState();},
|
|
1123
|
+
has:function(cat){
|
|
1124
|
+
if(cat==="essential")return true;
|
|
1125
|
+
var st=currentState();
|
|
1126
|
+
if(!st)return false;
|
|
1127
|
+
if(cat==="functional")return !!st.functional;
|
|
1128
|
+
if(cat==="analytics")return !!st.analytics;
|
|
1129
|
+
if(cat==="marketing")return !!st.marketing;
|
|
1130
|
+
return false;
|
|
1131
|
+
},
|
|
1132
|
+
onChange:function(cb){
|
|
1133
|
+
if(typeof cb!=="function")return function(){};
|
|
1134
|
+
listeners.push(cb);
|
|
1135
|
+
return function(){listeners=listeners.filter(function(x){return x!==cb;});};
|
|
1136
|
+
},
|
|
1137
|
+
open:function(){openPanel();}
|
|
1138
|
+
});
|
|
1139
|
+
try{Object.defineProperty(window,"emprivacy",{value:api,writable:false,configurable:false});}catch(e){window.emprivacy=api;}
|
|
1140
|
+
}
|
|
1141
|
+
function applyTheme(root){
|
|
1142
|
+
try{
|
|
1143
|
+
root.style.setProperty("--emprivacy-bg",C.theme.bg);
|
|
1144
|
+
root.style.setProperty("--emprivacy-text",C.theme.text);
|
|
1145
|
+
root.style.setProperty("--emprivacy-accent",C.theme.accent);
|
|
1146
|
+
root.style.setProperty("--emprivacy-radius",String(C.theme.radiusPx)+"px");
|
|
1147
|
+
}catch(e){}
|
|
1148
|
+
}
|
|
1149
|
+
function mount(){
|
|
1150
|
+
var root=document.getElementById("emprivacy-root");
|
|
1151
|
+
if(!root)return;
|
|
1152
|
+
applyTheme(root);
|
|
1153
|
+
var initial=currentState();
|
|
1154
|
+
if(initial) hydrateEmbeds(initial);
|
|
1155
|
+
var trigger=document.createElement("button");
|
|
1156
|
+
trigger.type="button";
|
|
1157
|
+
trigger.className="emprivacy-cookie-trigger";
|
|
1158
|
+
trigger.setAttribute("aria-label",C.ui.cookieSettings);
|
|
1159
|
+
var ic=document.createElement("span");
|
|
1160
|
+
ic.setAttribute("aria-hidden","true");
|
|
1161
|
+
ic.className="emprivacy-cookie-trigger-icon";
|
|
1162
|
+
ic.appendChild(document.createTextNode("🍪"));
|
|
1163
|
+
trigger.appendChild(ic);
|
|
1164
|
+
function showTrigger(){trigger.removeAttribute("hidden");}
|
|
1165
|
+
function hideTrigger(){trigger.setAttribute("hidden","");}
|
|
1166
|
+
function buildPanel(isReopen,hint){
|
|
1167
|
+
var st=parseState(readCookie());
|
|
1168
|
+
var aOn=st?!!st.analytics:(C.strictDefaults?false:true);
|
|
1169
|
+
var mOn=st?!!st.marketing:(C.strictDefaults?false:true);
|
|
1170
|
+
var fOn=st?!!st.functional:(C.strictDefaults?false:true);
|
|
1171
|
+
if(isReopen) hideTrigger();
|
|
1172
|
+
var bar=document.createElement("div");
|
|
1173
|
+
bar.className="emprivacy-bar"+(isReopen?" emprivacy-bar--reopen":"");
|
|
1174
|
+
bar.setAttribute("role","dialog");
|
|
1175
|
+
bar.setAttribute("aria-modal",isReopen?"true":"false");
|
|
1176
|
+
var title=document.createElement("h2");
|
|
1177
|
+
title.className="emprivacy-title";
|
|
1178
|
+
title.appendChild(document.createTextNode(C.bannerTitle));
|
|
1179
|
+
var msg=document.createElement("p");
|
|
1180
|
+
msg.className="emprivacy-msg";
|
|
1181
|
+
msg.appendChild(document.createTextNode(C.bannerMessage));
|
|
1182
|
+
var links=document.createElement("div");
|
|
1183
|
+
links.className="emprivacy-links";
|
|
1184
|
+
addPolicyLinks(links);
|
|
1185
|
+
var opts=document.createElement("div");
|
|
1186
|
+
opts.className="emprivacy-opts";
|
|
1187
|
+
if(!isReopen) opts.setAttribute("hidden","");
|
|
1188
|
+
if(hint){
|
|
1189
|
+
var hintEl=document.createElement("p");
|
|
1190
|
+
hintEl.className="emprivacy-note";
|
|
1191
|
+
hintEl.appendChild(document.createTextNode(hint));
|
|
1192
|
+
opts.appendChild(hintEl);
|
|
1193
|
+
show(opts);
|
|
1194
|
+
}
|
|
1195
|
+
var ess=mkRow(C.ui.essential,isReopen?"emprivacy-re":"emprivacy-e",true,true);
|
|
1196
|
+
var note=document.createElement("p");
|
|
1197
|
+
note.className="emprivacy-note";
|
|
1198
|
+
note.appendChild(document.createTextNode(C.ui.essentialNote));
|
|
1199
|
+
var fr=mkRow(C.ui.functional,isReopen?"emprivacy-rf":"emprivacy-f",fOn,false);
|
|
1200
|
+
var er=mkRow(C.ui.analytics,isReopen?"emprivacy-ra":"emprivacy-a",aOn,false);
|
|
1201
|
+
var mr=mkRow(C.ui.marketing,isReopen?"emprivacy-rm":"emprivacy-m",mOn,false);
|
|
1202
|
+
opts.appendChild(ess.wrap);
|
|
1203
|
+
opts.appendChild(note);
|
|
1204
|
+
opts.appendChild(fr.wrap);
|
|
1205
|
+
opts.appendChild(er.wrap);
|
|
1206
|
+
opts.appendChild(mr.wrap);
|
|
1207
|
+
addVendorDisclosure(opts);
|
|
1208
|
+
var actions=document.createElement("div");
|
|
1209
|
+
actions.className="emprivacy-actions";
|
|
1210
|
+
var btnAll=document.createElement("button");
|
|
1211
|
+
btnAll.type="button";
|
|
1212
|
+
btnAll.className="emprivacy-btn emprivacy-btn-primary";
|
|
1213
|
+
btnAll.appendChild(document.createTextNode(C.ui.acceptAll));
|
|
1214
|
+
btnAll.addEventListener("click",function(){persist(true,true,true);});
|
|
1215
|
+
var btnRej=document.createElement("button");
|
|
1216
|
+
btnRej.type="button";
|
|
1217
|
+
btnRej.className="emprivacy-btn";
|
|
1218
|
+
btnRej.appendChild(document.createTextNode(C.ui.rejectNonEssential));
|
|
1219
|
+
btnRej.addEventListener("click",function(){persist(false,false,false);});
|
|
1220
|
+
var btnSave=document.createElement("button");
|
|
1221
|
+
btnSave.type="button";
|
|
1222
|
+
btnSave.className="emprivacy-btn emprivacy-btn-primary";
|
|
1223
|
+
if(!isReopen&&!hint) btnSave.setAttribute("hidden","");
|
|
1224
|
+
btnSave.appendChild(document.createTextNode(C.ui.saveChoices));
|
|
1225
|
+
btnSave.addEventListener("click",function(){persist(!!er.box.checked,!!mr.box.checked,!!fr.box.checked);});
|
|
1226
|
+
actions.appendChild(btnAll);
|
|
1227
|
+
actions.appendChild(btnRej);
|
|
1228
|
+
if(!isReopen){
|
|
1229
|
+
var btnCust=document.createElement("button");
|
|
1230
|
+
btnCust.type="button";
|
|
1231
|
+
btnCust.className="emprivacy-btn";
|
|
1232
|
+
btnCust.appendChild(document.createTextNode(C.ui.customize));
|
|
1233
|
+
btnCust.addEventListener("click",function(){show(opts);btnSave.removeAttribute("hidden");});
|
|
1234
|
+
actions.appendChild(btnCust);
|
|
1235
|
+
}else{
|
|
1236
|
+
var btnClose=document.createElement("button");
|
|
1237
|
+
btnClose.type="button";
|
|
1238
|
+
btnClose.className="emprivacy-btn";
|
|
1239
|
+
btnClose.appendChild(document.createTextNode(C.ui.close));
|
|
1240
|
+
btnClose.addEventListener("click",function(){
|
|
1241
|
+
if(bar.parentNode)bar.parentNode.removeChild(bar);
|
|
1242
|
+
showTrigger();
|
|
1243
|
+
});
|
|
1244
|
+
actions.appendChild(btnClose);
|
|
1245
|
+
}
|
|
1246
|
+
actions.appendChild(btnSave);
|
|
1247
|
+
bar.appendChild(title);
|
|
1248
|
+
bar.appendChild(msg);
|
|
1249
|
+
bar.appendChild(links);
|
|
1250
|
+
bar.appendChild(opts);
|
|
1251
|
+
bar.appendChild(actions);
|
|
1252
|
+
return bar;
|
|
1253
|
+
}
|
|
1254
|
+
function openReopenPanel(hint){
|
|
1255
|
+
if(root.querySelector(".emprivacy-bar--reopen"))return;
|
|
1256
|
+
var st=parseState(readCookie());
|
|
1257
|
+
if(!st)return;
|
|
1258
|
+
root.appendChild(buildPanel(true,hint||""));
|
|
1259
|
+
}
|
|
1260
|
+
openPanel=function(hint){
|
|
1261
|
+
var st=parseState(readCookie());
|
|
1262
|
+
if(st) openReopenPanel(hint);
|
|
1263
|
+
else{
|
|
1264
|
+
var existing=root.querySelector(".emprivacy-bar");
|
|
1265
|
+
if(existing){
|
|
1266
|
+
if(hint){
|
|
1267
|
+
var note=document.createElement("p");
|
|
1268
|
+
note.className="emprivacy-note";
|
|
1269
|
+
note.appendChild(document.createTextNode(hint));
|
|
1270
|
+
var opts=existing.querySelector(".emprivacy-opts");
|
|
1271
|
+
if(opts){opts.insertBefore(note,opts.firstChild);show(opts);}
|
|
1272
|
+
}
|
|
1273
|
+
try{existing.scrollIntoView({block:"end"});}catch(e){}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
};
|
|
1277
|
+
root.appendChild(trigger);
|
|
1278
|
+
hideTrigger();
|
|
1279
|
+
function wireEmbedButtons(){
|
|
1280
|
+
document.addEventListener("click",function(ev){
|
|
1281
|
+
var t=ev.target;
|
|
1282
|
+
if(!t||!t.closest)return;
|
|
1283
|
+
var btn=t.closest("[data-emprivacy-embed-load]");
|
|
1284
|
+
if(!btn)return;
|
|
1285
|
+
ev.preventDefault();
|
|
1286
|
+
if(!C.gateEmbeds)return;
|
|
1287
|
+
var st=currentState();
|
|
1288
|
+
if(st&&embedAllowed(st)){
|
|
1289
|
+
hydrateEmbeds(st);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
openPanel(C.ui.embedNeedConsent);
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
wireEmbedButtons();
|
|
1296
|
+
if(!needBanner()){
|
|
1297
|
+
var st=parseState(readCookie());
|
|
1298
|
+
if(st){
|
|
1299
|
+
applyScripts(st);
|
|
1300
|
+
gtagUpdate(st);
|
|
1301
|
+
hydrateEmbeds(st);
|
|
1302
|
+
}
|
|
1303
|
+
showTrigger();
|
|
1304
|
+
trigger.addEventListener("click",function(){openReopenPanel();});
|
|
1305
|
+
installApi();
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
root.appendChild(buildPanel(false,""));
|
|
1309
|
+
trigger.addEventListener("click",function(){openReopenPanel();});
|
|
1310
|
+
installApi();
|
|
1311
|
+
}
|
|
1312
|
+
if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",mount);
|
|
1313
|
+
else mount();
|
|
1314
|
+
})();`;
|
|
1315
|
+
}
|
|
1316
|
+
function buildGoogleConsentHeadScript() {
|
|
1317
|
+
return `(function(){window.dataLayer=window.dataLayer||[];function g(){window.dataLayer.push(arguments);}window.gtag=g;g("consent","default",{"analytics_storage":"denied","ad_storage":"denied","ad_user_data":"denied","ad_personalization":"denied","functionality_storage":"denied","security_storage":"granted","personalization_storage":"denied"});})();`;
|
|
1318
|
+
}
|
|
1319
|
+
//#endregion
|
|
1320
|
+
//#region src/vendors.ts
|
|
1321
|
+
const PLAUSIBLE_SCRIPT = "https://plausible.io/js/script.js";
|
|
1322
|
+
const FATHOM_SCRIPT = "https://cdn.usefathom.com/script.js";
|
|
1323
|
+
const SIMPLE_SCRIPT = "https://scripts.simpleanalyticscdn.com/latest.js";
|
|
1324
|
+
function buildAnalyticsLoader(cfg) {
|
|
1325
|
+
switch (cfg.analyticsProvider) {
|
|
1326
|
+
case "none": return { type: "none" };
|
|
1327
|
+
case "cloudflare": return cfg.cloudflareWebAnalyticsToken ? {
|
|
1328
|
+
type: "cloudflare",
|
|
1329
|
+
token: cfg.cloudflareWebAnalyticsToken
|
|
1330
|
+
} : { type: "none" };
|
|
1331
|
+
case "custom": return cfg.analyticsScriptUrls.length ? {
|
|
1332
|
+
type: "custom",
|
|
1333
|
+
scripts: cfg.analyticsScriptUrls.map((src) => ({
|
|
1334
|
+
src,
|
|
1335
|
+
integrity: cfg.scriptIntegrity[src] ?? null
|
|
1336
|
+
}))
|
|
1337
|
+
} : { type: "none" };
|
|
1338
|
+
case "plausible": return isHostname$1(cfg.analyticsId) ? {
|
|
1339
|
+
type: "plausible",
|
|
1340
|
+
src: PLAUSIBLE_SCRIPT,
|
|
1341
|
+
domain: cfg.analyticsId.trim().toLowerCase()
|
|
1342
|
+
} : { type: "none" };
|
|
1343
|
+
case "fathom": return isFathomId(cfg.analyticsId) ? {
|
|
1344
|
+
type: "fathom",
|
|
1345
|
+
src: FATHOM_SCRIPT,
|
|
1346
|
+
siteId: cfg.analyticsId.trim()
|
|
1347
|
+
} : { type: "none" };
|
|
1348
|
+
case "umami": return isUmamiId(cfg.analyticsId) && cfg.umamiScriptUrl ? {
|
|
1349
|
+
type: "umami",
|
|
1350
|
+
src: cfg.umamiScriptUrl,
|
|
1351
|
+
websiteId: cfg.analyticsId.trim()
|
|
1352
|
+
} : { type: "none" };
|
|
1353
|
+
case "simpleanalytics": return {
|
|
1354
|
+
type: "simpleanalytics",
|
|
1355
|
+
src: SIMPLE_SCRIPT
|
|
1356
|
+
};
|
|
1357
|
+
case "ga4": return isGa4Id(cfg.analyticsId) ? {
|
|
1358
|
+
type: "ga4",
|
|
1359
|
+
measurementId: cfg.analyticsId.trim()
|
|
1360
|
+
} : { type: "none" };
|
|
1361
|
+
case "gtm": return isGtmId(cfg.analyticsId) ? {
|
|
1362
|
+
type: "gtm",
|
|
1363
|
+
containerId: cfg.analyticsId.trim()
|
|
1364
|
+
} : { type: "none" };
|
|
1365
|
+
default: return { type: "none" };
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
function buildVendorList(cfg) {
|
|
1369
|
+
const rows = [{
|
|
1370
|
+
id: "emprivacy",
|
|
1371
|
+
name: "EmPrivacy consent cookie",
|
|
1372
|
+
category: "essential",
|
|
1373
|
+
purpose: "Stores your category choices (`emprivacy_cc`) so the banner does not reappear every visit."
|
|
1374
|
+
}];
|
|
1375
|
+
if (cfg.gateEmbeds) rows.push({
|
|
1376
|
+
id: "embeds",
|
|
1377
|
+
name: "Third-party embeds (YouTube, Vimeo, social posts, Gists)",
|
|
1378
|
+
category: cfg.embedCategory,
|
|
1379
|
+
purpose: "Loads official EmDash embed blocks only after this category is allowed. Unknown or unsafe embed URLs are never framed."
|
|
1380
|
+
});
|
|
1381
|
+
const loader = buildAnalyticsLoader(cfg);
|
|
1382
|
+
if (loader.type === "cloudflare") rows.push({
|
|
1383
|
+
id: "cloudflare-wa",
|
|
1384
|
+
name: "Cloudflare Web Analytics",
|
|
1385
|
+
category: "analytics",
|
|
1386
|
+
purpose: "Privacy-oriented page analytics via Cloudflare’s beacon after Analytics consent.",
|
|
1387
|
+
policyUrl: "https://www.cloudflare.com/privacypolicy/"
|
|
1388
|
+
});
|
|
1389
|
+
else if (loader.type === "plausible") rows.push({
|
|
1390
|
+
id: "plausible",
|
|
1391
|
+
name: "Plausible Analytics",
|
|
1392
|
+
category: "analytics",
|
|
1393
|
+
purpose: "Cookieless first-party analytics loaded after Analytics consent.",
|
|
1394
|
+
policyUrl: "https://plausible.io/privacy"
|
|
1395
|
+
});
|
|
1396
|
+
else if (loader.type === "fathom") rows.push({
|
|
1397
|
+
id: "fathom",
|
|
1398
|
+
name: "Fathom Analytics",
|
|
1399
|
+
category: "analytics",
|
|
1400
|
+
purpose: "Privacy-focused analytics loaded after Analytics consent.",
|
|
1401
|
+
policyUrl: "https://usefathom.com/privacy"
|
|
1402
|
+
});
|
|
1403
|
+
else if (loader.type === "umami") rows.push({
|
|
1404
|
+
id: "umami",
|
|
1405
|
+
name: "Umami",
|
|
1406
|
+
category: "analytics",
|
|
1407
|
+
purpose: "Self-hosted or Umami Cloud analytics loaded after Analytics consent.",
|
|
1408
|
+
policyUrl: "https://umami.is/privacy"
|
|
1409
|
+
});
|
|
1410
|
+
else if (loader.type === "simpleanalytics") rows.push({
|
|
1411
|
+
id: "simpleanalytics",
|
|
1412
|
+
name: "Simple Analytics",
|
|
1413
|
+
category: "analytics",
|
|
1414
|
+
purpose: "Privacy-focused analytics loaded after Analytics consent.",
|
|
1415
|
+
policyUrl: "https://www.simpleanalytics.com/privacy-policy"
|
|
1416
|
+
});
|
|
1417
|
+
else if (loader.type === "ga4") rows.push({
|
|
1418
|
+
id: "ga4",
|
|
1419
|
+
name: "Google Analytics 4",
|
|
1420
|
+
category: "analytics",
|
|
1421
|
+
purpose: "Google Analytics loaded after Analytics consent. Enable Google Consent Mode if you also use Google tags.",
|
|
1422
|
+
policyUrl: "https://policies.google.com/privacy"
|
|
1423
|
+
});
|
|
1424
|
+
else if (loader.type === "gtm") rows.push({
|
|
1425
|
+
id: "gtm",
|
|
1426
|
+
name: "Google Tag Manager",
|
|
1427
|
+
category: "marketing",
|
|
1428
|
+
purpose: "Loads GTM only after Marketing consent (containers often fire ads/remarketing). You remain responsible for tags inside the container.",
|
|
1429
|
+
policyUrl: "https://policies.google.com/privacy"
|
|
1430
|
+
});
|
|
1431
|
+
else if (loader.type === "custom") for (const [i, script] of loader.scripts.entries()) {
|
|
1432
|
+
let host = "custom script";
|
|
1433
|
+
try {
|
|
1434
|
+
host = new URL(script.src).hostname;
|
|
1435
|
+
} catch {}
|
|
1436
|
+
rows.push({
|
|
1437
|
+
id: `custom-analytics-${i}`,
|
|
1438
|
+
name: `Custom analytics (${host})`,
|
|
1439
|
+
category: "analytics",
|
|
1440
|
+
purpose: "A script URL you configured. Loaded only after Analytics consent."
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
for (const [i, src] of cfg.marketingScriptUrls.entries()) {
|
|
1444
|
+
let host = "custom script";
|
|
1445
|
+
try {
|
|
1446
|
+
host = new URL(src).hostname;
|
|
1447
|
+
} catch {}
|
|
1448
|
+
rows.push({
|
|
1449
|
+
id: `marketing-${i}`,
|
|
1450
|
+
name: `Marketing script (${host})`,
|
|
1451
|
+
category: "marketing",
|
|
1452
|
+
purpose: "A marketing or advertising script you configured. Loaded only after Marketing consent."
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
if (cfg.googleConsentMode) rows.push({
|
|
1456
|
+
id: "gcm",
|
|
1457
|
+
name: "Google Consent Mode v2",
|
|
1458
|
+
category: "essential",
|
|
1459
|
+
purpose: "Sets denied-by-default Google consent signals in the page; updates after the visitor chooses.",
|
|
1460
|
+
policyUrl: "https://support.google.com/tagmanager/answer/13695607"
|
|
1461
|
+
});
|
|
1462
|
+
return rows;
|
|
1463
|
+
}
|
|
1464
|
+
//#endregion
|
|
3
1465
|
//#region src/version.ts
|
|
4
|
-
const VERSION = "1.0
|
|
5
|
-
|
|
1466
|
+
const VERSION = "2.1.0";
|
|
1467
|
+
//#endregion
|
|
1468
|
+
//#region src/runtime.ts
|
|
1469
|
+
/** Soft cap for anonymous consent POSTs per client fingerprint per UTC hour. */
|
|
1470
|
+
const RECORD_RATE_LIMIT = 30;
|
|
1471
|
+
const RECORD_LOG_CAP = 500;
|
|
1472
|
+
const ADMIN_SETTINGS_PATH = "/settings";
|
|
1473
|
+
const SAVE_ACTION_ID = "emprivacy-save";
|
|
1474
|
+
const recordInput = z.object({
|
|
1475
|
+
policyVersion: z.string().trim().min(1).max(64),
|
|
1476
|
+
functional: z.boolean(),
|
|
1477
|
+
analytics: z.boolean(),
|
|
1478
|
+
marketing: z.boolean()
|
|
1479
|
+
});
|
|
1480
|
+
async function loadConfig(ctx) {
|
|
1481
|
+
const raw = await ctx.kv.get(KV_KEY);
|
|
1482
|
+
if (!raw) return normalizeConfig({});
|
|
1483
|
+
try {
|
|
1484
|
+
return normalizeConfig(JSON.parse(raw));
|
|
1485
|
+
} catch {
|
|
1486
|
+
return normalizeConfig({});
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
async function saveConfigString(ctx, json) {
|
|
1490
|
+
await ctx.kv.set(KV_KEY, json);
|
|
1491
|
+
}
|
|
1492
|
+
function recordPathForSite() {
|
|
1493
|
+
return `/_emdash/api/plugins/${PLUGIN_ID}/record`;
|
|
1494
|
+
}
|
|
1495
|
+
function absolutePolicyHref(stored, ctx) {
|
|
1496
|
+
const r = resolvePolicyHref(stored, ctx).trim();
|
|
1497
|
+
if (!r) return null;
|
|
1498
|
+
try {
|
|
1499
|
+
if (new URL(r).protocol !== "https:") return null;
|
|
1500
|
+
return r;
|
|
1501
|
+
} catch {
|
|
1502
|
+
return null;
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
function clientFingerKey(request) {
|
|
1506
|
+
const cf = request.headers.get("cf-connecting-ip")?.trim();
|
|
1507
|
+
const xff = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
1508
|
+
const ua = (request.headers.get("user-agent") ?? "").slice(0, 120);
|
|
1509
|
+
return `${cf || xff || "unknown"}|${ua}`;
|
|
1510
|
+
}
|
|
1511
|
+
async function hashFinger(s) {
|
|
1512
|
+
const data = new TextEncoder().encode(`emprivacy-rl:${s}`);
|
|
1513
|
+
const dig = await crypto.subtle.digest("SHA-256", data);
|
|
1514
|
+
return Array.from(new Uint8Array(dig)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
|
|
1515
|
+
}
|
|
1516
|
+
async function allowRecordWrite(ctx, request) {
|
|
1517
|
+
const key = `consent:rl:${rateHourKey()}:${await hashFinger(clientFingerKey(request))}`;
|
|
1518
|
+
try {
|
|
1519
|
+
const raw = await ctx.kv.get(key);
|
|
1520
|
+
const n = raw && /^\d{1,6}$/.test(raw) ? Number(raw) : 0;
|
|
1521
|
+
if (n >= RECORD_RATE_LIMIT) return false;
|
|
1522
|
+
await ctx.kv.set(key, String(n + 1));
|
|
1523
|
+
return true;
|
|
1524
|
+
} catch {
|
|
1525
|
+
return false;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
function publicRuntime(cfg, ctx, page) {
|
|
1529
|
+
const copy = resolvePublicCopy(cfg, page.locale);
|
|
1530
|
+
const hideBanner = cfg.hideBannerOnPolicyPages && isPolicyPagePath(page.path, cfg);
|
|
1531
|
+
return {
|
|
1532
|
+
bannerTitle: copy.bannerTitle,
|
|
1533
|
+
bannerMessage: copy.bannerMessage,
|
|
1534
|
+
privacyPolicyUrl: absolutePolicyHref(cfg.privacyPolicyUrl, ctx) ?? "",
|
|
1535
|
+
cookiePolicyUrl: cfg.cookiePolicyUrl ? absolutePolicyHref(cfg.cookiePolicyUrl, ctx) ?? "" : "",
|
|
1536
|
+
strictDefaults: cfg.strictDefaults,
|
|
1537
|
+
policyVersion: cfg.policyVersion,
|
|
1538
|
+
googleConsentMode: cfg.googleConsentMode,
|
|
1539
|
+
logConsent: cfg.logConsentToServer,
|
|
1540
|
+
recordPath: recordPathForSite(),
|
|
1541
|
+
embedCategory: cfg.embedCategory,
|
|
1542
|
+
gateEmbeds: cfg.gateEmbeds,
|
|
1543
|
+
hideBanner,
|
|
1544
|
+
theme: cfg.theme,
|
|
1545
|
+
ui: copy.ui,
|
|
1546
|
+
loader: buildAnalyticsLoader(cfg),
|
|
1547
|
+
marketingScripts: cfg.marketingScriptUrls.map((src) => ({
|
|
1548
|
+
src,
|
|
1549
|
+
integrity: cfg.scriptIntegrity[src] ?? null
|
|
1550
|
+
})),
|
|
1551
|
+
scriptHostAllowlist: cfg.scriptHostAllowlist,
|
|
1552
|
+
scriptIntegrity: cfg.scriptIntegrity,
|
|
1553
|
+
cookieMaxAge: cookieMaxAgeSeconds(cfg.cookieMaxAgeDays),
|
|
1554
|
+
vendors: buildVendorList(cfg)
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
function themeStyle() {
|
|
1558
|
+
return `<style id="emprivacy-style">
|
|
1559
|
+
#emprivacy-root{font-family:system-ui,sans-serif;font-size:14px;--emprivacy-bg:#111111;--emprivacy-text:#eeeeee;--emprivacy-accent:#3b82f6;--emprivacy-radius:6px}
|
|
1560
|
+
.emprivacy-bar{position:fixed;z-index:99999;left:0;right:0;bottom:0;background:var(--emprivacy-bg);color:var(--emprivacy-text);padding:16px 20px 20px;box-shadow:0 -4px 24px rgba(0,0,0,.25);max-height:45vh;overflow:auto}
|
|
1561
|
+
.emprivacy-title{margin:0 0 8px;font-size:1.1rem}
|
|
1562
|
+
.emprivacy-msg,.emprivacy-note{margin:0 0 10px;line-height:1.4;opacity:.95}
|
|
1563
|
+
.emprivacy-note{font-size:.85rem}
|
|
1564
|
+
.emprivacy-links a{color:var(--emprivacy-accent);margin-right:12px}
|
|
1565
|
+
.emprivacy-opts{margin:10px 0;border-top:1px solid color-mix(in srgb,var(--emprivacy-text) 20%,transparent);padding-top:10px}
|
|
1566
|
+
.emprivacy-switch{display:block;margin:6px 0}
|
|
1567
|
+
.emprivacy-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
|
|
1568
|
+
.emprivacy-btn{border-radius:var(--emprivacy-radius);border:1px solid color-mix(in srgb,var(--emprivacy-text) 35%,transparent);background:transparent;color:var(--emprivacy-text);padding:8px 12px;cursor:pointer}
|
|
1569
|
+
.emprivacy-btn-primary{background:var(--emprivacy-accent);border-color:var(--emprivacy-accent);color:#fff}
|
|
1570
|
+
.emprivacy-cookie-trigger{position:fixed;z-index:99998;left:16px;bottom:16px;width:48px;height:48px;border-radius:50%;border:1px solid color-mix(in srgb,var(--emprivacy-text) 35%,transparent);background:var(--emprivacy-bg);color:var(--emprivacy-text);box-shadow:0 2px 12px rgba(0,0,0,.2);cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center}
|
|
1571
|
+
.emprivacy-cookie-trigger:hover{filter:brightness(1.1)}
|
|
1572
|
+
.emprivacy-cookie-trigger:focus{outline:2px solid var(--emprivacy-accent);outline-offset:2px}
|
|
1573
|
+
.emprivacy-cookie-trigger[hidden]{display:none!important}
|
|
1574
|
+
.emprivacy-cookie-trigger-icon{font-size:1.35rem;line-height:1}
|
|
1575
|
+
.emprivacy-bar--reopen{z-index:100000}
|
|
1576
|
+
.emprivacy-vendors{margin:10px 0 0}
|
|
1577
|
+
.emprivacy-vendors ul{margin:8px 0 0;padding-left:1.2rem}
|
|
1578
|
+
</style>`;
|
|
1579
|
+
}
|
|
1580
|
+
function createPlugin() {
|
|
1581
|
+
return definePlugin({
|
|
1582
|
+
id: PLUGIN_ID,
|
|
1583
|
+
version: VERSION,
|
|
1584
|
+
capabilities: ["hooks.page-fragments:register"],
|
|
1585
|
+
storage: { consentEvents: { indexes: ["createdAt", "policyVersion"] } },
|
|
1586
|
+
admin: { pages: [{
|
|
1587
|
+
path: ADMIN_SETTINGS_PATH,
|
|
1588
|
+
label: "EmPrivacy",
|
|
1589
|
+
icon: "shield"
|
|
1590
|
+
}] },
|
|
1591
|
+
hooks: {
|
|
1592
|
+
"plugin:install": async (_e, ctx) => {
|
|
1593
|
+
if (!await ctx.kv.get("consent:config")) {
|
|
1594
|
+
await saveConfigString(ctx, JSON.stringify(normalizeConfig({})));
|
|
1595
|
+
ctx.log.info("EmPrivacy: seeded default configuration");
|
|
1596
|
+
}
|
|
1597
|
+
},
|
|
1598
|
+
"page:metadata": { handler: async (_e, ctx) => {
|
|
1599
|
+
const cfg = await loadConfig(ctx);
|
|
1600
|
+
const out = [];
|
|
1601
|
+
const privacyHref = absolutePolicyHref(cfg.privacyPolicyUrl, ctx);
|
|
1602
|
+
if (privacyHref) out.push({
|
|
1603
|
+
kind: "link",
|
|
1604
|
+
rel: "site.standard.document",
|
|
1605
|
+
href: privacyHref,
|
|
1606
|
+
key: "emprivacy:privacy"
|
|
1607
|
+
});
|
|
1608
|
+
const cookieHref = cfg.cookiePolicyUrl ? absolutePolicyHref(cfg.cookiePolicyUrl, ctx) : null;
|
|
1609
|
+
if (cookieHref) out.push({
|
|
1610
|
+
kind: "meta",
|
|
1611
|
+
name: "cookie-policy",
|
|
1612
|
+
content: cookieHref,
|
|
1613
|
+
key: "emprivacy:cookie-meta"
|
|
1614
|
+
});
|
|
1615
|
+
return out.length ? out : null;
|
|
1616
|
+
} },
|
|
1617
|
+
"page:fragments": { handler: async (e, ctx) => {
|
|
1618
|
+
const cfg = await loadConfig(ctx);
|
|
1619
|
+
const pr = publicRuntime(cfg, ctx, e.page);
|
|
1620
|
+
const style = {
|
|
1621
|
+
kind: "html",
|
|
1622
|
+
placement: "body:end",
|
|
1623
|
+
key: "emprivacy:style",
|
|
1624
|
+
html: themeStyle()
|
|
1625
|
+
};
|
|
1626
|
+
const shell = {
|
|
1627
|
+
kind: "html",
|
|
1628
|
+
placement: "body:end",
|
|
1629
|
+
key: "emprivacy:shell",
|
|
1630
|
+
html: `<div id="emprivacy-root" data-emprivacy="1"></div>`
|
|
1631
|
+
};
|
|
1632
|
+
const headScripts = [];
|
|
1633
|
+
if (cfg.googleConsentMode) headScripts.push({
|
|
1634
|
+
kind: "inline-script",
|
|
1635
|
+
placement: "head",
|
|
1636
|
+
key: "emprivacy:gcm-default",
|
|
1637
|
+
code: buildGoogleConsentHeadScript()
|
|
1638
|
+
});
|
|
1639
|
+
const boot = {
|
|
1640
|
+
kind: "inline-script",
|
|
1641
|
+
placement: "body:end",
|
|
1642
|
+
key: "emprivacy:boot",
|
|
1643
|
+
code: buildBodyBootstrap(pr)
|
|
1644
|
+
};
|
|
1645
|
+
return [
|
|
1646
|
+
...headScripts,
|
|
1647
|
+
style,
|
|
1648
|
+
shell,
|
|
1649
|
+
boot
|
|
1650
|
+
];
|
|
1651
|
+
} }
|
|
1652
|
+
},
|
|
1653
|
+
routes: {
|
|
1654
|
+
admin: { handler: async (ctx) => {
|
|
1655
|
+
const interaction = ctx.input;
|
|
1656
|
+
if (interaction?.type === "page_load" && interaction.page === ADMIN_SETTINGS_PATH) return buildSettingsPage(ctx);
|
|
1657
|
+
if (interaction?.type === "form_submit" && interaction.action_id === SAVE_ACTION_ID) {
|
|
1658
|
+
const v = interaction.values ?? {};
|
|
1659
|
+
const asBool = (x) => {
|
|
1660
|
+
if (x === true) return true;
|
|
1661
|
+
if (x === false) return false;
|
|
1662
|
+
if (typeof x === "string") return x === "true" || x === "on" || x === "1";
|
|
1663
|
+
return Boolean(x);
|
|
1664
|
+
};
|
|
1665
|
+
try {
|
|
1666
|
+
const next = assertValidSavedConfig({
|
|
1667
|
+
bannerTitle: String(v.banner_title ?? ""),
|
|
1668
|
+
bannerMessage: String(v.banner_message ?? ""),
|
|
1669
|
+
privacyPolicyUrl: String(v.privacy_url ?? ""),
|
|
1670
|
+
cookiePolicyUrl: String(v.cookie_url ?? ""),
|
|
1671
|
+
strictDefaults: asBool(v.strict_defaults),
|
|
1672
|
+
policyVersion: String(v.policy_version ?? ""),
|
|
1673
|
+
analyticsPlatform: String(v.analytics_platform ?? "cloudflare"),
|
|
1674
|
+
cloudflareToken: String(v.cloudflare_token ?? ""),
|
|
1675
|
+
analyticsId: String(v.analytics_id ?? ""),
|
|
1676
|
+
umamiScriptUrl: String(v.umami_script_url ?? ""),
|
|
1677
|
+
analyticsUrlsText: String(v.analytics_urls ?? ""),
|
|
1678
|
+
marketingUrlsText: String(v.marketing_urls ?? ""),
|
|
1679
|
+
scriptHostAllowlistText: String(v.script_host_allowlist ?? ""),
|
|
1680
|
+
cookieMaxAgeDays: String(v.cookie_max_age_days ?? "180"),
|
|
1681
|
+
googleConsentMode: asBool(v.google_cm),
|
|
1682
|
+
logConsentToServer: asBool(v.log_server),
|
|
1683
|
+
embedCategory: String(v.embed_category ?? "marketing"),
|
|
1684
|
+
gateEmbeds: asBool(v.gate_embeds),
|
|
1685
|
+
hideBannerOnPolicyPages: asBool(v.hide_on_policy),
|
|
1686
|
+
defaultLocale: String(v.default_locale ?? "en"),
|
|
1687
|
+
localeOverridesText: String(v.locale_overrides ?? ""),
|
|
1688
|
+
themeBg: String(v.theme_bg ?? ""),
|
|
1689
|
+
themeText: String(v.theme_text ?? ""),
|
|
1690
|
+
themeAccent: String(v.theme_accent ?? ""),
|
|
1691
|
+
themeRadius: String(v.theme_radius ?? "")
|
|
1692
|
+
});
|
|
1693
|
+
await saveConfigString(ctx, JSON.stringify(next));
|
|
1694
|
+
return {
|
|
1695
|
+
...await buildSettingsPage(ctx),
|
|
1696
|
+
toast: {
|
|
1697
|
+
message: "EmPrivacy settings saved.",
|
|
1698
|
+
type: "success"
|
|
1699
|
+
}
|
|
1700
|
+
};
|
|
1701
|
+
} catch (e) {
|
|
1702
|
+
const msg = e instanceof Error ? e.message : "Save failed.";
|
|
1703
|
+
return {
|
|
1704
|
+
...await buildSettingsPage(ctx),
|
|
1705
|
+
toast: {
|
|
1706
|
+
message: msg,
|
|
1707
|
+
type: "error"
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
return { blocks: [] };
|
|
1713
|
+
} },
|
|
1714
|
+
record: {
|
|
1715
|
+
public: true,
|
|
1716
|
+
input: recordInput,
|
|
1717
|
+
handler: async (ctx) => {
|
|
1718
|
+
if (ctx.request.method !== "POST") return new Response("method_not_allowed", { status: 405 });
|
|
1719
|
+
let expectedOrigin;
|
|
1720
|
+
try {
|
|
1721
|
+
expectedOrigin = new URL(ctx.url("/")).origin;
|
|
1722
|
+
} catch {
|
|
1723
|
+
return new Response("bad_request", { status: 400 });
|
|
1724
|
+
}
|
|
1725
|
+
const csrf = assertSameOriginMutation(ctx.request, expectedOrigin);
|
|
1726
|
+
if (csrf) return csrf;
|
|
1727
|
+
if (!(ctx.request.headers.get("content-type") ?? "").toLowerCase().includes("application/json")) return new Response("unsupported_media_type", { status: 415 });
|
|
1728
|
+
const cfg = await loadConfig(ctx);
|
|
1729
|
+
if (!cfg.logConsentToServer) return {
|
|
1730
|
+
ok: false,
|
|
1731
|
+
reason: "logging_disabled"
|
|
1732
|
+
};
|
|
1733
|
+
const input = ctx.input;
|
|
1734
|
+
if (input.policyVersion !== cfg.policyVersion) return new Response("policy_version_mismatch", { status: 400 });
|
|
1735
|
+
if (!await allowRecordWrite(ctx, ctx.request)) return new Response("rate_limited", { status: 429 });
|
|
1736
|
+
const row = {
|
|
1737
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1738
|
+
policyVersion: input.policyVersion,
|
|
1739
|
+
functional: input.functional,
|
|
1740
|
+
analytics: input.analytics,
|
|
1741
|
+
marketing: input.marketing
|
|
1742
|
+
};
|
|
1743
|
+
try {
|
|
1744
|
+
if ((await ctx.storage.consentEvents.query({
|
|
1745
|
+
orderBy: { createdAt: "desc" },
|
|
1746
|
+
limit: RECORD_LOG_CAP
|
|
1747
|
+
})).items.length >= RECORD_LOG_CAP) return {
|
|
1748
|
+
ok: false,
|
|
1749
|
+
reason: "log_full"
|
|
1750
|
+
};
|
|
1751
|
+
} catch {
|
|
1752
|
+
return {
|
|
1753
|
+
ok: false,
|
|
1754
|
+
reason: "storage_unavailable"
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
await ctx.storage.consentEvents.put(`${Date.now()}-${Math.random().toString(36).slice(2)}`, row);
|
|
1758
|
+
return { ok: true };
|
|
1759
|
+
}
|
|
1760
|
+
},
|
|
1761
|
+
vendors: {
|
|
1762
|
+
public: true,
|
|
1763
|
+
handler: async (ctx) => {
|
|
1764
|
+
if (ctx.request.method !== "GET") return new Response("method_not_allowed", { status: 405 });
|
|
1765
|
+
return { vendors: buildVendorList(await loadConfig(ctx)) };
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
}
|
|
1771
|
+
async function buildSettingsPage(ctx) {
|
|
1772
|
+
const [cfg, latestPublished] = await Promise.all([loadConfig(ctx), fetchLatestPublishedVersion({ fetch: ctx.http?.fetch ?? globalThis.fetch })]);
|
|
1773
|
+
const installedVersion = ctx.plugin?.version ?? "2.1.0";
|
|
1774
|
+
const analyticsText = cfg.analyticsScriptUrls.map((src) => {
|
|
1775
|
+
const i = cfg.scriptIntegrity[src];
|
|
1776
|
+
return i ? `${src} ${i}` : src;
|
|
1777
|
+
}).join("\n");
|
|
1778
|
+
const marketingText = cfg.marketingScriptUrls.map((src) => {
|
|
1779
|
+
const i = cfg.scriptIntegrity[src];
|
|
1780
|
+
return i ? `${src} ${i}` : src;
|
|
1781
|
+
}).join("\n");
|
|
1782
|
+
const platform = cfg.analyticsProvider;
|
|
1783
|
+
const docsUrl = "https://github.com/EmPlugins/EmPrivacy/blob/main/docs/PLUGIN_SETTINGS.md";
|
|
1784
|
+
const vendorFields = buildVendorList(cfg).map((v) => ({
|
|
1785
|
+
label: `${v.name} · ${v.category}`,
|
|
1786
|
+
value: v.purpose
|
|
1787
|
+
}));
|
|
1788
|
+
const localeOverridesText = Object.keys(cfg.localeOverrides).length > 0 ? JSON.stringify(cfg.localeOverrides, null, 2) : "";
|
|
1789
|
+
const consentFields = await (async () => {
|
|
1790
|
+
try {
|
|
1791
|
+
return (await ctx.storage.consentEvents.query({
|
|
1792
|
+
orderBy: { createdAt: "desc" },
|
|
1793
|
+
limit: 15
|
|
1794
|
+
})).items.map((item) => {
|
|
1795
|
+
const d = item.data;
|
|
1796
|
+
const functional = typeof d.functional === "boolean" ? ` · functional ${d.functional ? "on" : "off"}` : "";
|
|
1797
|
+
return {
|
|
1798
|
+
label: d.createdAt,
|
|
1799
|
+
value: `policy ${d.policyVersion}${functional} · analytics ${d.analytics ? "on" : "off"} · marketing ${d.marketing ? "on" : "off"}`
|
|
1800
|
+
};
|
|
1801
|
+
});
|
|
1802
|
+
} catch {
|
|
1803
|
+
return [];
|
|
1804
|
+
}
|
|
1805
|
+
})();
|
|
1806
|
+
return { blocks: [
|
|
1807
|
+
{
|
|
1808
|
+
type: "header",
|
|
1809
|
+
text: "EmPrivacy — Cookie & consent"
|
|
1810
|
+
},
|
|
1811
|
+
{
|
|
1812
|
+
type: "fields",
|
|
1813
|
+
fields: pluginVersionFields(installedVersion, latestPublished)
|
|
1814
|
+
},
|
|
1815
|
+
{
|
|
1816
|
+
type: "context",
|
|
1817
|
+
text: pluginVersionNote(installedVersion, latestPublished)
|
|
1818
|
+
},
|
|
1819
|
+
{
|
|
1820
|
+
type: "context",
|
|
1821
|
+
text: `Documentation: ${docsUrl}`
|
|
1822
|
+
},
|
|
1823
|
+
{
|
|
1824
|
+
type: "context",
|
|
1825
|
+
text: "This plugin is a technical consent layer, not legal advice and not a privacy-policy generator. You still write and publish the legal pages. EmPrivacy does not scan your theme for leftover trackers, does not geo-locate visitors, and does not block first-party EmDash comments."
|
|
1826
|
+
},
|
|
1827
|
+
{
|
|
1828
|
+
type: "context",
|
|
1829
|
+
text: "Privacy / cookie links: use your EmDash **Page** public path (e.g. `/privacy`) or a full `https://…` URL. Banner chrome follows the page locale when a built-in translation exists (en, de, fr, es, it, nl, pt, pl)."
|
|
1830
|
+
},
|
|
1831
|
+
{ type: "divider" },
|
|
1832
|
+
{
|
|
1833
|
+
type: "form",
|
|
1834
|
+
blockId: "emprivacy-form",
|
|
1835
|
+
fields: [
|
|
1836
|
+
{
|
|
1837
|
+
type: "text_input",
|
|
1838
|
+
action_id: "banner_title",
|
|
1839
|
+
label: "Banner title (fallback locale)",
|
|
1840
|
+
initial_value: cfg.bannerTitle
|
|
1841
|
+
},
|
|
1842
|
+
{
|
|
1843
|
+
type: "text_input",
|
|
1844
|
+
action_id: "banner_message",
|
|
1845
|
+
label: "Short notice (fallback locale)",
|
|
1846
|
+
multiline: true,
|
|
1847
|
+
initial_value: cfg.bannerMessage
|
|
1848
|
+
},
|
|
1849
|
+
{
|
|
1850
|
+
type: "text_input",
|
|
1851
|
+
action_id: "default_locale",
|
|
1852
|
+
label: "Fallback locale code",
|
|
1853
|
+
placeholder: "en",
|
|
1854
|
+
initial_value: cfg.defaultLocale
|
|
1855
|
+
},
|
|
1856
|
+
{
|
|
1857
|
+
type: "text_input",
|
|
1858
|
+
action_id: "locale_overrides",
|
|
1859
|
+
label: "Optional title/message translations (JSON)",
|
|
1860
|
+
placeholder: "{\"de\":{\"bannerTitle\":\"Cookies & Datenschutz\",\"bannerMessage\":\"…\"}}\n",
|
|
1861
|
+
multiline: true,
|
|
1862
|
+
initial_value: localeOverridesText
|
|
1863
|
+
},
|
|
1864
|
+
{
|
|
1865
|
+
type: "text_input",
|
|
1866
|
+
action_id: "privacy_url",
|
|
1867
|
+
label: "Privacy policy — EmDash Page path or https URL",
|
|
1868
|
+
placeholder: "/privacy or https://…",
|
|
1869
|
+
initial_value: cfg.privacyPolicyUrl
|
|
1870
|
+
},
|
|
1871
|
+
{
|
|
1872
|
+
type: "text_input",
|
|
1873
|
+
action_id: "cookie_url",
|
|
1874
|
+
label: "Cookie policy (optional) — path or https URL",
|
|
1875
|
+
placeholder: "/cookies or https://…",
|
|
1876
|
+
initial_value: cfg.cookiePolicyUrl || ""
|
|
1877
|
+
},
|
|
1878
|
+
{
|
|
1879
|
+
type: "text_input",
|
|
1880
|
+
action_id: "policy_version",
|
|
1881
|
+
label: "Policy / consent version",
|
|
1882
|
+
initial_value: cfg.policyVersion
|
|
1883
|
+
},
|
|
1884
|
+
{
|
|
1885
|
+
type: "toggle",
|
|
1886
|
+
action_id: "strict_defaults",
|
|
1887
|
+
label: "Strict defaults (require opt-in for functional, analytics, and marketing)",
|
|
1888
|
+
initial_value: cfg.strictDefaults
|
|
1889
|
+
},
|
|
1890
|
+
{
|
|
1891
|
+
type: "toggle",
|
|
1892
|
+
action_id: "hide_on_policy",
|
|
1893
|
+
label: "Hide the first-visit banner on privacy and cookie policy pages",
|
|
1894
|
+
initial_value: cfg.hideBannerOnPolicyPages
|
|
1895
|
+
},
|
|
1896
|
+
{
|
|
1897
|
+
type: "radio",
|
|
1898
|
+
action_id: "analytics_platform",
|
|
1899
|
+
label: "Analytics platform",
|
|
1900
|
+
options: [
|
|
1901
|
+
{
|
|
1902
|
+
value: "cloudflare",
|
|
1903
|
+
label: "Cloudflare Web Analytics"
|
|
1904
|
+
},
|
|
1905
|
+
{
|
|
1906
|
+
value: "plausible",
|
|
1907
|
+
label: "Plausible"
|
|
1908
|
+
},
|
|
1909
|
+
{
|
|
1910
|
+
value: "fathom",
|
|
1911
|
+
label: "Fathom"
|
|
1912
|
+
},
|
|
1913
|
+
{
|
|
1914
|
+
value: "umami",
|
|
1915
|
+
label: "Umami"
|
|
1916
|
+
},
|
|
1917
|
+
{
|
|
1918
|
+
value: "simpleanalytics",
|
|
1919
|
+
label: "Simple Analytics"
|
|
1920
|
+
},
|
|
1921
|
+
{
|
|
1922
|
+
value: "ga4",
|
|
1923
|
+
label: "Google Analytics 4"
|
|
1924
|
+
},
|
|
1925
|
+
{
|
|
1926
|
+
value: "gtm",
|
|
1927
|
+
label: "Google Tag Manager (requires Marketing consent)"
|
|
1928
|
+
},
|
|
1929
|
+
{
|
|
1930
|
+
value: "none",
|
|
1931
|
+
label: "None"
|
|
1932
|
+
},
|
|
1933
|
+
{
|
|
1934
|
+
value: "custom",
|
|
1935
|
+
label: "Custom (https script URLs, one per line)"
|
|
1936
|
+
}
|
|
1937
|
+
],
|
|
1938
|
+
initial_value: platform
|
|
1939
|
+
},
|
|
1940
|
+
{
|
|
1941
|
+
type: "text_input",
|
|
1942
|
+
action_id: "cloudflare_token",
|
|
1943
|
+
label: "Cloudflare Web Analytics site token",
|
|
1944
|
+
placeholder: "Token from Web Analytics in the Cloudflare dashboard",
|
|
1945
|
+
initial_value: cfg.cloudflareWebAnalyticsToken,
|
|
1946
|
+
condition: {
|
|
1947
|
+
field: "analytics_platform",
|
|
1948
|
+
eq: "cloudflare"
|
|
1949
|
+
}
|
|
1950
|
+
},
|
|
1951
|
+
{
|
|
1952
|
+
type: "text_input",
|
|
1953
|
+
action_id: "analytics_id",
|
|
1954
|
+
label: "Analytics ID (domain, site ID, G-…, or GTM-…)",
|
|
1955
|
+
placeholder: "example.com / G-XXXX / GTM-XXXX",
|
|
1956
|
+
initial_value: cfg.analyticsId
|
|
1957
|
+
},
|
|
1958
|
+
{
|
|
1959
|
+
type: "text_input",
|
|
1960
|
+
action_id: "umami_script_url",
|
|
1961
|
+
label: "Umami script URL (https only)",
|
|
1962
|
+
placeholder: "https://cloud.umami.is/script.js",
|
|
1963
|
+
initial_value: cfg.umamiScriptUrl,
|
|
1964
|
+
condition: {
|
|
1965
|
+
field: "analytics_platform",
|
|
1966
|
+
eq: "umami"
|
|
1967
|
+
}
|
|
1968
|
+
},
|
|
1969
|
+
{
|
|
1970
|
+
type: "text_input",
|
|
1971
|
+
action_id: "analytics_urls",
|
|
1972
|
+
label: "Custom analytics script URLs (one https URL per line; optional trailing SRI: … sha384-…)",
|
|
1973
|
+
placeholder: "https://cdn.example/a.js sha384-…",
|
|
1974
|
+
multiline: true,
|
|
1975
|
+
initial_value: analyticsText,
|
|
1976
|
+
condition: {
|
|
1977
|
+
field: "analytics_platform",
|
|
1978
|
+
eq: "custom"
|
|
1979
|
+
}
|
|
1980
|
+
},
|
|
1981
|
+
{
|
|
1982
|
+
type: "text_input",
|
|
1983
|
+
action_id: "marketing_urls",
|
|
1984
|
+
label: "Marketing script URLs (one https URL per line; optional trailing SRI: … sha384-…)",
|
|
1985
|
+
placeholder: "https://… (one per line)",
|
|
1986
|
+
multiline: true,
|
|
1987
|
+
initial_value: marketingText
|
|
1988
|
+
},
|
|
1989
|
+
{
|
|
1990
|
+
type: "text_input",
|
|
1991
|
+
action_id: "script_host_allowlist",
|
|
1992
|
+
label: "Script host allowlist (optional; one hostname per line). When set, Custom analytics and marketing URLs must match.",
|
|
1993
|
+
placeholder: "cdn.example.com\njs.stripe.com",
|
|
1994
|
+
multiline: true,
|
|
1995
|
+
initial_value: cfg.scriptHostAllowlist.join("\n")
|
|
1996
|
+
},
|
|
1997
|
+
{
|
|
1998
|
+
type: "text_input",
|
|
1999
|
+
action_id: "cookie_max_age_days",
|
|
2000
|
+
label: "Consent cookie lifetime (days, 1–365, default 180)",
|
|
2001
|
+
placeholder: "180",
|
|
2002
|
+
initial_value: String(cfg.cookieMaxAgeDays)
|
|
2003
|
+
},
|
|
2004
|
+
{
|
|
2005
|
+
type: "toggle",
|
|
2006
|
+
action_id: "google_cm",
|
|
2007
|
+
label: "Google Consent Mode v2 (denied defaults in head; updates after choice)",
|
|
2008
|
+
initial_value: cfg.googleConsentMode
|
|
2009
|
+
},
|
|
2010
|
+
{
|
|
2011
|
+
type: "toggle",
|
|
2012
|
+
action_id: "gate_embeds",
|
|
2013
|
+
label: "Gate official EmDash embed blocks (YouTube, Vimeo, social, Gist)",
|
|
2014
|
+
initial_value: cfg.gateEmbeds
|
|
2015
|
+
},
|
|
2016
|
+
{
|
|
2017
|
+
type: "radio",
|
|
2018
|
+
action_id: "embed_category",
|
|
2019
|
+
label: "Embeds require this category",
|
|
2020
|
+
options: [{
|
|
2021
|
+
value: "marketing",
|
|
2022
|
+
label: "Marketing (recommended for YouTube / social)"
|
|
2023
|
+
}, {
|
|
2024
|
+
value: "functional",
|
|
2025
|
+
label: "Functional"
|
|
2026
|
+
}],
|
|
2027
|
+
initial_value: cfg.embedCategory
|
|
2028
|
+
},
|
|
2029
|
+
{
|
|
2030
|
+
type: "text_input",
|
|
2031
|
+
action_id: "theme_bg",
|
|
2032
|
+
label: "Banner background (hex)",
|
|
2033
|
+
placeholder: "#111111",
|
|
2034
|
+
initial_value: cfg.theme.bg
|
|
2035
|
+
},
|
|
2036
|
+
{
|
|
2037
|
+
type: "text_input",
|
|
2038
|
+
action_id: "theme_text",
|
|
2039
|
+
label: "Banner text (hex)",
|
|
2040
|
+
placeholder: "#eeeeee",
|
|
2041
|
+
initial_value: cfg.theme.text
|
|
2042
|
+
},
|
|
2043
|
+
{
|
|
2044
|
+
type: "text_input",
|
|
2045
|
+
action_id: "theme_accent",
|
|
2046
|
+
label: "Banner accent (hex)",
|
|
2047
|
+
placeholder: "#3b82f6",
|
|
2048
|
+
initial_value: cfg.theme.accent
|
|
2049
|
+
},
|
|
2050
|
+
{
|
|
2051
|
+
type: "text_input",
|
|
2052
|
+
action_id: "theme_radius",
|
|
2053
|
+
label: "Banner corner radius (0–24 px)",
|
|
2054
|
+
placeholder: "6",
|
|
2055
|
+
initial_value: String(cfg.theme.radiusPx)
|
|
2056
|
+
},
|
|
2057
|
+
{
|
|
2058
|
+
type: "toggle",
|
|
2059
|
+
action_id: "log_server",
|
|
2060
|
+
label: "Log consent choices to the server (minimal record; no IP; rate-limited; Origin/Sec-Fetch-Site required)",
|
|
2061
|
+
initial_value: cfg.logConsentToServer
|
|
2062
|
+
}
|
|
2063
|
+
],
|
|
2064
|
+
submit: {
|
|
2065
|
+
label: "Save settings",
|
|
2066
|
+
action_id: SAVE_ACTION_ID
|
|
2067
|
+
}
|
|
2068
|
+
},
|
|
2069
|
+
{ type: "divider" },
|
|
2070
|
+
{
|
|
2071
|
+
type: "header",
|
|
2072
|
+
text: "What this site uses"
|
|
2073
|
+
},
|
|
2074
|
+
{
|
|
2075
|
+
type: "context",
|
|
2076
|
+
text: "Generated from your current settings. Paste this into your cookie policy if you want a living vendor list. Also available at `/_emdash/api/plugins/emprivacy/vendors`. Tokens and script IDs are not included."
|
|
2077
|
+
},
|
|
2078
|
+
{
|
|
2079
|
+
type: "fields",
|
|
2080
|
+
fields: vendorFields
|
|
2081
|
+
},
|
|
2082
|
+
...cfg.logConsentToServer && consentFields.length > 0 ? [
|
|
2083
|
+
{ type: "divider" },
|
|
2084
|
+
{
|
|
2085
|
+
type: "header",
|
|
2086
|
+
text: "Recent consent records"
|
|
2087
|
+
},
|
|
2088
|
+
{
|
|
2089
|
+
type: "fields",
|
|
2090
|
+
fields: consentFields
|
|
2091
|
+
}
|
|
2092
|
+
] : []
|
|
2093
|
+
] };
|
|
2094
|
+
}
|
|
2095
|
+
//#endregion
|
|
2096
|
+
//#region src/astro/embed-resolve.ts
|
|
2097
|
+
/**
|
|
2098
|
+
* Validate official EmDash embed block payloads and produce a first-party
|
|
2099
|
+
* placeholder plan. Never returns a URL we did not construct or allowlist.
|
|
2100
|
+
*/
|
|
2101
|
+
const EMBED_BLOCK_TYPES = [
|
|
2102
|
+
"youtube",
|
|
2103
|
+
"vimeo",
|
|
2104
|
+
"tweet",
|
|
2105
|
+
"bluesky",
|
|
2106
|
+
"mastodon",
|
|
2107
|
+
"linkPreview",
|
|
2108
|
+
"gist"
|
|
2109
|
+
];
|
|
2110
|
+
const YT_ID = /^[A-Za-z0-9_-]{11}$/;
|
|
2111
|
+
const VIMEO_ID = /^\d{6,12}$/;
|
|
2112
|
+
const TWEET_ID = /^\d{5,20}$/;
|
|
2113
|
+
const BSKY_RKEY = /^[a-z0-9]{1,32}$/i;
|
|
2114
|
+
const BSKY_HANDLE = /^[a-z0-9.-]{1,253}$/i;
|
|
2115
|
+
const GIST_ID = /^[a-f0-9]{8,64}$/i;
|
|
2116
|
+
const GIST_USER = /^[A-Za-z0-9-]{1,39}$/;
|
|
2117
|
+
const YT_HOSTS = /* @__PURE__ */ new Set([
|
|
2118
|
+
"youtube.com",
|
|
2119
|
+
"www.youtube.com",
|
|
2120
|
+
"m.youtube.com",
|
|
2121
|
+
"youtu.be",
|
|
2122
|
+
"www.youtube-nocookie.com",
|
|
2123
|
+
"youtube-nocookie.com"
|
|
2124
|
+
]);
|
|
2125
|
+
const VIMEO_HOSTS = /* @__PURE__ */ new Set([
|
|
2126
|
+
"vimeo.com",
|
|
2127
|
+
"www.vimeo.com",
|
|
2128
|
+
"player.vimeo.com"
|
|
2129
|
+
]);
|
|
2130
|
+
const TWEET_HOSTS = /* @__PURE__ */ new Set([
|
|
2131
|
+
"twitter.com",
|
|
2132
|
+
"www.twitter.com",
|
|
2133
|
+
"x.com",
|
|
2134
|
+
"www.x.com",
|
|
2135
|
+
"mobile.twitter.com"
|
|
2136
|
+
]);
|
|
2137
|
+
const BSKY_HOSTS = /* @__PURE__ */ new Set(["bsky.app", "www.bsky.app"]);
|
|
2138
|
+
const GIST_HOSTS = /* @__PURE__ */ new Set(["gist.github.com"]);
|
|
2139
|
+
const POSTER_HOSTS = /* @__PURE__ */ new Set([
|
|
2140
|
+
"i.ytimg.com",
|
|
2141
|
+
"img.youtube.com",
|
|
2142
|
+
"i.vimeocdn.com"
|
|
2143
|
+
]);
|
|
2144
|
+
function hasUnsafeChars(s) {
|
|
2145
|
+
return /[\u0000-\u001F\u007F\s]/.test(s);
|
|
2146
|
+
}
|
|
2147
|
+
function parseHttpsUrl(raw) {
|
|
2148
|
+
const t = raw.trim();
|
|
2149
|
+
if (!t || t.length > 2048 || hasUnsafeChars(t)) return null;
|
|
2150
|
+
if (/%0d|%0a|%09|%0b|%0c|%20/i.test(t)) return null;
|
|
2151
|
+
try {
|
|
2152
|
+
const u = new URL(t);
|
|
2153
|
+
if (u.protocol !== "https:") return null;
|
|
2154
|
+
if (u.username || u.password) return null;
|
|
2155
|
+
if (u.hostname === "localhost" || u.hostname.endsWith(".localhost")) return null;
|
|
2156
|
+
return u;
|
|
2157
|
+
} catch {
|
|
2158
|
+
return null;
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
function hostOf(u) {
|
|
2162
|
+
return u.hostname.toLowerCase();
|
|
2163
|
+
}
|
|
2164
|
+
function youtubeId(raw) {
|
|
2165
|
+
const t = raw.trim();
|
|
2166
|
+
if (YT_ID.test(t)) return t;
|
|
2167
|
+
const u = parseHttpsUrl(t);
|
|
2168
|
+
if (!u || !YT_HOSTS.has(hostOf(u))) return null;
|
|
2169
|
+
if (hostOf(u) === "youtu.be") {
|
|
2170
|
+
const id = u.pathname.replace(/^\//, "").split("/")[0] ?? "";
|
|
2171
|
+
return YT_ID.test(id) ? id : null;
|
|
2172
|
+
}
|
|
2173
|
+
const v = u.searchParams.get("v");
|
|
2174
|
+
if (v && YT_ID.test(v)) return v;
|
|
2175
|
+
const parts = u.pathname.split("/").filter(Boolean);
|
|
2176
|
+
const embedIdx = parts.indexOf("embed");
|
|
2177
|
+
if (embedIdx >= 0) {
|
|
2178
|
+
const id = parts[embedIdx + 1] ?? "";
|
|
2179
|
+
return YT_ID.test(id) ? id : null;
|
|
2180
|
+
}
|
|
2181
|
+
const shortsIdx = parts.indexOf("shorts");
|
|
2182
|
+
if (shortsIdx >= 0) {
|
|
2183
|
+
const id = parts[shortsIdx + 1] ?? "";
|
|
2184
|
+
return YT_ID.test(id) ? id : null;
|
|
2185
|
+
}
|
|
2186
|
+
return null;
|
|
2187
|
+
}
|
|
2188
|
+
function vimeoId(raw) {
|
|
2189
|
+
const t = raw.trim();
|
|
2190
|
+
if (VIMEO_ID.test(t)) return t;
|
|
2191
|
+
const u = parseHttpsUrl(t);
|
|
2192
|
+
if (!u || !VIMEO_HOSTS.has(hostOf(u))) return null;
|
|
2193
|
+
const parts = u.pathname.split("/").filter(Boolean);
|
|
2194
|
+
const last = parts[parts.length - 1] ?? "";
|
|
2195
|
+
const videoIdx = parts.indexOf("video");
|
|
2196
|
+
if (videoIdx >= 0) {
|
|
2197
|
+
const id = parts[videoIdx + 1] ?? "";
|
|
2198
|
+
return VIMEO_ID.test(id) ? id : null;
|
|
2199
|
+
}
|
|
2200
|
+
return VIMEO_ID.test(last) ? last : null;
|
|
2201
|
+
}
|
|
2202
|
+
function tweetId(raw) {
|
|
2203
|
+
const t = raw.trim();
|
|
2204
|
+
if (TWEET_ID.test(t)) return t;
|
|
2205
|
+
const u = parseHttpsUrl(t);
|
|
2206
|
+
if (!u || !TWEET_HOSTS.has(hostOf(u))) return null;
|
|
2207
|
+
return /\/status(?:es)?\/(\d{5,20})(?:\/|$)/i.exec(u.pathname)?.[1] ?? null;
|
|
2208
|
+
}
|
|
2209
|
+
function blueskyHref(raw) {
|
|
2210
|
+
const u = parseHttpsUrl(raw);
|
|
2211
|
+
if (!u || !BSKY_HOSTS.has(hostOf(u))) return null;
|
|
2212
|
+
const m = /^\/profile\/([^/]+)\/post\/([^/]+)\/?$/.exec(u.pathname);
|
|
2213
|
+
if (!m) return null;
|
|
2214
|
+
const handle = m[1] ?? "";
|
|
2215
|
+
const rkey = m[2] ?? "";
|
|
2216
|
+
if (!BSKY_HANDLE.test(handle) || !BSKY_RKEY.test(rkey)) return null;
|
|
2217
|
+
return `https://bsky.app/profile/${handle}/post/${rkey}`;
|
|
2218
|
+
}
|
|
2219
|
+
function gistHref(raw) {
|
|
2220
|
+
const u = parseHttpsUrl(raw);
|
|
2221
|
+
if (!u || !GIST_HOSTS.has(hostOf(u))) return null;
|
|
2222
|
+
const parts = u.pathname.split("/").filter(Boolean);
|
|
2223
|
+
if (parts.length < 2) return null;
|
|
2224
|
+
const user = parts[0] ?? "";
|
|
2225
|
+
const id = (parts[1] ?? "").replace(/\.js$/i, "");
|
|
2226
|
+
if (!GIST_USER.test(user) || !GIST_ID.test(id)) return null;
|
|
2227
|
+
return `https://gist.github.com/${user}/${id}`;
|
|
2228
|
+
}
|
|
2229
|
+
function mastodonHref(raw) {
|
|
2230
|
+
const u = parseHttpsUrl(raw);
|
|
2231
|
+
if (!u) return null;
|
|
2232
|
+
if (!/^\/@[^/]+\/\d+\/?$/.test(u.pathname) && !/^\/users\/[^/]+\/statuses\/\d+\/?$/.test(u.pathname)) return null;
|
|
2233
|
+
return `https://${hostOf(u)}${u.pathname.replace(/\/$/, "")}`;
|
|
2234
|
+
}
|
|
2235
|
+
function posterUrl(raw, kind, mediaId) {
|
|
2236
|
+
if (kind === "youtube" && mediaId && YT_ID.test(mediaId)) return `https://i.ytimg.com/vi/${mediaId}/hqdefault.jpg`;
|
|
2237
|
+
if (typeof raw !== "string") return null;
|
|
2238
|
+
const u = parseHttpsUrl(raw);
|
|
2239
|
+
if (!u || !POSTER_HOSTS.has(hostOf(u))) return null;
|
|
2240
|
+
return u.href;
|
|
2241
|
+
}
|
|
2242
|
+
function isEmbedBlockType(s) {
|
|
2243
|
+
return EMBED_BLOCK_TYPES.includes(s);
|
|
2244
|
+
}
|
|
2245
|
+
function resolveEmbed(node) {
|
|
2246
|
+
if (!node || typeof node._type !== "string" || !isEmbedBlockType(node._type)) return null;
|
|
2247
|
+
if (typeof node.id !== "string") return null;
|
|
2248
|
+
const kind = node._type;
|
|
2249
|
+
const rawId = node.id.trim();
|
|
2250
|
+
if (!rawId || rawId.length > 2048) return null;
|
|
2251
|
+
if (kind === "youtube") {
|
|
2252
|
+
const id = youtubeId(rawId);
|
|
2253
|
+
if (!id) return null;
|
|
2254
|
+
return {
|
|
2255
|
+
kind,
|
|
2256
|
+
mode: "iframe",
|
|
2257
|
+
src: `https://www.youtube-nocookie.com/embed/${id}`,
|
|
2258
|
+
label: "YouTube",
|
|
2259
|
+
poster: posterUrl(node.poster, kind, id)
|
|
2260
|
+
};
|
|
2261
|
+
}
|
|
2262
|
+
if (kind === "vimeo") {
|
|
2263
|
+
const id = vimeoId(rawId);
|
|
2264
|
+
if (!id) return null;
|
|
2265
|
+
return {
|
|
2266
|
+
kind,
|
|
2267
|
+
mode: "iframe",
|
|
2268
|
+
src: `https://player.vimeo.com/video/${id}`,
|
|
2269
|
+
label: "Vimeo",
|
|
2270
|
+
poster: posterUrl(node.poster, kind, id)
|
|
2271
|
+
};
|
|
2272
|
+
}
|
|
2273
|
+
if (kind === "tweet") {
|
|
2274
|
+
const id = tweetId(rawId);
|
|
2275
|
+
if (!id) return null;
|
|
2276
|
+
return {
|
|
2277
|
+
kind,
|
|
2278
|
+
mode: "link",
|
|
2279
|
+
src: `https://x.com/i/web/status/${id}`,
|
|
2280
|
+
label: "Post on X",
|
|
2281
|
+
poster: null
|
|
2282
|
+
};
|
|
2283
|
+
}
|
|
2284
|
+
if (kind === "bluesky") {
|
|
2285
|
+
const href = blueskyHref(rawId);
|
|
2286
|
+
if (!href) return null;
|
|
2287
|
+
return {
|
|
2288
|
+
kind,
|
|
2289
|
+
mode: "link",
|
|
2290
|
+
src: href,
|
|
2291
|
+
label: "Bluesky post",
|
|
2292
|
+
poster: null
|
|
2293
|
+
};
|
|
2294
|
+
}
|
|
2295
|
+
if (kind === "mastodon") {
|
|
2296
|
+
const href = mastodonHref(rawId);
|
|
2297
|
+
if (!href) return null;
|
|
2298
|
+
return {
|
|
2299
|
+
kind,
|
|
2300
|
+
mode: "link",
|
|
2301
|
+
src: href,
|
|
2302
|
+
label: "Mastodon post",
|
|
2303
|
+
poster: null
|
|
2304
|
+
};
|
|
2305
|
+
}
|
|
2306
|
+
if (kind === "gist") {
|
|
2307
|
+
const href = gistHref(rawId);
|
|
2308
|
+
if (!href) return null;
|
|
2309
|
+
return {
|
|
2310
|
+
kind,
|
|
2311
|
+
mode: "link",
|
|
2312
|
+
src: href,
|
|
2313
|
+
label: "GitHub Gist",
|
|
2314
|
+
poster: null
|
|
2315
|
+
};
|
|
2316
|
+
}
|
|
2317
|
+
const href = parseHttpsUrl(rawId);
|
|
2318
|
+
if (!href) return null;
|
|
2319
|
+
return {
|
|
2320
|
+
kind: "linkPreview",
|
|
2321
|
+
mode: "link",
|
|
2322
|
+
src: href.href,
|
|
2323
|
+
label: href.hostname,
|
|
2324
|
+
poster: null
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
6
2327
|
//#endregion
|
|
7
2328
|
//#region src/index.ts
|
|
2329
|
+
var src_default = createPlugin;
|
|
8
2330
|
/**
|
|
9
|
-
* EmDash plugin descriptor — add to `plugins: []` in `emdash({ ... })` inside `astro.config`.
|
|
10
|
-
*
|
|
2331
|
+
* EmDash native plugin descriptor — add to `plugins: []` in `emdash({ ... })` inside `astro.config`.
|
|
2332
|
+
*
|
|
2333
|
+
* EmPrivacy uses `page:fragments` (banner + consent scripts) and optional Portable Text
|
|
2334
|
+
* embed placeholders, which only run for **native** plugins in `plugins: []`.
|
|
2335
|
+
* Do not place this descriptor in `sandboxed: []`.
|
|
11
2336
|
*/
|
|
12
2337
|
function emprivacyPlugin() {
|
|
13
2338
|
return {
|
|
14
2339
|
id: PLUGIN_ID,
|
|
15
2340
|
version: VERSION,
|
|
16
|
-
format: "
|
|
17
|
-
entrypoint: "@emplugins/emprivacy
|
|
18
|
-
|
|
19
|
-
storage: { consentEvents: { indexes: ["createdAt", "policyVersion"] } },
|
|
2341
|
+
format: "native",
|
|
2342
|
+
entrypoint: "@emplugins/emprivacy",
|
|
2343
|
+
componentsEntry: "@emplugins/emprivacy/astro",
|
|
20
2344
|
adminPages: [{
|
|
21
2345
|
path: "/settings",
|
|
22
2346
|
label: "EmPrivacy",
|
|
@@ -24,6 +2348,5 @@ function emprivacyPlugin() {
|
|
|
24
2348
|
}]
|
|
25
2349
|
};
|
|
26
2350
|
}
|
|
27
|
-
|
|
28
2351
|
//#endregion
|
|
29
|
-
export { COOKIE_NAME, DEFAULT_CONFIG, KV_KEY, assertValidCloudflareToken, emprivacyPlugin, isRootRelativeSitePath, isValidPolicyHrefInput, normalizeConfig, resolvePolicyHref };
|
|
2352
|
+
export { COOKIE_NAME, DEFAULT_CONFIG, EMBED_BLOCK_TYPES, KV_KEY, assertValidCloudflareToken, buildAnalyticsLoader, buildVendorList, createPlugin, src_default as default, emprivacyPlugin, isPolicyPagePath, isRootRelativeSitePath, isValidPolicyHrefInput, normalizeConfig, resolveEmbed, resolvePolicyHref };
|