Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ static/ Static image assets
| `/about` | How apps are analysed, what a report means, jurisdiction labels, project background and contact |
| `/sitemap.xml`, `/robots.txt` | Crawler metadata |

The Danish versions of the public pages use the same paths under `/da/` (for
example `/da/` and `/da/analysis/:appId`). These paths are part of the website
cache key; analyser, API, health, sitemap and asset endpoints remain English-only.

The reverse lookup pages are served from an inverted index built by
`lib/reverseIndex.js` and cached under `CACHE_DIR` alongside the aggregate site
data. It is rebuilt whenever the set of stored analyses changes, so no extra
Expand Down Expand Up @@ -154,19 +158,19 @@ Cloudflare dashboard setup — all on the free plan, on the zone
*not* what you want:

1. **Security → WAF → Custom rules**, two rules with action *Managed Challenge*:
- `(http.request.uri.path eq "/search")`
- `(starts_with(http.request.uri.path, "/request/"))`
- `(http.request.uri.path eq "/search" or http.request.uri.path eq "/da/search")`
- `(starts_with(http.request.uri.path, "/request/") or starts_with(http.request.uri.path, "/da/request/"))`
Optionally a third as a backstop:
- `(http.request.method eq "POST" and starts_with(http.request.uri.path, "/analysis/"))`
- `(http.request.method eq "POST" and (starts_with(http.request.uri.path, "/analysis/") or starts_with(http.request.uri.path, "/da/analysis/")))`
2. **Security → Settings → Challenge Passage** sets how long one solved challenge
lasts.

### Cache purging

The published pages are cached at the Cloudflare edge, which cannot see the
origin invalidate its own cache, so uploading an analysis also purges the URLs
that analysis changed: the app's report, the homepage, `/statistics`,
`/trackers`, `/companies` and `/sitemap.xml`. The tracker and company pages it
that analysis changed in both languages: the app's report, the homepage,
`/statistics`, `/trackers`, `/companies` and `/sitemap.xml`. The tracker and company pages it
also changes are left to expire on their own, because purge-by-URL takes at
most 30 URLs per call.

Expand Down
5 changes: 5 additions & 0 deletions lib/cloudflarePurge.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,15 @@ function analysisUrls(baseUrl, appId) {

return [
`${base}/analysis/${encodeURIComponent(appId)}`,
`${base}/da/analysis/${encodeURIComponent(appId)}`,
`${base}/`,
`${base}/da/`,
`${base}/statistics`,
`${base}/da/statistics`,
`${base}/trackers`,
`${base}/da/trackers`,
`${base}/companies`,
`${base}/da/companies`,
`${base}/sitemap.xml`
];
}
Expand Down
557 changes: 557 additions & 0 deletions lib/i18n.js

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions public/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ button { cursor: pointer; }
.site-nav a:hover, .site-footer a:hover { color: var(--brand); text-decoration: underline; }
.site-nav a.is-current { color: var(--brand-dark); font-weight: 750; text-decoration: underline; }

.language-toggle {
align-items: center;
display: flex;
flex-shrink: 0;
gap: .15rem;
font-size: .78rem;
}
.language-toggle a {
border: 1px solid transparent;
border-radius: var(--radius);
color: var(--muted);
padding: .25rem .35rem;
text-decoration: none;
}
.language-toggle a:hover { color: var(--brand); text-decoration: underline; }
.language-toggle a.is-current { border-color: var(--border-strong); color: var(--brand-dark); font-weight: 750; }

.nav-search {
align-items: center;
display: flex;
Expand Down
5 changes: 4 additions & 1 deletion public/js/filter.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
var rows = Array.prototype.slice.call(table.querySelectorAll('tbody tr'));
var empty = document.getElementById('directory-empty');
var count = document.getElementById('directory-count');
var danish = document.documentElement.lang.toLowerCase().indexOf('da') === 0;

function apply() {
var term = input.value.trim().toLowerCase();
Expand All @@ -24,7 +25,9 @@
});

if (empty) empty.hidden = visible > 0;
if (count) count.textContent = visible + ' entr' + (visible === 1 ? 'y' : 'ies');
if (count) count.textContent = danish
? visible + ' ' + (visible === 1 ? 'post' : 'poster')
: visible + ' entr' + (visible === 1 ? 'y' : 'ies');
}

