117 lines
4.4 KiB
JavaScript
117 lines
4.4 KiB
JavaScript
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 };
|
|
};
|
|
|
|
const extractLinks = (message) =>
|
|
[...String(message || '').matchAll(/href="([^"]+)"/g)].map((match) => match[1]);
|
|
|
|
export const validateAndroidPrivacyDocument = (privacy) => {
|
|
if (!privacy || typeof privacy !== 'object') {
|
|
throw new Error('androidPrivacy.json must contain a JSON object');
|
|
}
|
|
if (privacy.prompt !== 'template') {
|
|
throw new Error('androidPrivacy.json must use prompt=template');
|
|
}
|
|
if (!/^[1-9]\d*$/.test(String(privacy.version || ''))) {
|
|
throw new Error('androidPrivacy.json version must be a positive numeric policy version');
|
|
}
|
|
if (privacy.hrefLoader !== 'system') {
|
|
throw new Error('androidPrivacy.json must open legal links with hrefLoader=system');
|
|
}
|
|
|
|
const primaryLinks = extractLinks(privacy.message);
|
|
const secondaryLinks = extractLinks(privacy.second?.message);
|
|
if (primaryLinks.length !== 2 || secondaryLinks.length !== 2) {
|
|
throw new Error('androidPrivacy.json must contain two legal links in both prompts');
|
|
}
|
|
|
|
const primary = validateLegalUrlPair(primaryLinks[0], primaryLinks[1]);
|
|
const secondary = validateLegalUrlPair(secondaryLinks[0], secondaryLinks[1]);
|
|
if (primary.termsUrl !== secondary.termsUrl || primary.privacyUrl !== secondary.privacyUrl) {
|
|
throw new Error('androidPrivacy.json primary and secondary prompts must use the same legal links');
|
|
}
|
|
return primary;
|
|
};
|
|
|
|
export const renderAndroidPrivacy = (templateText, termsValue, privacyValue) => {
|
|
const { termsUrl, privacyUrl } = validateLegalUrlPair(termsValue, privacyValue);
|
|
const rendered = String(templateText)
|
|
.replaceAll('__TERMS_URL__', termsUrl)
|
|
.replaceAll('__PRIVACY_URL__', privacyUrl);
|
|
const privacy = JSON.parse(rendered);
|
|
validateAndroidPrivacyDocument(privacy);
|
|
return `${JSON.stringify(privacy, null, 2)}\n`;
|
|
};
|