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
4 changes: 3 additions & 1 deletion api/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ currently `dns-resolvers.js`, the country-annotated resolver list behind
Root-level `sentry-instrument.js` (loaded via `node --import` *before*
express, so ESM loader hooks can auto-instrument route tracing) does the
init; `backend-server.js` attaches `setupExpressErrorHandler` after the
routes. No `SENTRY_DSN_BACKEND` → `@sentry/node` never loads. Handlers
routes. No `SENTRY_DSN_BACKEND` → `@sentry/node` never loads;
`SENTRY_ENVIRONMENT=development` skips the init, so a local run reports
nothing (same rule the cron check-ins already follow). Handlers
never import Sentry or capture manually: uncaught throws and 5xx traces
are automatic; caught failures stay on the logger — a hook in
`common/logger.js` mirrors warn+ to Sentry Logs and elevates error+ to
Expand Down
1 change: 1 addition & 0 deletions api/data/dns-resolvers.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,5 @@ export const DNS_RESOLVERS = [
{ id: 'dns4eu', name: 'DNS4EU', country: 'EU', udp: '86.54.11.1' },
{ id: 'cznic', name: 'CZ.NIC ODVR', country: 'CZ', udp: '193.17.47.1' },
{ id: 'dnssb', name: 'DNS.SB', country: 'EU', udp: '185.222.222.222', doh: 'https://doh.dns.sb/dns-query?' },
{ id: 'kt', name: 'KT', country: 'KR', udp: '168.126.63.1' },
];
4 changes: 3 additions & 1 deletion frontend/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@ replaces the map, and Home clears it on unmount — shortcuts are home-route onl
### Error monitoring (Sentry) is env-gated and invisible to app code

No `VITE_SENTRY_DSN_FRONTEND` → no Sentry code in the bundle at all
(build-time-gated dynamic import, like `firebase-init.js`). Rules:
(build-time-gated dynamic import, like `firebase-init.js`). The same gate in
`main.js` skips the load under `import.meta.env.DEV`, so a `pnpm dev` run
reports nothing even with a DSN in `.env`. Rules:

- **Never import `@sentry/vue` in app code** — a static import drags the SDK
into the main bundle. All config lives in `sentry-init.js`.
Expand Down
48 changes: 19 additions & 29 deletions frontend/components/PrivacyPolicy.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { useMainStore } from '@/store';
import { isAnalyticsEnabled } from '@/utils/analytics';
import { isDocsConfigured } from '@/composables/use-docs-assistant.js';
import { useDocumentMeta } from '@/composables/use-document-meta.js';
import { fallbackChain } from '@/utils/locale-registry.js';
import { datasetLoaders, loadLocaleDataset } from '@/utils/locale-datasets.js';
import Footer from '@/components/Footer.vue';
import StandalonePageHeader from '@/components/StandalonePageHeader.vue';
import { Spinner } from '@/components/ui/spinner';
Expand Down Expand Up @@ -77,38 +77,28 @@ const isDocsAssistantEnabled = computed(() => isDocsConfigured && store.configs?
// data/tools.js), which keeps the section off deployments without it.
const isPersonaCheckEnabled = computed(() => store.configs?.originalSite === true);

// Privacy copy is loaded on demand per locale (mirrors the security-checklist
// dataset pattern), then merged into i18n so t() / tm() can resolve it.
// Discovered by glob, keyed by locale code; a locale with no file of its own
// resolves to the first one on its fallback chain that has one.
const privacyPacks = import.meta.glob('../locales/privacy/*.json');
const privacyLoaders = Object.fromEntries(
Object.entries(privacyPacks).map(([path, loader]) => [path.match(/([^/]+)\.json$/)[1], loader]),
);

const loaded = new Set();
// Privacy copy is loaded on demand per locale (same loader as the security
// checklist dataset), then merged into i18n so t() / tm() can resolve it.
// A pack is complete or absent (tests/locale-packs.test.js), so whichever
// pack the chain resolves to is merged under the ACTIVE locale — the page
// reveals with one language, never a mix, and never raw keys unless the whole
// chain is unreachable.
const privacyLoaders = datasetLoaders(import.meta.glob('../locales/privacy/*.json'));
const privacyCache = new Map();
const merged = new Set();
const ready = ref(false);

// Merge one locale's privacy copy into i18n (memoized). Does NOT touch `ready` —
// the caller decides when to reveal, so a background fallback load can't race the
// active locale and paint the wrong language.
const loadPrivacy = async (loc) => {
if (loaded.has(loc)) return;
const load = fallbackChain(loc).map((code) => privacyLoaders[code]).find(Boolean);
const { default: msgs } = await load();
mergeLocaleMessage(loc, msgs);
loaded.add(loc);
};

// Reveal only after the ACTIVE locale's copy is merged — otherwise a fallback
// load could resolve first and paint English (t() falling back) until the next
// re-render. The rest of the chain (covering any key the active locale might
// miss) loads in the background and doesn't gate the reveal.
watch(locale, async (loc) => {
ready.value = false;
await loadPrivacy(loc);
if (loc === locale.value) ready.value = true; // ignore a stale load if locale changed mid-flight
for (const code of fallbackChain(loc).slice(1)) loadPrivacy(code);
const pack = await loadLocaleDataset(privacyLoaders, loc, privacyCache);
if (loc !== locale.value) return; // stale load — the locale changed mid-flight
if (pack) {
if (!merged.has(loc)) mergeLocaleMessage(loc, pack.data);
merged.add(loc);
} else {
console.error('Privacy copy unavailable in every locale; rendering keys');
}
ready.value = true;
}, { immediate: true });

// Ordered section ids, gated on which collection actually happens here. The
Expand Down
15 changes: 9 additions & 6 deletions frontend/components/User.vue
Original file line number Diff line number Diff line change
Expand Up @@ -192,18 +192,17 @@ const refreshUserInfo = async () => {
}
};

