1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'WebAppManifestAuditor/0.1 (+https://apify.com)';
7const MAX_BODY_BYTES = 2_000_000;
8
9function isPrivateIPv4(ip) {
10 const parts = ip.split('.').map(Number);
11 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
12 const [a, b] = parts;
13 return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 127 || a === 0 || (a === 169 && b === 254);
14}
15
16function isPrivateIPv6(ip) {
17 const normalized = ip.toLowerCase();
18 return normalized === '::1' || normalized.startsWith('fc') || normalized.startsWith('fd') || normalized.startsWith('fe80:');
19}
20
21export async function normalizeAndValidateUrl(rawUrl) {
22 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
23 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) throw new Error('Only HTTP and HTTPS URLs are supported');
24
25 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
26 const url = new URL(withScheme);
27 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
28 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
29
30 const literalType = net.isIP(url.hostname);
31 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
32 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
33
34 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
35 for (const record of records) {
36 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
37 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
38 }
39 return url;
40}
41
42function clampInteger(value, fallback, min, max) {
43 const parsed = Number(value);
44 if (!Number.isFinite(parsed)) return fallback;
45 return Math.min(Math.max(Math.trunc(parsed), min), max);
46}
47
48function attrsFromTag(tag) {
49 const attrs = {};
50 for (const match of tag.matchAll(/([\w:-]+)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/g)) {
51 attrs[match[1].toLowerCase()] = match[2].replace(/^['"]|['"]$/g, '').trim();
52 }
53 return attrs;
54}
55
56export function parseManifestLinks(html, baseUrl) {
57 const manifests = [];
58 const favicons = [];
59 for (const match of html.matchAll(/<link\b[^>]*>/gi)) {
60 const attrs = attrsFromTag(match[0]);
61 const rels = (attrs.rel || '').toLowerCase().split(/\s+/);
62 const href = attrs.href || '';
63 let resolvedHref = null;
64 try { resolvedHref = href ? new URL(href, baseUrl).href : null; } catch {}
65 if (rels.includes('manifest')) manifests.push({ href, resolvedHref });
66 if (rels.some((rel) => ['icon', 'shortcut', 'apple-touch-icon', 'mask-icon'].includes(rel))) {
67 favicons.push({ rel: attrs.rel || '', href, resolvedHref, sizes: attrs.sizes || null, type: attrs.type || null });
68 }
69 }
70 return { manifests, favicons };
71}
72
73function manifestSignals(manifest) {
74 const icons = Array.isArray(manifest?.icons) ? manifest.icons : [];
75 return {
76 hasName: Boolean(manifest?.name || manifest?.short_name),
77 hasStartUrl: Boolean(manifest?.start_url),
78 hasDisplay: Boolean(manifest?.display),
79 hasThemeColor: Boolean(manifest?.theme_color),
80 manifestIconCount: icons.length,
81 icons,
82 };
83}
84
85function scoreAudit({ status, manifestUrl, manifestStatus, manifestValid, signals, faviconCount, error }) {
86 const issues = [];
87 const recommendations = [];
88 let score = 100;
89
90 if (error) return { score: 0, grade: 'F', issues: [error], recommendations: ['Verify the URL is public, reachable, and returns HTML.'] };
91 if (!status || status >= 400) {
92 score -= 35;
93 issues.push(`HTTP status is ${status || 'unknown'}`);
94 recommendations.push('Audit a live 2xx HTML page.');
95 }
96 if (!manifestUrl) {
97 score -= 35;
98 issues.push('No web app manifest link found');
99 recommendations.push('Add <link rel="manifest" href="/site.webmanifest"> in the page head.');
100 } else if (!manifestValid) {
101 score -= 30;
102 issues.push(`Manifest was not valid JSON or not reachable (status ${manifestStatus || 'unknown'})`);
103 recommendations.push('Serve a valid JSON web app manifest from a public HTTPS URL.');
104 }
105 if (faviconCount === 0) {
106 score -= 20;
107 issues.push('No favicon or touch icon links found');
108 recommendations.push('Add favicon and apple-touch-icon links for browser tabs and saved shortcuts.');
109 }
110 if (manifestValid) {
111 if (!signals.hasName) { score -= 15; issues.push('Manifest is missing name or short_name'); recommendations.push('Add name and short_name to the manifest.'); }
112 if (!signals.hasStartUrl) { score -= 10; issues.push('Manifest is missing start_url'); recommendations.push('Add start_url to control launch behavior.'); }
113 if (!signals.hasDisplay) { score -= 10; issues.push('Manifest is missing display'); recommendations.push('Add display, commonly standalone or minimal-ui.'); }
114 if (!signals.hasThemeColor) { score -= 5; issues.push('Manifest is missing theme_color'); recommendations.push('Add theme_color for browser UI integration.'); }
115 if (signals.manifestIconCount === 0) { score -= 15; issues.push('Manifest has no icons'); recommendations.push('Add at least 192x192 and 512x512 icons.'); }
116 }
117
118 const bounded = Math.max(0, score);
119 const grade = bounded >= 90 ? 'A' : bounded >= 75 ? 'B' : bounded >= 60 ? 'C' : bounded >= 45 ? 'D' : 'F';
120 return { score: bounded, grade, issues: [...new Set(issues)], recommendations: [...new Set(recommendations)] };
121}
122
123async function fetchText(url, timeoutSeconds, accept) {
124 await normalizeAndValidateUrl(url.href);
125 const controller = new AbortController();
126 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
127 try {
128 const response = await fetch(url, { redirect: 'manual', signal: controller.signal, headers: { 'user-agent': USER_AGENT, accept } });
129 const location = response.headers.get('location');
130 if (location && response.status >= 300 && response.status < 400) {
131 const next = new URL(location, url.href);
132 await normalizeAndValidateUrl(next.href);
133 return fetchText(next, timeoutSeconds, accept);
134 }
135 return { status: response.status, finalUrl: url.href, contentType: response.headers.get('content-type') || '', body: (await response.text()).slice(0, MAX_BODY_BYTES) };
136 } finally {
137 clearTimeout(timeout);
138 }
139}
140
141export async function auditWebAppManifest(input) {
142 const startUrl = await normalizeAndValidateUrl(input.startUrl);
143 const timeoutSeconds = clampInteger(input.timeoutSeconds, 10, 3, 30);
144 const checkedAt = new Date().toISOString();
145 let page = null;
146 let manifestFetch = null;
147 let manifest = null;
148 let error = null;
149
150 try {
151 page = await fetchText(startUrl, timeoutSeconds, 'text/html,*/*;q=0.1');
152 const parsed = parseManifestLinks(page.body, page.finalUrl);
153 const manifestUrl = parsed.manifests[0]?.resolvedHref;
154 if (manifestUrl) {
155 manifestFetch = await fetchText(new URL(manifestUrl), timeoutSeconds, 'application/manifest+json,application/json,*/*;q=0.1');
156 if (manifestFetch.status >= 200 && manifestFetch.status < 400) manifest = JSON.parse(manifestFetch.body);
157 }
158 } catch (caught) {
159 error = caught.message;
160 }
161
162 const finalUrl = page?.finalUrl || startUrl.href;
163 const parsed = parseManifestLinks(page?.body || '', finalUrl);
164 const signals = manifestSignals(manifest);
165 const manifestUrl = parsed.manifests[0]?.resolvedHref || null;
166 const manifestValid = Boolean(manifest);
167 const scored = scoreAudit({ status: page?.status, manifestUrl, manifestStatus: manifestFetch?.status, manifestValid, signals, faviconCount: parsed.favicons.length, error });
168
169 return {
170 inputUrl: input.startUrl,
171 normalizedInputUrl: startUrl.href,
172 finalUrl,
173 status: page?.status || null,
174 ok: !error && page?.status >= 200 && page?.status < 400,
175 checkedAt,
176 manifestUrl,
177 manifestStatus: manifestFetch?.status || null,
178 manifestValid,
179 manifestName: manifest?.name || null,
180 manifestShortName: manifest?.short_name || null,
181 faviconCount: parsed.favicons.length,
182 favicons: parsed.favicons,
183 manifestIconCount: signals.manifestIconCount,
184 manifestIcons: signals.icons,
185 hasName: signals.hasName,
186 hasStartUrl: signals.hasStartUrl,
187 hasDisplay: signals.hasDisplay,
188 hasThemeColor: signals.hasThemeColor,
189 score: scored.score,
190 grade: scored.grade,
191 issues: scored.issues,
192 recommendations: scored.recommendations,
193 error,
194 };
195}
196
197const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
198
199if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
200 await Actor.init();
201 try {
202 const input = await Actor.getInput();
203 const result = await auditWebAppManifest(input || {});
204 await Actor.pushData(result);
205 await Actor.setValue('OUTPUT', result);
206 Actor.log.info('Web app manifest audit complete', { finalUrl: result.finalUrl, score: result.score, grade: result.grade });
207 } finally {
208 await Actor.exit();
209 }
210}