input.addEventListener('input', apply);
Expand Down
15 changes: 11 additions & 4 deletions public/js/site.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
var rows = Array.prototype.slice.call(document.querySelectorAll('.tracker-list .tracker-row'));
var count = document.getElementById('tracker-result-count');
var empty = document.getElementById('tracker-no-results');
var danish = document.documentElement.lang.toLowerCase().indexOf('da') === 0;

function plural(value, singular, pluralForm) {
return value + ' ' + (value === 1 ? singular : pluralForm);
Expand Down Expand Up @@ -36,11 +37,17 @@
if (!count) return;

if (term === '') {
count.textContent = plural(totalThirdParty, 'third-party tracker shown', 'third-party trackers shown');
count.textContent = danish
? plural(totalThirdParty, 'tredjepartstracker vist', 'tredjepartstrackere vist')
: plural(totalThirdParty, 'third-party tracker shown', 'third-party trackers shown');
} else {
count.textContent = plural(visibleThirdParty, 'third-party tracker matches', 'third-party trackers match')
+ ' of ' + totalThirdParty
+ (visibleSystem > 0 ? '; ' + plural(visibleSystem, 'System API signature matches', 'System API signatures match') : '');
count.textContent = danish
? plural(visibleThirdParty, 'tredjepartstracker matcher', 'tredjepartstrackere matcher')
+ ' af ' + totalThirdParty
+ (visibleSystem > 0 ? '; ' + plural(visibleSystem, 'System-API-signatur matcher', 'System-API-signaturer matcher') : '')
: plural(visibleThirdParty, 'third-party tracker matches', 'third-party trackers match')
+ ' of ' + totalThirdParty
+ (visibleSystem > 0 ? '; ' + plural(visibleSystem, 'System API signature matches', 'System API signatures match') : '');
}
}

Expand Down
98 changes: 55 additions & 43 deletions routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const asyncHandler = require('../lib/asyncHandler');
const { buildReportMetadata, buildListingDetails } = require('../lib/appMetadata');
const { siteBaseUrl } = require('../lib/siteUrl');
const cloudflare = require('../lib/cloudflarePurge');
const { canonicalUrl } = require('../lib/i18n');