// Fetch user info
// Fetch user info. `remoteUserInfoFetched` flips only on success, so a failed
// round stays refetchable from Nav / Achievements on their next open.
const getUserInfo = async () => {
if (remoteUserInfoFetched.value || !isSignedIn.value) return;
try {
const response = await authenticatedFetch(`/api/getuserinfo`);
const data = response;
store.remoteUserInfo = data;
store.remoteUserInfo = await authenticatedFetch(`/api/getuserinfo`);
store.remoteUserInfoFetched = true;
initUserAchievements();
} catch (error) {
console.error('Error fetching user info:', error);
}
store.remoteUserInfoFetched = true;
};

// Initialize user achievements
Expand Down Expand Up @@ -267,8 +266,12 @@ watch(() => triggerUserBenefits.value, (newVal) => {
if (newVal) openUserBenefits();
});

// One-shot trigger: cleared here so the next request from Nav / Achievements
// is a fresh false → true edge rather than a no-op write.
watch(() => triggerRemoteUserInfo.value, (newVal) => {
if (newVal) getUserInfo();
if (!newVal) return;
store.triggerRemoteUserInfo = false;
getUserInfo();
});

watch(() => triggerUpdateAchievements.value, (newVal) => {
Expand Down
27 changes: 15 additions & 12 deletions frontend/components/advanced-tools/SecurityChecklist.vue
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ import { useMainStore } from '@/store';
import { useI18n } from 'vue-i18n';
import { trackEvent } from '@/utils/analytics';
import { emitAppEvent } from '@/utils/app-events.js';
import { fallbackChain } from '@/utils/locale-registry.js';
import { datasetLoaders, loadLocaleDataset } from '@/utils/locale-datasets.js';
import { CircleProgressBar } from 'circle-progress.vue';
import VueMarkdown from 'vue-markdown-render';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
Expand Down Expand Up @@ -317,24 +317,27 @@ const { t, locale } = useI18n();

// The checklist dataset is large (~30 KB gzipped per language) and only this tool
// reads it, so it's loaded on demand for the active locale instead of being baked
// into the initial i18n bundle (see frontend/locales/i18n.js).
// Discovered by glob, keyed by locale code; a locale with no dataset of its
// own resolves to the first one on its fallback chain that has one.
const securityDataPacks = import.meta.glob('../../locales/security-checklist/*.json');
const securityDataLoaders = Object.fromEntries(
Object.entries(securityDataPacks).map(([path, loader]) => [path.match(/([^/]+)\.json$/)[1], loader]),
);
// into the initial i18n bundle (see frontend/locales/i18n.js). The shared loader
// walks the fallback chain, so a failed chunk degrades to the next language
// instead of a spinner that never stops.
const securityDataLoaders = datasetLoaders(import.meta.glob('../../locales/security-checklist/*.json'));
const securityDataCache = new Map();

const securityChecklist = ref(null);

// Fetch the active locale's dataset and (re)build the list. Nulling fullList first
// surfaces the template's existing loading state during the swap.
const loadSecurityChecklist = async () => {
fullList.value = null;
const load = fallbackChain(locale.value).map((code) => securityDataLoaders[code]).find(Boolean);
const { default: data } = await load();
securityChecklist.value = data;
fullList.value = initSecurityList(securityChecklist.value);
const loc = locale.value;
const pack = await loadLocaleDataset(securityDataLoaders, loc, securityDataCache);
if (loc !== locale.value) return; // stale load — the locale changed mid-flight
if (!pack) {
console.error('Security checklist dataset unavailable in every locale');
return;
}
securityChecklist.value = pack.data;
fullList.value = initSecurityList(pack.data);
};

const store = useMainStore();
Expand Down
5 changes: 3 additions & 2 deletions frontend/composables/use-screenshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ function freezeInfoMaskForCapture(root) {
return () => restorers.forEach((fn) => fn());
}

// Extension- or translator-injected cross-origin stylesheets make
// html-to-image's webfont embedding throw a SecurityError while reading
// Cross-origin stylesheets on `document.styleSheets` — mostly the docs
// assistant widget's, plus anything an extension or translator injected —
// make html-to-image's webfont embedding throw a SecurityError while reading
// `cssRules`. Retry once without font embedding — the live page has already
// loaded its fonts, so the rendered output is unaffected in practice.
export const toPngWithFontFallback = async (toPng, element, options) => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/data/changelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -1973,7 +1973,7 @@
},
{
"version": "v7.5.0",
"date": "Beta",
"date": "2026-09-03",
"content": [
{
"type": "add",
Expand Down
34 changes: 34 additions & 0 deletions frontend/data/connectivity-import-lists.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,24 @@ export const IMPORT_LISTS = [
{ id: 'clickup', name: 'ClickUp', url: 'https://clickup.com/favicon.ico' },
],
},
{
id: 'education',
emoji: '🎓',
members: [
{ id: 'coursera', name: 'Coursera', url: 'https://www.coursera.org/favicon.ico' },
{ id: 'edx', name: 'edX', url: 'https://www.edx.org/favicon.ico' },
{ id: 'khan-academy', name: 'Khan Academy', url: 'https://www.khanacademy.org/favicon.ico' },
{ id: 'udemy', name: 'Udemy', url: 'https://www.udemy.com/staticx/udemy/images/v8/favicon-32x32.png' },
{ id: 'duolingo', name: 'Duolingo', url: 'https://www.duolingo.com/robots.txt' },
{ id: 'codecademy', name: 'Codecademy', url: 'https://www.codecademy.com/favicon.ico' },
{ id: 'brilliant', name: 'Brilliant', url: 'https://brilliant.org/favicon.ico' },
{ id: 'skillshare', name: 'Skillshare', url: 'https://www.skillshare.com/favicon.ico' },
{ id: 'futurelearn', name: 'FutureLearn', url: 'https://www.futurelearn.com/favicon.ico' },
{ id: 'masterclass', name: 'MasterClass', url: 'https://www.masterclass.com/favicon-32x32.png' },
{ id: 'leetcode', name: 'LeetCode', url: 'https://leetcode.com/favicon.ico', iconDomain: 'assets.leetcode.com' },
{ id: 'mit-ocw', name: 'MIT OpenCourseWare', url: 'https://ocw.mit.edu/favicon.ico', iconDomain: 'mit.edu' },
],
},
{
id: 'streaming',
emoji: '🎬',
Expand Down Expand Up @@ -325,6 +343,22 @@ export const IMPORT_LISTS = [
{ id: 'heroku', name: 'Heroku', url: 'https://www.heroku.com/favicon.ico' },
],
},
{
id: 'finance',
emoji: '💳',
members: [
{ id: 'paypal', name: 'PayPal', url: 'https://www.paypal.com/favicon.ico' },
{ id: 'wise', name: 'Wise', url: 'https://wise.com/robots.txt' },
{ id: 'revolut', name: 'Revolut', url: 'https://www.revolut.com/robots.txt' },
{ id: 'stripe', name: 'Stripe', url: 'https://stripe.com/favicon.ico' },
{ id: 'visa', name: 'Visa', url: 'https://www.visa.com/robots.txt' },
{ id: 'mastercard', name: 'Mastercard', url: 'https://developer.mastercard.com/favicon.ico', iconDomain: 'www.mastercard.com', siteUrl: 'https://www.mastercard.com' },
{ id: 'western-union', name: 'Western Union', url: 'https://www.westernunion.com/robots.txt' },
{ id: 'payoneer', name: 'Payoneer', url: 'https://www.payoneer.com/robots.txt' },
{ id: 'klarna', name: 'Klarna', url: 'https://docs.klarna.com/favicon.ico', iconDomain: 'www.klarna.com', siteUrl: 'https://www.klarna.com' },
{ id: 'monzo', name: 'Monzo', url: 'https://monzo.com/robots.txt' },
],
},
{
id: 'crypto',
emoji: '💰',
Expand Down
2 changes: 1 addition & 1 deletion frontend/data/default-preferences.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const PREFS_STORAGE_KEY = 'userPreferences_v7';

export const DEFAULT_PREFERENCES = Object.freeze({
theme: 'auto', // auto | light | dark
connectivityMultipleTests: false,
connectivityMultipleTests: true,
simpleMode: false,
// Per-module startup auto-run switches. IP info has no switch — it always
// runs on load. See use-refresh-orchestrator.js.
Expand Down
2 changes: 2 additions & 0 deletions frontend/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1288,11 +1288,13 @@
"ai": "AI Services",
"social": "Social & Messaging",
"productivity": "Work & Productivity",
"education": "Online Education",
"streaming": "Streaming",
"music": "Music & Audio",
"gaming": "Gaming",
"developer": "Developer",
"cloud": "Cloud & CDN",
"finance": "Finance & Payments",
"crypto": "Crypto Exchanges",
"ecommerce": "Global E-commerce",
"news": "News & Wiki"
Expand Down
2 changes: 2 additions & 0 deletions frontend/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1288,11 +1288,13 @@
"ai": "Services IA",
"social": "Réseaux sociaux",
"productivity": "Travail et productivité",
"education": "Formation en ligne",
"streaming": "Streaming",
"music": "Musique et audio",
"gaming": "Jeux vidéo",
"developer": "Développeurs",
"cloud": "Cloud et CDN",
"finance": "Finance et paiements",
"crypto": "Plateformes crypto",
"ecommerce": "E-commerce mondial",
"news": "Actualités"
Expand Down
11 changes: 8 additions & 3 deletions frontend/locales/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,20 +82,25 @@ const i18n = createI18n({
messages: {},
});

// Load one locale's messages into the instance (memoized).
// Load one locale's messages into the instance (memoized). A chunk that
// resolves without a default export leaves the locale unregistered rather
// than throwing — keys resolve down the chain.
const loaded = new Set();
async function loadOne(locale) {
if (loaded.has(locale) || !localeLoaders[locale]) return;
const { default: msgs } = await localeLoaders[locale]();
const mod = await localeLoaders[locale]();
const msgs = mod?.default;
if (!msgs) return;
i18n.global.setLocaleMessage(locale, msgs);
loaded.add(locale);
}

// Load the active locale's whole fallback chain — vue-i18n can only fall back
// to messages that are actually in the instance. Awaited in main.js before
// mount so the first render is already translated; the loads run in parallel.
// allSettled: one unreachable pack leaves the UI partly translated, never blank.
export async function loadActiveLocaleMessages() {
await Promise.all(fallbackChain(activeLocale).map((code) => loadOne(code)));
await Promise.allSettled(fallbackChain(activeLocale).map((code) => loadOne(code)));
updateMeta();
}

Expand Down
2 changes: 2 additions & 0 deletions frontend/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -1288,11 +1288,13 @@
"ai": "Serviços de IA",
"social": "Redes sociais e mensagens",
"productivity": "Trabalho e produtividade",
"education": "Educação online",
"streaming": "Streaming",
"music": "Música e áudio",
"gaming": "Jogos",
"developer": "Desenvolvimento",
"cloud": "Nuvem e CDN",
"finance": "Finanças e pagamentos",
"crypto": "Corretoras cripto",
"ecommerce": "E-commerce global",
"news": "Notícias e wiki"
Expand Down
2 changes: 2 additions & 0 deletions frontend/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -1288,11 +1288,13 @@
"ai": "ИИ-сервисы",
"social": "Соцсети",
"productivity": "Работа и продуктивность",
"education": "Онлайн-образование",
"streaming": "Стриминг",
"music": "Музыка и аудио",
"gaming": "Игровые платформы",
"developer": "Для разработчиков",
"cloud": "Облака и CDN",
"finance": "Финансы и платежи",
"crypto": "Криптобиржи",
"ecommerce": "Электронная коммерция",
"news": "Новости"
Expand Down
2 changes: 2 additions & 0 deletions frontend/locales/zh-TW.json
Original file line number Diff line number Diff line change
Expand Up @@ -1288,11 +1288,13 @@
"ai": "AI 服務",
"social": "社交通訊",
"productivity": "工作與生產力",
"education": "線上教育",
"streaming": "流媒體",
"music": "音樂與音訊",
"gaming": "遊戲平臺",
"developer": "開發者",
"cloud": "雲與 CDN",
"finance": "金融與支付",
"crypto": "加密交易所",
"ecommerce": "國際電商平臺",
"news": "新聞與百科"
Expand Down
2 changes: 2 additions & 0 deletions frontend/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -1288,11 +1288,13 @@
"ai": "AI 服务",
"social": "社交通讯",
"productivity": "工作与生产力",
"education": "在线教育",
"streaming": "流媒体",
"music": "音乐与音频",
"gaming": "游戏平台",
"developer": "开发者",
"cloud": "云与 CDN",
"finance": "金融与支付",
"crypto": "加密交易所",
"ecommerce": "国际电商平台",
"news": "新闻与百科"
Expand Down
18 changes: 10 additions & 8 deletions frontend/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,16 +66,18 @@ const store = useMainStore(pinia);
app.use(i18n);
app.use(router);

// Sentry — build-time env gate: without the DSN the chunk neither ships nor
// loads. The SDK chunk stays OFF the boot critical path: it loads after
// mount (see the mount chain's finally below) so it never competes with the
// locale pack the first render waits on. Until init, a tiny buffer catches
// uncaught errors / rejections — and any of them triggers an immediate
// load, so a boot that never reaches mount still reports. Perf data
// survives the late init (buffered observers, backdated pageload span).
// Sentry — build-time env gate: without the DSN, or in a dev server run, the
// chunk neither ships nor loads (local runs would otherwise report every
// experiment against the production project). The SDK chunk stays OFF the
// boot critical path: it loads after mount (see the mount chain's finally
// below) so it never competes with the locale pack the first render waits
// on. Until init, a tiny buffer catches uncaught errors / rejections — and
// any of them triggers an immediate load, so a boot that never reaches mount
// still reports. Perf data survives the late init (buffered observers,
// backdated pageload span).
const earlyErrors = [];
let loadSentry = () => {};
if (import.meta.env.VITE_SENTRY_DSN_FRONTEND) {
if (import.meta.env.VITE_SENTRY_DSN_FRONTEND && !import.meta.env.DEV) {
const onEarlyError = (event) => {
earlyErrors.push(event);
loadSentry();
Expand Down
Loading
Loading