@emplugins/emprivacy 2.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 +96 -50
- package/dist/index.d.mts +205 -30
- package/dist/index.mjs +1746 -290
- package/docs/DEVELOPMENT.md +2 -2
- package/docs/GETTING_STARTED.md +81 -77
- package/docs/PLUGIN_SETTINGS.md +121 -117
- package/docs/RELEASE_NOTES.md +13 -0
- package/docs/TESTING.md +19 -21
- package/package.json +10 -8
- package/src/astro/GatedEmbed.astro +70 -0
- package/src/astro/embed-resolve.ts +227 -0
- package/src/astro/index.ts +24 -0
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,407 @@
|
|
|
1
1
|
import { definePlugin } from "emdash";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
|
|
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
|
|
4
405
|
//#region src/config.ts
|
|
5
406
|
/**
|
|
6
407
|
* EmPrivacy KV document (`consent:config`) and shared helpers.
|
|
@@ -10,18 +411,40 @@ const COOKIE_NAME = "emprivacy_cc";
|
|
|
10
411
|
const PLUGIN_ID = "emprivacy";
|
|
11
412
|
const DEFAULT_CONFIG = {
|
|
12
413
|
bannerTitle: "Cookies & privacy",
|
|
13
|
-
bannerMessage: "We use cookies to run this site and optionally for analytics and marketing. You can accept or customize your choices.",
|
|
414
|
+
bannerMessage: "We use cookies to run this site and optionally for functional embeds, analytics, and marketing. You can accept or customize your choices.",
|
|
14
415
|
privacyPolicyUrl: "/privacy",
|
|
15
416
|
cookiePolicyUrl: "",
|
|
16
417
|
strictDefaults: true,
|
|
17
418
|
policyVersion: "1",
|
|
18
419
|
cloudflareWebAnalyticsToken: "",
|
|
19
420
|
analyticsProvider: "cloudflare",
|
|
421
|
+
analyticsId: "",
|
|
422
|
+
umamiScriptUrl: "",
|
|
20
423
|
analyticsScriptUrls: [],
|
|
21
424
|
marketingScriptUrls: [],
|
|
425
|
+
scriptIntegrity: {},
|
|
426
|
+
scriptHostAllowlist: [],
|
|
427
|
+
cookieMaxAgeDays: 180,
|
|
22
428
|
googleConsentMode: false,
|
|
23
|
-
logConsentToServer: false
|
|
429
|
+
logConsentToServer: false,
|
|
430
|
+
embedCategory: "marketing",
|
|
431
|
+
gateEmbeds: true,
|
|
432
|
+
hideBannerOnPolicyPages: true,
|
|
433
|
+
defaultLocale: "en",
|
|
434
|
+
localeOverrides: {},
|
|
435
|
+
theme: { ...DEFAULT_THEME }
|
|
24
436
|
};
|
|
437
|
+
const ANALYTICS_PROVIDERS = [
|
|
438
|
+
"cloudflare",
|
|
439
|
+
"none",
|
|
440
|
+
"custom",
|
|
441
|
+
"plausible",
|
|
442
|
+
"fathom",
|
|
443
|
+
"umami",
|
|
444
|
+
"simpleanalytics",
|
|
445
|
+
"ga4",
|
|
446
|
+
"gtm"
|
|
447
|
+
];
|
|
25
448
|
function hasUnsafeUrlChars(s) {
|
|
26
449
|
return /[\u0000-\u001F\u007F\s]/.test(s);
|
|
27
450
|
}
|
|
@@ -40,11 +463,14 @@ function isHttpsUrl(s) {
|
|
|
40
463
|
}
|
|
41
464
|
}
|
|
42
465
|
function invalidScriptUrlError(kind, u) {
|
|
43
|
-
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.
|
|
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}`;
|
|
44
467
|
return `Invalid ${kind} script URL (https only): ${u}`;
|
|
45
468
|
}
|
|
46
469
|
function isAnalyticsProvider(s) {
|
|
47
|
-
return s
|
|
470
|
+
return ANALYTICS_PROVIDERS.includes(s);
|
|
471
|
+
}
|
|
472
|
+
function isEmbedCategory(s) {
|
|
473
|
+
return s === "functional" || s === "marketing";
|
|
48
474
|
}
|
|
49
475
|
/** Reject characters that would break JSON or attributes; allow typical Cloudflare site token strings. */
|
|
50
476
|
function assertValidCloudflareToken(t) {
|
|
@@ -89,8 +515,34 @@ function assertLength(label, s, min, max) {
|
|
|
89
515
|
function assertOptionalLength(label, s, max) {
|
|
90
516
|
if (s.trim().length > max) throw new Error(`${label} is too long.`);
|
|
91
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
|
+
}
|
|
92
541
|
function normalizeConfig(raw) {
|
|
93
|
-
if (typeof raw !== "object" || raw === null) return {
|
|
542
|
+
if (typeof raw !== "object" || raw === null) return {
|
|
543
|
+
...DEFAULT_CONFIG,
|
|
544
|
+
theme: { ...DEFAULT_THEME }
|
|
545
|
+
};
|
|
94
546
|
const o = raw;
|
|
95
547
|
const analytics = Array.isArray(o.analyticsScriptUrls) ? o.analyticsScriptUrls.filter((x) => typeof x === "string") : DEFAULT_CONFIG.analyticsScriptUrls;
|
|
96
548
|
const marketing = Array.isArray(o.marketingScriptUrls) ? o.marketingScriptUrls.filter((x) => typeof x === "string") : DEFAULT_CONFIG.marketingScriptUrls;
|
|
@@ -100,6 +552,15 @@ function normalizeConfig(raw) {
|
|
|
100
552
|
if (isAnalyticsProvider(apCandidate)) analyticsProvider = apCandidate;
|
|
101
553
|
else if (analytics.length > 0) analyticsProvider = "custom";
|
|
102
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);
|
|
103
564
|
return {
|
|
104
565
|
bannerTitle: typeof o.bannerTitle === "string" ? o.bannerTitle : DEFAULT_CONFIG.bannerTitle,
|
|
105
566
|
bannerMessage: typeof o.bannerMessage === "string" ? o.bannerMessage : DEFAULT_CONFIG.bannerMessage,
|
|
@@ -109,12 +570,53 @@ function normalizeConfig(raw) {
|
|
|
109
570
|
policyVersion: typeof o.policyVersion === "string" && o.policyVersion.length > 0 ? o.policyVersion : DEFAULT_CONFIG.policyVersion,
|
|
110
571
|
cloudflareWebAnalyticsToken: cfTok,
|
|
111
572
|
analyticsProvider,
|
|
573
|
+
analyticsId: typeof o.analyticsId === "string" ? o.analyticsId : "",
|
|
574
|
+
umamiScriptUrl: typeof o.umamiScriptUrl === "string" ? o.umamiScriptUrl : "",
|
|
112
575
|
analyticsScriptUrls: analytics,
|
|
113
576
|
marketingScriptUrls: marketing,
|
|
577
|
+
scriptIntegrity,
|
|
578
|
+
scriptHostAllowlist,
|
|
579
|
+
cookieMaxAgeDays: normalizeCookieMaxAgeDays(o.cookieMaxAgeDays, DEFAULT_CONFIG.cookieMaxAgeDays),
|
|
114
580
|
googleConsentMode: typeof o.googleConsentMode === "boolean" ? o.googleConsentMode : DEFAULT_CONFIG.googleConsentMode,
|
|
115
|
-
logConsentToServer: typeof o.logConsentToServer === "boolean" ? o.logConsentToServer : DEFAULT_CONFIG.logConsentToServer
|
|
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
|
+
}
|
|
116
593
|
};
|
|
117
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
|
+
}
|
|
118
620
|
/** Validate admin-submitted fields; throws an Error with a short message on failure */
|
|
119
621
|
function assertValidSavedConfig(input) {
|
|
120
622
|
assertLength("Policy version", input.policyVersion, 1, 64);
|
|
@@ -128,27 +630,60 @@ function assertValidSavedConfig(input) {
|
|
|
128
630
|
if (cookiePolicyUrl.length > 2048) throw new Error("Cookie policy URL/path is too long.");
|
|
129
631
|
if (!cookiePolicyUrl) cookiePolicyUrl = DEFAULT_CONFIG.cookiePolicyUrl;
|
|
130
632
|
const ap = input.analyticsPlatform.trim() || "cloudflare";
|
|
131
|
-
if (!isAnalyticsProvider(ap)) throw new Error("Analytics: choose a supported
|
|
633
|
+
if (!isAnalyticsProvider(ap)) throw new Error("Analytics: choose a supported platform.");
|
|
132
634
|
const tokenTrim = input.cloudflareToken.trim();
|
|
133
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
|
+
}
|
|
134
662
|
const analyticsScriptUrls = [];
|
|
135
663
|
if (ap === "custom") {
|
|
136
664
|
const list = parseUrlList(input.analyticsUrlsText);
|
|
137
665
|
if (list.length > 50) throw new Error("Analytics script URL list is too long (max 50).");
|
|
138
|
-
for (const u of list)
|
|
139
|
-
if (u.length > 2048) throw new Error(invalidScriptUrlError("analytics", u));
|
|
140
|
-
if (!isHttpsUrl(u)) throw new Error(invalidScriptUrlError("analytics", u));
|
|
141
|
-
analyticsScriptUrls.push(u);
|
|
142
|
-
}
|
|
666
|
+
for (const u of list) analyticsScriptUrls.push(assertScriptLine("analytics", u));
|
|
143
667
|
}
|
|
144
668
|
const marketingScriptUrls = [];
|
|
145
669
|
const mlist = parseUrlList(input.marketingUrlsText);
|
|
146
670
|
if (mlist.length > 50) throw new Error("Marketing script URL list is too long (max 50).");
|
|
147
|
-
for (const u of mlist)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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);
|
|
151
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.");
|
|
152
687
|
return normalizeConfig({
|
|
153
688
|
bannerTitle: input.bannerTitle.trim() || DEFAULT_CONFIG.bannerTitle,
|
|
154
689
|
bannerMessage: input.bannerMessage.trim() || DEFAULT_CONFIG.bannerMessage,
|
|
@@ -158,73 +693,156 @@ function assertValidSavedConfig(input) {
|
|
|
158
693
|
policyVersion: input.policyVersion.trim(),
|
|
159
694
|
analyticsProvider: ap,
|
|
160
695
|
cloudflareWebAnalyticsToken: ap === "cloudflare" ? tokenTrim : "",
|
|
696
|
+
analyticsId: ap === "cloudflare" || ap === "custom" || ap === "none" ? "" : input.analyticsId.trim(),
|
|
697
|
+
umamiScriptUrl: ap === "umami" ? umamiScriptUrl : "",
|
|
161
698
|
analyticsScriptUrls: ap === "custom" ? analyticsScriptUrls : [],
|
|
162
699
|
marketingScriptUrls,
|
|
700
|
+
scriptIntegrity,
|
|
701
|
+
scriptHostAllowlist,
|
|
702
|
+
cookieMaxAgeDays,
|
|
163
703
|
googleConsentMode: input.googleConsentMode,
|
|
164
|
-
logConsentToServer: input.logConsentToServer
|
|
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
|
+
}
|
|
165
716
|
});
|
|
166
717
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
+
}
|
|
172
742
|
//#endregion
|
|
173
|
-
//#region src/
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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;
|
|
189
760
|
}
|
|
190
|
-
|
|
191
|
-
|
|
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
|
+
];
|
|
192
769
|
}
|
|
193
|
-
/**
|
|
194
|
-
function
|
|
195
|
-
|
|
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];
|
|
196
778
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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;
|
|
201
802
|
try {
|
|
202
|
-
|
|
203
|
-
|
|
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;
|
|
204
824
|
} catch {
|
|
825
|
+
cache = {
|
|
826
|
+
value: null,
|
|
827
|
+
expiresAt: now + FAILURE_CACHE_MS
|
|
828
|
+
};
|
|
205
829
|
return null;
|
|
206
830
|
}
|
|
207
831
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
832
|
+
//#endregion
|
|
833
|
+
//#region src/public-bootstrap.ts
|
|
211
834
|
/**
|
|
212
|
-
* Public-site bootstrap
|
|
213
|
-
*
|
|
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).
|
|
214
837
|
*/
|
|
215
838
|
function buildBodyBootstrap(pr) {
|
|
216
839
|
return `(function(){
|
|
217
|
-
var C=${
|
|
218
|
-
switch (c) {
|
|
219
|
-
case "<": return "\\u003c";
|
|
220
|
-
case ">": return "\\u003e";
|
|
221
|
-
case "&": return "\\u0026";
|
|
222
|
-
case "\u2028": return "\\u2028";
|
|
223
|
-
case "\u2029": return "\\u2029";
|
|
224
|
-
default: return c;
|
|
225
|
-
}
|
|
226
|
-
})};
|
|
840
|
+
var C=${jsonForHtmlScript(pr)};
|
|
227
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}\\/?$/;
|
|
228
846
|
function readCookie(){
|
|
229
847
|
try{
|
|
230
848
|
var m=document.cookie.match(new RegExp("(?:^|;\\\\s*)"+CN+"=([^;]*)"));
|
|
@@ -235,44 +853,96 @@ return v;
|
|
|
235
853
|
}
|
|
236
854
|
function writeCookie(val){
|
|
237
855
|
var secure=document.location.protocol==="https:"?"; Secure":"";
|
|
238
|
-
|
|
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;
|
|
239
858
|
}
|
|
859
|
+
function flagOk(x){return x===0||x===1||x===true||x===false;}
|
|
240
860
|
function parseState(s){
|
|
241
861
|
try{
|
|
242
862
|
var o=JSON.parse(s);
|
|
243
863
|
if(!o||typeof o!=="object")return null;
|
|
244
864
|
if(typeof o.v!=="string")return null;
|
|
245
865
|
if(o.v!==C.policyVersion)return null;
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
var mOk=(m===0||m===1||m===true||m===false);
|
|
249
|
-
if(!aOk||!mOk)return null;
|
|
250
|
-
return {v:o.v,a:!!a,m:!!m};
|
|
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};
|
|
251
868
|
}catch(e){return null;}
|
|
252
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
|
+
}
|
|
253
875
|
function needBanner(){
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
return false;
|
|
876
|
+
if(C.hideBanner)return false;
|
|
877
|
+
return !parseState(readCookie());
|
|
257
878
|
}
|
|
258
|
-
function
|
|
879
|
+
function parseHttps(raw){
|
|
259
880
|
try{
|
|
260
|
-
|
|
261
|
-
return
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
return
|
|
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;}
|
|
265
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;
|
|
266
898
|
}
|
|
267
|
-
function
|
|
268
|
-
|
|
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;
|
|
269
930
|
var e=document.createElement("script");
|
|
270
|
-
e.src=
|
|
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
|
+
}
|
|
271
941
|
document.head.appendChild(e);
|
|
272
942
|
}
|
|
273
943
|
function loadCloudflareBeacon(token){
|
|
274
944
|
var u="https://static.cloudflareinsights.com/beacon.min.js";
|
|
275
|
-
if(!token)return;
|
|
945
|
+
if(!token||typeof token!=="string"||token.length>256||/[\\s<>'"&]/.test(token))return;
|
|
276
946
|
if(alreadyScriptSrc(u))return;
|
|
277
947
|
var e=document.createElement("script");
|
|
278
948
|
e.src=u;
|
|
@@ -281,72 +951,121 @@ e.setAttribute("data-cf-beacon",JSON.stringify({token:token}));
|
|
|
281
951
|
e.referrerPolicy="no-referrer-when-downgrade";
|
|
282
952
|
document.head.appendChild(e);
|
|
283
953
|
}
|
|
284
|
-
function
|
|
285
|
-
|
|
286
|
-
if(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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);
|
|
290
968
|
}
|
|
291
969
|
}
|
|
292
|
-
|
|
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);
|
|
293
978
|
}
|
|
294
|
-
|
|
979
|
+
}
|
|
980
|
+
function applyScripts(st){
|
|
981
|
+
if(st.analytics) applyAnalytics();
|
|
982
|
+
if(st.marketing) applyMarketing();
|
|
983
|
+
}
|
|
984
|
+
function gtagUpdate(st){
|
|
295
985
|
if(!C.googleConsentMode||!window.gtag)return;
|
|
296
986
|
window.gtag("consent","update",{
|
|
297
|
-
analytics_storage:
|
|
298
|
-
ad_storage:
|
|
299
|
-
ad_user_data:
|
|
300
|
-
ad_personalization:
|
|
301
|
-
personalization_storage:
|
|
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"
|
|
302
994
|
});
|
|
303
995
|
}
|
|
304
|
-
function logServer(
|
|
996
|
+
function logServer(st){
|
|
305
997
|
if(!C.logConsent)return;
|
|
306
|
-
|
|
307
|
-
fetch(p,{
|
|
998
|
+
fetch(C.recordPath,{
|
|
308
999
|
method:"POST",
|
|
309
1000
|
credentials:"same-origin",
|
|
310
1001
|
headers:{"Content-Type":"application/json"},
|
|
311
|
-
body:JSON.stringify({policyVersion:C.policyVersion,analytics:!!
|
|
1002
|
+
body:JSON.stringify({policyVersion:C.policyVersion,functional:!!st.functional,analytics:!!st.analytics,marketing:!!st.marketing})
|
|
312
1003
|
}).catch(function(){});
|
|
313
1004
|
}
|
|
314
1005
|
function hide(el){if(el)el.setAttribute("hidden","");el&&(el.style.display="none");}
|
|
315
1006
|
function show(el){if(el)el.removeAttribute("hidden");el&&(el.style.display="");}
|
|
316
|
-
function
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
var trigger=document.createElement("button");
|
|
320
|
-
trigger.type="button";
|
|
321
|
-
trigger.className="emprivacy-cookie-trigger";
|
|
322
|
-
trigger.setAttribute("aria-label","Cookie and privacy settings");
|
|
323
|
-
var ic=document.createElement("span");
|
|
324
|
-
ic.setAttribute("aria-hidden","true");
|
|
325
|
-
ic.className="emprivacy-cookie-trigger-icon";
|
|
326
|
-
ic.appendChild(document.createTextNode("🍪"));
|
|
327
|
-
trigger.appendChild(ic);
|
|
328
|
-
function showTrigger(){
|
|
329
|
-
trigger.removeAttribute("hidden");
|
|
1007
|
+
function embedAllowed(st){
|
|
1008
|
+
if(!C.gateEmbeds)return false;
|
|
1009
|
+
return C.embedCategory==="functional"?!!st.functional:!!st.marketing;
|
|
330
1010
|
}
|
|
331
|
-
function
|
|
332
|
-
|
|
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);
|
|
333
1025
|
}
|
|
334
|
-
function
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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;
|
|
344
1064
|
}
|
|
345
1065
|
function addPolicyLinks(links){
|
|
346
1066
|
function addOne(href,text){
|
|
347
1067
|
if(!href)return;
|
|
348
|
-
|
|
349
|
-
if(!ok)return;
|
|
1068
|
+
if(!parseHttps(href))return;
|
|
350
1069
|
var a=document.createElement("a");
|
|
351
1070
|
a.href=href;
|
|
352
1071
|
a.rel="nofollow noopener";
|
|
@@ -354,20 +1073,106 @@ a.target="_blank";
|
|
|
354
1073
|
a.appendChild(document.createTextNode(text));
|
|
355
1074
|
links.appendChild(a);
|
|
356
1075
|
}
|
|
357
|
-
addOne(C.privacyPolicyUrl,
|
|
358
|
-
if(C.cookiePolicyUrl)addOne(C.cookiePolicyUrl,
|
|
1076
|
+
addOne(C.privacyPolicyUrl,C.ui.privacyPolicy);
|
|
1077
|
+
if(C.cookiePolicyUrl)addOne(C.cookiePolicyUrl,C.ui.cookiePolicy);
|
|
359
1078
|
}
|
|
360
|
-
function
|
|
361
|
-
if(
|
|
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){
|
|
362
1167
|
var st=parseState(readCookie());
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
var
|
|
366
|
-
|
|
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();
|
|
367
1172
|
var bar=document.createElement("div");
|
|
368
|
-
bar.className="emprivacy-bar emprivacy-bar--reopen";
|
|
1173
|
+
bar.className="emprivacy-bar"+(isReopen?" emprivacy-bar--reopen":"");
|
|
369
1174
|
bar.setAttribute("role","dialog");
|
|
370
|
-
bar.setAttribute("aria-modal","true");
|
|
1175
|
+
bar.setAttribute("aria-modal",isReopen?"true":"false");
|
|
371
1176
|
var title=document.createElement("h2");
|
|
372
1177
|
title.className="emprivacy-title";
|
|
373
1178
|
title.appendChild(document.createTextNode(C.bannerTitle));
|
|
@@ -379,132 +1184,399 @@ links.className="emprivacy-links";
|
|
|
379
1184
|
addPolicyLinks(links);
|
|
380
1185
|
var opts=document.createElement("div");
|
|
381
1186
|
opts.className="emprivacy-opts";
|
|
382
|
-
|
|
383
|
-
|
|
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);
|
|
384
1205
|
opts.appendChild(er.wrap);
|
|
385
1206
|
opts.appendChild(mr.wrap);
|
|
1207
|
+
addVendorDisclosure(opts);
|
|
386
1208
|
var actions=document.createElement("div");
|
|
387
1209
|
actions.className="emprivacy-actions";
|
|
388
|
-
function doReopenPersist(a,m){
|
|
389
|
-
writeCookie(JSON.stringify({v:C.policyVersion,a:a?1:0,m:m?1:0}));
|
|
390
|
-
logServer(a,m);
|
|
391
|
-
location.reload();
|
|
392
|
-
}
|
|
393
1210
|
var btnAll=document.createElement("button");
|
|
394
1211
|
btnAll.type="button";
|
|
395
1212
|
btnAll.className="emprivacy-btn emprivacy-btn-primary";
|
|
396
|
-
btnAll.appendChild(document.createTextNode(
|
|
397
|
-
btnAll.addEventListener("click",function(){
|
|
1213
|
+
btnAll.appendChild(document.createTextNode(C.ui.acceptAll));
|
|
1214
|
+
btnAll.addEventListener("click",function(){persist(true,true,true);});
|
|
398
1215
|
var btnRej=document.createElement("button");
|
|
399
1216
|
btnRej.type="button";
|
|
400
1217
|
btnRej.className="emprivacy-btn";
|
|
401
|
-
btnRej.appendChild(document.createTextNode(
|
|
402
|
-
btnRej.addEventListener("click",function(){
|
|
1218
|
+
btnRej.appendChild(document.createTextNode(C.ui.rejectNonEssential));
|
|
1219
|
+
btnRej.addEventListener("click",function(){persist(false,false,false);});
|
|
403
1220
|
var btnSave=document.createElement("button");
|
|
404
1221
|
btnSave.type="button";
|
|
405
1222
|
btnSave.className="emprivacy-btn emprivacy-btn-primary";
|
|
406
|
-
btnSave.
|
|
407
|
-
btnSave.
|
|
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{
|
|
408
1236
|
var btnClose=document.createElement("button");
|
|
409
1237
|
btnClose.type="button";
|
|
410
1238
|
btnClose.className="emprivacy-btn";
|
|
411
|
-
btnClose.appendChild(document.createTextNode(
|
|
1239
|
+
btnClose.appendChild(document.createTextNode(C.ui.close));
|
|
412
1240
|
btnClose.addEventListener("click",function(){
|
|
413
1241
|
if(bar.parentNode)bar.parentNode.removeChild(bar);
|
|
414
1242
|
showTrigger();
|
|
415
1243
|
});
|
|
416
|
-
actions.appendChild(btnAll);
|
|
417
|
-
actions.appendChild(btnRej);
|
|
418
|
-
actions.appendChild(btnSave);
|
|
419
1244
|
actions.appendChild(btnClose);
|
|
1245
|
+
}
|
|
1246
|
+
actions.appendChild(btnSave);
|
|
420
1247
|
bar.appendChild(title);
|
|
421
1248
|
bar.appendChild(msg);
|
|
422
1249
|
bar.appendChild(links);
|
|
423
1250
|
bar.appendChild(opts);
|
|
424
1251
|
bar.appendChild(actions);
|
|
425
|
-
|
|
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
|
+
}
|
|
426
1275
|
}
|
|
1276
|
+
};
|
|
427
1277
|
root.appendChild(trigger);
|
|
428
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();
|
|
429
1296
|
if(!needBanner()){
|
|
430
1297
|
var st=parseState(readCookie());
|
|
431
|
-
|
|
432
|
-
|
|
1298
|
+
if(st){
|
|
1299
|
+
applyScripts(st);
|
|
1300
|
+
gtagUpdate(st);
|
|
1301
|
+
hydrateEmbeds(st);
|
|
1302
|
+
}
|
|
433
1303
|
showTrigger();
|
|
434
1304
|
trigger.addEventListener("click",function(){openReopenPanel();});
|
|
1305
|
+
installApi();
|
|
435
1306
|
return;
|
|
436
1307
|
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
bar.className="emprivacy-bar";
|
|
441
|
-
bar.setAttribute("role","dialog");
|
|
442
|
-
bar.setAttribute("aria-modal","false");
|
|
443
|
-
var title=document.createElement("h2");
|
|
444
|
-
title.className="emprivacy-title";
|
|
445
|
-
title.appendChild(document.createTextNode(C.bannerTitle));
|
|
446
|
-
var msg=document.createElement("p");
|
|
447
|
-
msg.className="emprivacy-msg";
|
|
448
|
-
msg.appendChild(document.createTextNode(C.bannerMessage));
|
|
449
|
-
var links=document.createElement("div");
|
|
450
|
-
links.className="emprivacy-links";
|
|
451
|
-
addPolicyLinks(links);
|
|
452
|
-
var opts=document.createElement("div");
|
|
453
|
-
opts.className="emprivacy-opts";
|
|
454
|
-
opts.setAttribute("hidden","");
|
|
455
|
-
var er=mkRow("Analytics","emprivacy-a",aOn);
|
|
456
|
-
var mr=mkRow("Marketing","emprivacy-m",mOn);
|
|
457
|
-
opts.appendChild(er.wrap);
|
|
458
|
-
opts.appendChild(mr.wrap);
|
|
459
|
-
var actions=document.createElement("div");
|
|
460
|
-
actions.className="emprivacy-actions";
|
|
461
|
-
function persist(a,m){
|
|
462
|
-
writeCookie(JSON.stringify({v:C.policyVersion,a:a?1:0,m:m?1:0}));
|
|
463
|
-
logServer(a,m);
|
|
464
|
-
// For Cloudflare Web Analytics (and most tag-based analytics), injecting after load can miss the initial pageview.
|
|
465
|
-
// Reload ensures the script runs in the normal page lifecycle immediately after consent is persisted.
|
|
466
|
-
location.reload();
|
|
467
|
-
}
|
|
468
|
-
var btnAll=document.createElement("button");
|
|
469
|
-
btnAll.type="button";
|
|
470
|
-
btnAll.className="emprivacy-btn emprivacy-btn-primary";
|
|
471
|
-
btnAll.appendChild(document.createTextNode("Accept all"));
|
|
472
|
-
btnAll.addEventListener("click",function(){persist(true,true);});
|
|
473
|
-
var btnRej=document.createElement("button");
|
|
474
|
-
btnRej.type="button";
|
|
475
|
-
btnRej.className="emprivacy-btn";
|
|
476
|
-
btnRej.appendChild(document.createTextNode("Reject non-essential"));
|
|
477
|
-
btnRej.addEventListener("click",function(){persist(false,false);});
|
|
478
|
-
var btnCust=document.createElement("button");
|
|
479
|
-
btnCust.type="button";
|
|
480
|
-
btnCust.className="emprivacy-btn";
|
|
481
|
-
btnCust.appendChild(document.createTextNode("Customize"));
|
|
482
|
-
btnCust.addEventListener("click",function(){show(opts);btnSave.removeAttribute("hidden");});
|
|
483
|
-
var btnSave=document.createElement("button");
|
|
484
|
-
btnSave.type="button";
|
|
485
|
-
btnSave.className="emprivacy-btn emprivacy-btn-primary";
|
|
486
|
-
btnSave.setAttribute("hidden","");
|
|
487
|
-
btnSave.appendChild(document.createTextNode("Save choices"));
|
|
488
|
-
btnSave.addEventListener("click",function(){persist(!!er.box.checked,!!mr.box.checked);});
|
|
489
|
-
actions.appendChild(btnAll);
|
|
490
|
-
actions.appendChild(btnRej);
|
|
491
|
-
actions.appendChild(btnCust);
|
|
492
|
-
actions.appendChild(btnSave);
|
|
493
|
-
bar.appendChild(title);
|
|
494
|
-
bar.appendChild(msg);
|
|
495
|
-
bar.appendChild(links);
|
|
496
|
-
bar.appendChild(opts);
|
|
497
|
-
bar.appendChild(actions);
|
|
498
|
-
root.appendChild(bar);
|
|
1308
|
+
root.appendChild(buildPanel(false,""));
|
|
1309
|
+
trigger.addEventListener("click",function(){openReopenPanel();});
|
|
1310
|
+
installApi();
|
|
499
1311
|
}
|
|
500
1312
|
if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",mount);
|
|
501
1313
|
else mount();
|
|
502
1314
|
})();`;
|
|
503
1315
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
1465
|
+
//#region src/version.ts
|
|
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
|
+
}
|
|
508
1580
|
function createPlugin() {
|
|
509
1581
|
return definePlugin({
|
|
510
1582
|
id: PLUGIN_ID,
|
|
@@ -518,7 +1590,7 @@ function createPlugin() {
|
|
|
518
1590
|
}] },
|
|
519
1591
|
hooks: {
|
|
520
1592
|
"plugin:install": async (_e, ctx) => {
|
|
521
|
-
if (!await ctx.kv.get(
|
|
1593
|
+
if (!await ctx.kv.get("consent:config")) {
|
|
522
1594
|
await saveConfigString(ctx, JSON.stringify(normalizeConfig({})));
|
|
523
1595
|
ctx.log.info("EmPrivacy: seeded default configuration");
|
|
524
1596
|
}
|
|
@@ -542,47 +1614,14 @@ function createPlugin() {
|
|
|
542
1614
|
});
|
|
543
1615
|
return out.length ? out : null;
|
|
544
1616
|
} },
|
|
545
|
-
"page:fragments": { handler: async (
|
|
1617
|
+
"page:fragments": { handler: async (e, ctx) => {
|
|
546
1618
|
const cfg = await loadConfig(ctx);
|
|
547
|
-
const
|
|
548
|
-
const cookieResolved = cfg.cookiePolicyUrl ? absolutePolicyHref(cfg.cookiePolicyUrl, ctx) ?? "" : "";
|
|
549
|
-
const pr = {
|
|
550
|
-
bannerTitle: cfg.bannerTitle,
|
|
551
|
-
bannerMessage: cfg.bannerMessage,
|
|
552
|
-
privacyPolicyUrl: privacyResolved,
|
|
553
|
-
cookiePolicyUrl: cookieResolved,
|
|
554
|
-
strictDefaults: cfg.strictDefaults,
|
|
555
|
-
policyVersion: cfg.policyVersion,
|
|
556
|
-
analyticsProvider: cfg.analyticsProvider,
|
|
557
|
-
cloudflareToken: cfg.cloudflareWebAnalyticsToken,
|
|
558
|
-
analyticsScriptUrls: cfg.analyticsScriptUrls,
|
|
559
|
-
marketingScriptUrls: cfg.marketingScriptUrls,
|
|
560
|
-
googleConsentMode: cfg.googleConsentMode,
|
|
561
|
-
logConsent: cfg.logConsentToServer,
|
|
562
|
-
recordPath: recordPathForSite()
|
|
563
|
-
};
|
|
1619
|
+
const pr = publicRuntime(cfg, ctx, e.page);
|
|
564
1620
|
const style = {
|
|
565
1621
|
kind: "html",
|
|
566
1622
|
placement: "body:end",
|
|
567
1623
|
key: "emprivacy:style",
|
|
568
|
-
html:
|
|
569
|
-
#emprivacy-root{font-family:system-ui,sans-serif;font-size:14px}
|
|
570
|
-
.emprivacy-bar{position:fixed;z-index:99999;left:0;right:0;bottom:0;background:#111;color:#eee;padding:16px 20px 20px;box-shadow:0 -4px 24px rgba(0,0,0,.25);max-height:45vh;overflow:auto}
|
|
571
|
-
.emprivacy-title{margin:0 0 8px;font-size:1.1rem}
|
|
572
|
-
.emprivacy-msg{margin:0 0 10px;line-height:1.4;opacity:.95}
|
|
573
|
-
.emprivacy-links a{color:#8ecbff;margin-right:12px}
|
|
574
|
-
.emprivacy-opts{margin:10px 0;border-top:1px solid #333;padding-top:10px}
|
|
575
|
-
.emprivacy-switch{display:block;margin:6px 0}
|
|
576
|
-
.emprivacy-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
|
|
577
|
-
.emprivacy-btn{border-radius:6px;border:1px solid #555;background:#222;color:#eee;padding:8px 12px;cursor:pointer}
|
|
578
|
-
.emprivacy-btn-primary{background:#3b82f6;border-color:#3b82f6;color:#fff}
|
|
579
|
-
.emprivacy-cookie-trigger{position:fixed;z-index:99998;left:16px;bottom:16px;width:48px;height:48px;border-radius:50%;border:1px solid #555;background:#222;color:#e8b86d;box-shadow:0 2px 12px rgba(0,0,0,.2);cursor:pointer;padding:0;display:flex;align-items:center;justify-content:center}
|
|
580
|
-
.emprivacy-cookie-trigger:hover{background:#2a2a2a}
|
|
581
|
-
.emprivacy-cookie-trigger:focus{outline:2px solid #3b82f6;outline-offset:2px}
|
|
582
|
-
.emprivacy-cookie-trigger[hidden]{display:none!important}
|
|
583
|
-
.emprivacy-cookie-trigger-icon{font-size:1.35rem;line-height:1}
|
|
584
|
-
.emprivacy-bar--reopen{z-index:100000}
|
|
585
|
-
</style>`
|
|
1624
|
+
html: themeStyle()
|
|
586
1625
|
};
|
|
587
1626
|
const shell = {
|
|
588
1627
|
kind: "html",
|
|
@@ -633,10 +1672,23 @@ function createPlugin() {
|
|
|
633
1672
|
policyVersion: String(v.policy_version ?? ""),
|
|
634
1673
|
analyticsPlatform: String(v.analytics_platform ?? "cloudflare"),
|
|
635
1674
|
cloudflareToken: String(v.cloudflare_token ?? ""),
|
|
1675
|
+
analyticsId: String(v.analytics_id ?? ""),
|
|
1676
|
+
umamiScriptUrl: String(v.umami_script_url ?? ""),
|
|
636
1677
|
analyticsUrlsText: String(v.analytics_urls ?? ""),
|
|
637
1678
|
marketingUrlsText: String(v.marketing_urls ?? ""),
|
|
1679
|
+
scriptHostAllowlistText: String(v.script_host_allowlist ?? ""),
|
|
1680
|
+
cookieMaxAgeDays: String(v.cookie_max_age_days ?? "180"),
|
|
638
1681
|
googleConsentMode: asBool(v.google_cm),
|
|
639
|
-
logConsentToServer: asBool(v.log_server)
|
|
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 ?? "")
|
|
640
1692
|
});
|
|
641
1693
|
await saveConfigString(ctx, JSON.stringify(next));
|
|
642
1694
|
return {
|
|
@@ -663,15 +1715,15 @@ function createPlugin() {
|
|
|
663
1715
|
public: true,
|
|
664
1716
|
input: recordInput,
|
|
665
1717
|
handler: async (ctx) => {
|
|
666
|
-
if (ctx.request.method !== "POST") return {
|
|
1718
|
+
if (ctx.request.method !== "POST") return new Response("method_not_allowed", { status: 405 });
|
|
1719
|
+
let expectedOrigin;
|
|
667
1720
|
try {
|
|
668
|
-
|
|
669
|
-
if (origin) {
|
|
670
|
-
if (origin !== new URL(ctx.url("/")).origin) return new Response("forbidden", { status: 403 });
|
|
671
|
-
}
|
|
1721
|
+
expectedOrigin = new URL(ctx.url("/")).origin;
|
|
672
1722
|
} catch {
|
|
673
1723
|
return new Response("bad_request", { status: 400 });
|
|
674
1724
|
}
|
|
1725
|
+
const csrf = assertSameOriginMutation(ctx.request, expectedOrigin);
|
|
1726
|
+
if (csrf) return csrf;
|
|
675
1727
|
if (!(ctx.request.headers.get("content-type") ?? "").toLowerCase().includes("application/json")) return new Response("unsupported_media_type", { status: 415 });
|
|
676
1728
|
const cfg = await loadConfig(ctx);
|
|
677
1729
|
if (!cfg.logConsentToServer) return {
|
|
@@ -680,34 +1732,60 @@ function createPlugin() {
|
|
|
680
1732
|
};
|
|
681
1733
|
const input = ctx.input;
|
|
682
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 });
|
|
683
1736
|
const row = {
|
|
684
1737
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
685
1738
|
policyVersion: input.policyVersion,
|
|
1739
|
+
functional: input.functional,
|
|
686
1740
|
analytics: input.analytics,
|
|
687
1741
|
marketing: input.marketing
|
|
688
1742
|
};
|
|
689
1743
|
try {
|
|
690
1744
|
if ((await ctx.storage.consentEvents.query({
|
|
691
1745
|
orderBy: { createdAt: "desc" },
|
|
692
|
-
limit:
|
|
693
|
-
})).items.length >=
|
|
1746
|
+
limit: RECORD_LOG_CAP
|
|
1747
|
+
})).items.length >= RECORD_LOG_CAP) return {
|
|
694
1748
|
ok: false,
|
|
695
1749
|
reason: "log_full"
|
|
696
1750
|
};
|
|
697
|
-
} catch {
|
|
1751
|
+
} catch {
|
|
1752
|
+
return {
|
|
1753
|
+
ok: false,
|
|
1754
|
+
reason: "storage_unavailable"
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
698
1757
|
await ctx.storage.consentEvents.put(`${Date.now()}-${Math.random().toString(36).slice(2)}`, row);
|
|
699
1758
|
return { ok: true };
|
|
700
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
|
+
}
|
|
701
1767
|
}
|
|
702
1768
|
}
|
|
703
1769
|
});
|
|
704
1770
|
}
|
|
705
1771
|
async function buildSettingsPage(ctx) {
|
|
706
|
-
const cfg = await loadConfig(ctx);
|
|
707
|
-
const
|
|
708
|
-
const
|
|
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");
|
|
709
1782
|
const platform = cfg.analyticsProvider;
|
|
710
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) : "";
|
|
711
1789
|
const consentFields = await (async () => {
|
|
712
1790
|
try {
|
|
713
1791
|
return (await ctx.storage.consentEvents.query({
|
|
@@ -715,9 +1793,10 @@ async function buildSettingsPage(ctx) {
|
|
|
715
1793
|
limit: 15
|
|
716
1794
|
})).items.map((item) => {
|
|
717
1795
|
const d = item.data;
|
|
1796
|
+
const functional = typeof d.functional === "boolean" ? ` · functional ${d.functional ? "on" : "off"}` : "";
|
|
718
1797
|
return {
|
|
719
1798
|
label: d.createdAt,
|
|
720
|
-
value: `policy ${d.policyVersion} · analytics ${d.analytics ? "on" : "off"} · marketing ${d.marketing ? "on" : "off"}`
|
|
1799
|
+
value: `policy ${d.policyVersion}${functional} · analytics ${d.analytics ? "on" : "off"} · marketing ${d.marketing ? "on" : "off"}`
|
|
721
1800
|
};
|
|
722
1801
|
});
|
|
723
1802
|
} catch {
|
|
@@ -729,17 +1808,25 @@ async function buildSettingsPage(ctx) {
|
|
|
729
1808
|
type: "header",
|
|
730
1809
|
text: "EmPrivacy — Cookie & consent"
|
|
731
1810
|
},
|
|
1811
|
+
{
|
|
1812
|
+
type: "fields",
|
|
1813
|
+
fields: pluginVersionFields(installedVersion, latestPublished)
|
|
1814
|
+
},
|
|
1815
|
+
{
|
|
1816
|
+
type: "context",
|
|
1817
|
+
text: pluginVersionNote(installedVersion, latestPublished)
|
|
1818
|
+
},
|
|
732
1819
|
{
|
|
733
1820
|
type: "context",
|
|
734
1821
|
text: `Documentation: ${docsUrl}`
|
|
735
1822
|
},
|
|
736
1823
|
{
|
|
737
1824
|
type: "context",
|
|
738
|
-
text: "
|
|
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."
|
|
739
1826
|
},
|
|
740
1827
|
{
|
|
741
1828
|
type: "context",
|
|
742
|
-
text: "Privacy / cookie links: use your EmDash **Page** public path (e.g. `/privacy`
|
|
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)."
|
|
743
1830
|
},
|
|
744
1831
|
{ type: "divider" },
|
|
745
1832
|
{
|
|
@@ -749,16 +1836,31 @@ async function buildSettingsPage(ctx) {
|
|
|
749
1836
|
{
|
|
750
1837
|
type: "text_input",
|
|
751
1838
|
action_id: "banner_title",
|
|
752
|
-
label: "Banner title",
|
|
1839
|
+
label: "Banner title (fallback locale)",
|
|
753
1840
|
initial_value: cfg.bannerTitle
|
|
754
1841
|
},
|
|
755
1842
|
{
|
|
756
1843
|
type: "text_input",
|
|
757
1844
|
action_id: "banner_message",
|
|
758
|
-
label: "Short notice",
|
|
1845
|
+
label: "Short notice (fallback locale)",
|
|
759
1846
|
multiline: true,
|
|
760
1847
|
initial_value: cfg.bannerMessage
|
|
761
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
|
+
},
|
|
762
1864
|
{
|
|
763
1865
|
type: "text_input",
|
|
764
1866
|
action_id: "privacy_url",
|
|
@@ -782,9 +1884,15 @@ async function buildSettingsPage(ctx) {
|
|
|
782
1884
|
{
|
|
783
1885
|
type: "toggle",
|
|
784
1886
|
action_id: "strict_defaults",
|
|
785
|
-
label: "Strict defaults (require opt-in for analytics
|
|
1887
|
+
label: "Strict defaults (require opt-in for functional, analytics, and marketing)",
|
|
786
1888
|
initial_value: cfg.strictDefaults
|
|
787
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
|
+
},
|
|
788
1896
|
{
|
|
789
1897
|
type: "radio",
|
|
790
1898
|
action_id: "analytics_platform",
|
|
@@ -794,6 +1902,30 @@ async function buildSettingsPage(ctx) {
|
|
|
794
1902
|
value: "cloudflare",
|
|
795
1903
|
label: "Cloudflare Web Analytics"
|
|
796
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
|
+
},
|
|
797
1929
|
{
|
|
798
1930
|
value: "none",
|
|
799
1931
|
label: "None"
|
|
@@ -816,11 +1948,29 @@ async function buildSettingsPage(ctx) {
|
|
|
816
1948
|
eq: "cloudflare"
|
|
817
1949
|
}
|
|
818
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
|
+
},
|
|
819
1969
|
{
|
|
820
1970
|
type: "text_input",
|
|
821
1971
|
action_id: "analytics_urls",
|
|
822
|
-
label: "Custom analytics script URLs (one https URL per line
|
|
823
|
-
placeholder: "https
|
|
1972
|
+
label: "Custom analytics script URLs (one https URL per line; optional trailing SRI: … sha384-…)",
|
|
1973
|
+
placeholder: "https://cdn.example/a.js sha384-…",
|
|
824
1974
|
multiline: true,
|
|
825
1975
|
initial_value: analyticsText,
|
|
826
1976
|
condition: {
|
|
@@ -831,21 +1981,83 @@ async function buildSettingsPage(ctx) {
|
|
|
831
1981
|
{
|
|
832
1982
|
type: "text_input",
|
|
833
1983
|
action_id: "marketing_urls",
|
|
834
|
-
label: "Marketing script URLs (one https URL per line
|
|
1984
|
+
label: "Marketing script URLs (one https URL per line; optional trailing SRI: … sha384-…)",
|
|
835
1985
|
placeholder: "https://… (one per line)",
|
|
836
1986
|
multiline: true,
|
|
837
1987
|
initial_value: marketingText
|
|
838
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
|
+
},
|
|
839
2004
|
{
|
|
840
2005
|
type: "toggle",
|
|
841
2006
|
action_id: "google_cm",
|
|
842
2007
|
label: "Google Consent Mode v2 (denied defaults in head; updates after choice)",
|
|
843
2008
|
initial_value: cfg.googleConsentMode
|
|
844
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
|
+
},
|
|
845
2057
|
{
|
|
846
2058
|
type: "toggle",
|
|
847
2059
|
action_id: "log_server",
|
|
848
|
-
label: "Log consent choices to the server (minimal record; no IP
|
|
2060
|
+
label: "Log consent choices to the server (minimal record; no IP; rate-limited; Origin/Sec-Fetch-Site required)",
|
|
849
2061
|
initial_value: cfg.logConsentToServer
|
|
850
2062
|
}
|
|
851
2063
|
],
|
|
@@ -854,6 +2066,19 @@ async function buildSettingsPage(ctx) {
|
|
|
854
2066
|
action_id: SAVE_ACTION_ID
|
|
855
2067
|
}
|
|
856
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
|
+
},
|
|
857
2082
|
...cfg.logConsentToServer && consentFields.length > 0 ? [
|
|
858
2083
|
{ type: "divider" },
|
|
859
2084
|
{
|
|
@@ -867,16 +2092,247 @@ async function buildSettingsPage(ctx) {
|
|
|
867
2092
|
] : []
|
|
868
2093
|
] };
|
|
869
2094
|
}
|
|
870
|
-
|
|
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
|
+
}
|
|
871
2327
|
//#endregion
|
|
872
2328
|
//#region src/index.ts
|
|
873
2329
|
var src_default = createPlugin;
|
|
874
2330
|
/**
|
|
875
2331
|
* EmDash native plugin descriptor — add to `plugins: []` in `emdash({ ... })` inside `astro.config`.
|
|
876
2332
|
*
|
|
877
|
-
* EmPrivacy uses `page:fragments` (banner + consent scripts)
|
|
878
|
-
*
|
|
879
|
-
*
|
|
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: []`.
|
|
880
2336
|
*/
|
|
881
2337
|
function emprivacyPlugin() {
|
|
882
2338
|
return {
|
|
@@ -884,6 +2340,7 @@ function emprivacyPlugin() {
|
|
|
884
2340
|
version: VERSION,
|
|
885
2341
|
format: "native",
|
|
886
2342
|
entrypoint: "@emplugins/emprivacy",
|
|
2343
|
+
componentsEntry: "@emplugins/emprivacy/astro",
|
|
887
2344
|
adminPages: [{
|
|
888
2345
|
path: "/settings",
|
|
889
2346
|
label: "EmPrivacy",
|
|
@@ -891,6 +2348,5 @@ function emprivacyPlugin() {
|
|
|
891
2348
|
}]
|
|
892
2349
|
};
|
|
893
2350
|
}
|
|
894
|
-
|
|
895
2351
|
//#endregion
|
|
896
|
-
export { COOKIE_NAME, DEFAULT_CONFIG, KV_KEY, assertValidCloudflareToken, createPlugin, src_default as default, 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 };
|