// Taken from https://reports.exodus-privacy.eu.org/api/trackers
const exodusTrackers = JSON.parse(fs.readFileSync('./exodusTrackers.json', 'utf-8'))
Expand Down Expand Up @@ -40,22 +41,29 @@ function requireValidAppId(req, res, next) {

function renderPublicError(res, status, message) {
res.set('X-Robots-Tag', 'noindex');
const translate = res.locals.t || ((value) => value);
return res.status(status).render('error', {
title: status === 404 ? 'Page not found' : 'Request unavailable',
title: translate(status === 404 ? 'Page not found' : 'Request unavailable'),
status,
message,
message: translate(message),
});
}

function renderAnalysisRequest(res, appId, { status = 200, error = null } = {}) {
res.set('X-Robots-Tag', 'noindex');
const translate = res.locals.t || ((value) => value);
return res.status(status).render('request-analysis', {
title: 'Request app analysis',
title: translate('Request app analysis'),
appId,
error,
error: error ? translate(error) : error,
});
}

function redirectPublic(res, status, path) {
const local = res.locals.localUrl || ((value) => value);
return res.redirect(status, local(path));
}

// ping from analyser in past hour?
router.use(function (req, res, next) {
res.locals.analyserOnline = lastPing > Date.now() - 1000*60*60;
Expand All @@ -76,11 +84,11 @@ router.use(function (req, res, next) {
const base = siteBaseUrl(req);
const path = req.path.length > 1 ? req.path.replace(/\/+$/, '') : req.path;

res.locals.siteName = SITE_NAME;
res.locals.siteName = res.locals.t(SITE_NAME);
res.locals.currentPath = req.path.toLowerCase();
res.locals.siteBaseUrl = base;
res.locals.canonicalUrl = base + path;
res.locals.pageDescription = DEFAULT_DESCRIPTION;
res.locals.canonicalUrl = canonicalUrl(base, path, res.locals.locale);
res.locals.pageDescription = res.locals.t(DEFAULT_DESCRIPTION);
res.locals.ogImage = null;
next();
});
Expand Down Expand Up @@ -319,11 +327,9 @@ router.get('/', asyncHandler(async (req, res) => {
try {
const data = await getSiteData();
return res.render('form', {
title: 'App Privacy Checker',
title: res.locals.t('App Privacy Checker'),
data: req.body,
pageDescription: `Search ${data.headlines.totalApps} analysed iOS apps to see `
+ 'which trackers they embed, which companies control them, and under '
+ 'which jurisdiction that tracking falls.',
pageDescription: res.locals.t('Search {{total}} analysed iOS apps to see which trackers they embed, which companies control them, and under which jurisdiction that tracking falls.', { total: data.headlines.totalApps }),
headlines: data.headlines,
appsWithMostTrackers: data.appsWithMostTrackers,
jurisdictionStats: data.jurisdictionStats,
Expand All @@ -332,7 +338,7 @@ router.get('/', asyncHandler(async (req, res) => {
} catch (err) {
console.error('Homepage error:', err.message);
return res.render('form', {
title: 'App Privacy Checker',
title: res.locals.t('App Privacy Checker'),
data: req.body,
headlines: null,
appsWithMostTrackers: [],
Expand Down Expand Up @@ -374,7 +380,7 @@ router.get('/statistics', asyncHandler(async (req, res) => {
} catch (err) {
console.error('Statistics error:', err.message);
return res.render('statistics', {
title: 'Detailed Statistics',
title: res.locals.t('Detailed statistics'),
data: req.body,
headlines: { totalApps: 0 },
jurisdictionStats: { totalApps: 0, classificationCounts: {}, classificationPcts: {}, topCompaniesSorted: [], categoriesSorted: [] },
Expand All @@ -394,11 +400,9 @@ router.get('/statistics', asyncHandler(async (req, res) => {
const linked = withLookupSlugs(data, index);

return res.render('statistics', {
title: 'Detailed Statistics',
title: res.locals.t('Detailed statistics'),
data: req.body,
pageDescription: `Tracking jurisdiction across ${data.headlines.totalApps} `
+ 'analysed iOS apps: the most prevalent trackers, the companies behind '
+ 'them, and how they break down by country and App Store category.',
pageDescription: res.locals.t('Tracking jurisdiction across {{total}} analysed iOS apps: the most prevalent trackers, the companies behind them, and how they break down by country and App Store category.', { total: data.headlines.totalApps }),
headlines: data.headlines,
jurisdictionStats: linked.jurisdictionStats,
jurisdictionMeta: jurisdiction.classificationMeta,
Expand Down Expand Up @@ -452,22 +456,22 @@ router.get('/search',
}));

res.render('form', {
title: 'Search app',
title: res.locals.t('Search app'),
errors: errors.array(),
data: req.query,
searchResults
});
} catch (err) {
console.log(err);
res.status(502).render('form', {
title: 'Search apps',
title: res.locals.t('Search apps title'),
data: req.query,
errors: [{ msg: 'Error while searching. Try again later.' }],
errors: [{ msg: res.locals.t('Error while searching. Try again later.') }],
});
}
} else {
res.render('form', {
title: 'Search app',
title: res.locals.t('Search app'),
errors: errors.array(),
data: req.query,
});
Expand All @@ -481,7 +485,7 @@ router.get('/search',
router.get('/request/:appId', requireValidAppId, asyncHandler(async (req, res) => {
const appId = req.params.appId;
const existing = await Apps.findApp(appId);
if (existing) return res.redirect(303, `/analysis/${existing.appid}`);
if (existing) return redirectPublic(res, 303, `/analysis/${existing.appid}`);

return renderAnalysisRequest(res, appId);
}));
Expand All @@ -492,7 +496,7 @@ router.get('/analysis/:appId', requireValidAppId, asyncHandler(async (req, res)
console.log('Fetching', appId);

let app = await Apps.findApp(appId);
if (!app) return res.redirect(303, `/request/${appId}`);
if (!app) return redirectPublic(res, 303, `/request/${appId}`);

app.reportMetadata = buildReportMetadata({
analysis: {
Expand Down Expand Up @@ -565,10 +569,16 @@ router.get('/analysis/:appId', requireValidAppId, asyncHandler(async (req, res)
// so a refreshed storefront title is not contradicted by the card.
const displayTitle = app.reportMetadata.title || app.details.title;
const pageDescription = trackerCount === null
? `Tracker analysis of ${displayTitle} for iOS.`
: `${trackerCount === 0 ? 'No trackers were' : `${trackerCount} tracker${trackerCount === 1 ? ' was' : 's were'}`}`
+ ` detected in ${displayTitle} for iOS`
+ (jurisdictionData && jurisdictionData.meta ? `: ${jurisdictionData.meta.label.toLowerCase()}.` : '.');
? res.locals.t('Tracker analysis of {{title}} for iOS.', { title: displayTitle })
: res.locals.t('{{prefix}} detected in {{title}} for iOS{{suffix}}', {
prefix: trackerCount === 0
? res.locals.t('No trackers were')
: res.locals.t(trackerCount === 1 ? '{{count}} tracker was' : '{{count}} trackers were', { count: trackerCount }),
title: displayTitle,
suffix: jurisdictionData && jurisdictionData.meta
? `: ${res.locals.t(jurisdictionData.meta.label)}.`
: '.',
});

res.render('form', {
title: displayTitle,
Expand All @@ -591,7 +601,7 @@ router.post('/analysis/:appId',
const requestedAppId = req.params.appId;
const existing = await Apps.findApp(requestedAppId);
if (existing)
return res.redirect(303, `/analysis/${existing.appid}`);
return redirectPublic(res, 303, `/analysis/${existing.appid}`);

let details = await Apps.findCachedAppStoreResult(requestedAppId);
let fetchedFromAppStore = false;
Expand Down Expand Up @@ -647,17 +657,15 @@ router.post('/analysis/:appId',
});
}

return res.redirect(303, `/analysis/${details.appId}`);
return redirectPublic(res, 303, `/analysis/${details.appId}`);
}));

// About page: what this service does, what a report does and does not mean,
// where the country labels come from, and who is behind it.
router.get('/about', (req, res) => {
res.render('about', {
title: 'About',
pageDescription: 'How this service analyses iOS apps for embedded trackers, '
+ 'what a report does and does not tell you, who runs it, and how to get '
+ 'in touch.',
title: res.locals.t('About'),
pageDescription: res.locals.t('How this service analyses iOS apps for embedded trackers, what a report does and does not tell you, who runs it, and how to get in touch.'),
jurisdictionMeta: jurisdiction.classificationMeta
});
});
Expand All @@ -681,17 +689,15 @@ function renderDirectory(kind) {
: index.companyList.map((slug) => index.companies[slug]);

res.render('directory', {
title: isTracker ? 'Tracker directory' : 'Company directory',
title: res.locals.t(isTracker ? 'Tracker directory' : 'Company directory'),
kind,
entries,
totalApps: index.totalApps,
trackedApps: index.trackedApps,
latestAnalysis: index.latestAnalysis,
pageDescription: isTracker
? `Every tracker detected across ${index.totalApps} analysed iOS apps, `
+ 'with the company and country behind it.'
: `Every company whose tracking code was detected across ${index.totalApps} `
+ 'analysed iOS apps, ranked by how many apps they reach.'
? res.locals.t('Every tracker detected across {{total}} analysed iOS apps, with the company and country behind it.', { total: index.totalApps })
: res.locals.t('Every company whose tracking code was detected across {{total}} analysed iOS apps, ranked by how many apps they reach.', { total: index.totalApps })
});
});
}
Expand Down Expand Up @@ -732,9 +738,15 @@ function renderLookup(kind) {
);

const attribution = entry.company || (isTracker ? null : entry.name);
const description = `${entry.name} was detected in ${entry.appCount} of `
+ `${index.totalApps} analysed iOS apps (${entry.pct}%)`
+ (attribution && entry.countryName ? `. Operated by ${attribution} (${entry.countryName}).` : '.');
const description = res.locals.t('{{name}} was detected in {{count}} of {{total}} analysed iOS apps ({{pct}}%){{suffix}}', {
name: entry.name,
count: entry.appCount,
total: index.totalApps,
pct: entry.pct,
suffix: attribution && entry.countryName
? res.locals.t('. Operated by {{company}} ({{country}}).', { company: attribution, country: entry.countryName })
: '.',
});

res.render('lookup', {
title: entry.name,
Expand All @@ -747,8 +759,8 @@ function renderLookup(kind) {
exodus: isTracker ? trackerNameToExodus[entry.name] : null,
companySlug: isTracker ? entry.companySlug : null,
pageDescription: description,
canonicalUrl: res.locals.canonicalUrl
+ (pagination.page > 1 ? `?page=${pagination.page}` : '')
canonicalUrl: canonicalUrl(siteBaseUrl(req), req.path, res.locals.locale,
pagination.page > 1 ? { page: pagination.page } : {})
});
});
}
Expand Down
Loading
Loading