import { isIP } from 'node:net'; const PLACEHOLDER_PATTERN = /__|change[-_ ]?me|your[-_ ]?|example\.(com|org|net)/i; const RESERVED_HOSTNAME_PATTERN = /(?:^|\.)(?:example|invalid|test)$/i; const isPrivateIpv4 = (hostname) => { const parts = hostname.split('.').map(Number); if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) { return false; } return parts[0] === 0 || parts[0] === 10 || parts[0] === 127 || (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) || (parts[0] === 169 && parts[1] === 254) || (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || (parts[0] === 192 && parts[1] === 168) || (parts[0] === 192 && parts[1] === 0 && (parts[2] === 0 || parts[2] === 2)) || (parts[0] === 198 && (parts[1] === 18 || parts[1] === 19)) || (parts[0] === 198 && parts[1] === 51 && parts[2] === 100) || (parts[0] === 203 && parts[1] === 0 && parts[2] === 113) || parts[0] >= 224; }; const isPrivateHostname = (hostname) => { const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ''); const ipVersion = isIP(normalized); return normalized === 'localhost' || normalized.endsWith('.localhost') || (ipVersion === 6 && ( normalized === '::1' || /^f[cd]/.test(normalized) || normalized.startsWith('fe80:') )) || (ipVersion === 4 && isPrivateIpv4(normalized)); }; export const normalizeLegalUrl = (rawValue, label = 'legal URL') => { const value = String(rawValue || '').trim(); if (!value) { throw new Error(`${label} is required`); } if (PLACEHOLDER_PATTERN.test(value)) { throw new Error(`${label} must not use a placeholder or example domain`); } let parsed; try { parsed = new URL(value); } catch { throw new Error(`${label} must be a valid absolute URL`); } if (parsed.protocol !== 'https:') { throw new Error(`${label} must use HTTPS`); } if (parsed.username || parsed.password) { throw new Error(`${label} must not contain embedded credentials`); } if (RESERVED_HOSTNAME_PATTERN.test(parsed.hostname)) { throw new Error(`${label} must not use a reserved hostname`); } if (isPrivateHostname(parsed.hostname)) { throw new Error(`${label} must use a public hostname`); } return parsed.toString(); }; export const validateLegalUrlPair = (termsValue, privacyValue) => { const termsUrl = normalizeLegalUrl(termsValue, 'service agreement URL'); const privacyUrl = normalizeLegalUrl(privacyValue, 'privacy policy URL'); if (termsUrl === privacyUrl) { throw new Error('service agreement URL and privacy policy URL must be different'); } return { termsUrl, privacyUrl }; }; export const validateAndroidPrivacyDocument = (privacy) => { if (!privacy || typeof privacy !== 'object') { throw new Error('androidPrivacy.json must contain a JSON object'); } if (privacy.prompt !== 'none') { throw new Error('androidPrivacy.json must use prompt=none for the controlled internal App'); } if (!/^[1-9]\d*$/.test(String(privacy.version || ''))) { throw new Error('androidPrivacy.json version must be a positive numeric configuration version'); } for (const templateOnlyField of [ 'title', 'message', 'buttonAccept', 'buttonRefuse', 'hrefLoader', 'second', 'disagreeMode', 'styles' ]) { if (Object.hasOwn(privacy, templateOnlyField)) { throw new Error(`androidPrivacy.json prompt=none must not contain ${templateOnlyField}`); } } return { prompt: privacy.prompt, version: String(privacy.version) }; }; export const renderAndroidPrivacy = (templateText, termsValue, privacyValue) => { validateLegalUrlPair(termsValue, privacyValue); const privacy = JSON.parse(String(templateText)); validateAndroidPrivacyDocument(privacy); return `${JSON.stringify(privacy, null, 2)}\n`; };