diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..17e319e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,32 @@ +# CODEOWNERS — assigns required reviewers for sensitive paths. +# Format: [ ...] +# See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners +# +# Owners listed here are auto-requested for review when matching files +# change in a PR. If branch protection requires CODEOWNERS approval, the +# PR cannot be merged until an owner approves. + +# ── Internationalization / translations ── +# Translation contributions are held to a stricter neutrality standard +# than most code changes — see CONTRIBUTING.md "Translation contributions". +# The i18n layer itself (no network calls, no telemetry, static JSON +# bundled at build) is the structural guarantee that makes this safe; +# changes to it need owner review. +/frontend/src/i18n/ @BigBodyCobain + +# ── Security-sensitive code paths ── +/backend/auth.py @BigBodyCobain +/backend/routers/wormhole.py @BigBodyCobain +/backend/services/mesh/ @BigBodyCobain +/backend/services/fetchers/ @BigBodyCobain + +# ── CI / build / deploy infra ── +/.github/workflows/ @BigBodyCobain +/.gitlab-ci.yml @BigBodyCobain +/docker-compose.yml @BigBodyCobain +/docker-compose.gitlab.yml @BigBodyCobain +/helm/ @BigBodyCobain + +# ── This file and policy docs ── +/.github/CODEOWNERS @BigBodyCobain +/CONTRIBUTING.md @BigBodyCobain diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..91b0cd3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing to Shadowbroker + +Thank you for taking the time to contribute. This document covers things specific to this project — for general open-source contribution etiquette, see the GitHub docs. + +--- + +## Code contributions + +1. Fork the repo on GitHub (`bigbodycobain/Shadowbroker`) or GitLab (`bigbodycobain/Shadowbroker` mirror). +2. Make your changes on a feature branch. +3. Run the local test suite: + - Backend: `pytest backend/tests/` + - Frontend: `cd frontend && npx vitest run` +4. Open a Pull Request against `main`. + +CI runs on every PR. If CI fails, that's blocking — please push fixes rather than asking for it to be merged anyway. + +--- + +## Reporting security issues + +Do **not** file security issues as public GitHub issues. Email the maintainer or use a private security advisory on GitHub. Public disclosure of an exploitable vulnerability without prior coordination will be rejected from the project. + +--- + +## Translation contributions + +Shadowbroker supports UI localization (`frontend/src/i18n/`). Translation contributions are welcome but held to a stricter standard than most code changes, because translations can subtly reshape user perception in ways that are hard to spot during review. Read this section before submitting one. + +### The neutrality requirement + +**Translations must be technically faithful to the English source.** That means: + +- Each `t('key')` entry should mean approximately the same thing in the target language as in English, modulo idiom. +- Technical terms with established meanings (e.g. "GPS jamming," "military flight," "Tor," "onion routing," "encryption") should be translated using the corresponding established technical term in the target language — **not** softened, rebranded, or politically reframed. +- The set of UI strings should be **the same** between languages. Don't omit features from one locale that are visible in another. + +### What will get a translation PR rejected + +Translation choices that align the project with the framing or terminology of state propaganda — from **any** country — will be rejected. This applies symmetrically: + +| Country / source | Examples of substitutions we will reject | +|---|---| +| **PRC / CCP** | Calling Taiwan a "province" or "renegade province"; reframing protest layers as "riots"; using softened or euphemistic terms for surveillance, internment, or jamming when the source text is direct | +| **Russia** | Calling the Ukraine war a "special military operation"; relabeling occupied territories as Russian; softening sanctions/jamming/disinfo terminology | +| **United States / EU** | Reframing adversaries with editorial labels not in the source (e.g. inserting "regime" where the English says "government"); applying labels like "terrorist" or "rogue state" to entities the English source describes neutrally | +| **Israel / Palestine / any active conflict** | Substituting one side's preferred terminology when the source uses the other side's or a neutral term | +| **Any government** | Adding political slogans, omitting features that government finds inconvenient, or inserting terminology associated with a specific political faction | + +The test is **"would a translator working strictly from the English source produce this rendering?"** If the answer requires assuming a political stance the source does not take, the substitution does not belong in the translation. + +### How translation PRs are reviewed + +Changes to `frontend/src/i18n/**` are owned by the maintainer (see `CODEOWNERS`) and require explicit approval. We will: + +1. Diff the translation against the English source key-by-key. +2. Spot-check a sample of entries with a native speaker of the target language when possible. +3. Look for the patterns above. +4. Look for suspicious additions to the i18n infrastructure itself (e.g. a remote translation fetcher, telemetry on language choice) — the i18n layer is supposed to be 100% client-side static JSON. + +A PR that adds a new language is harder to review than one that fixes typos in an existing language. For new languages, please be patient and expect a real review window. For typo fixes, please describe each change in the PR body so the reviewer can verify intent. + +### What about adding a new language? + +We welcome new languages. The mechanical setup is documented in the header comment of `frontend/src/i18n/index.ts`. Beyond that: + +- We are more likely to merge a new language quickly if at least one reviewer in the maintainer's network speaks it. +- If you are the *only* speaker of the target language reading this repo, your translation is welcome but the merge timeline will be longer while a reviewer is found. +- Partial translations are fine — the system falls back to English for any missing key. + +--- + +## Anything else + +If you have a question that isn't a security report, opening a GitHub Discussion or a draft PR with a question in the body is the fastest way to get a response. Direct emails are read but not always replied to promptly. diff --git a/frontend/src/__tests__/i18n/i18nProvider.test.tsx b/frontend/src/__tests__/i18n/i18nProvider.test.tsx new file mode 100644 index 0000000..bb7f78a --- /dev/null +++ b/frontend/src/__tests__/i18n/i18nProvider.test.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { act, cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { I18nProvider, LOCALES, useTranslation, type Locale } from '@/i18n'; + +/** + * Renders a tiny consumer so we can drive the I18nContext from tests. + */ +function Probe({ keyToRender }: { keyToRender: string }) { + const { locale, setLocale, t } = useTranslation(); + return ( +
+ {locale} + {t(keyToRender)} + + +
+ ); +} + +describe('I18nProvider', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + it('exposes a non-empty LOCALES registry with en and zh-CN', () => { + const codes = LOCALES.map((l) => l.code); + expect(codes).toContain('en'); + expect(codes).toContain('zh-CN'); + // Native labels — used by the language picker. These must be set + // so the picker shows the native language name regardless of + // current UI locale. + for (const entry of LOCALES) { + expect(entry.label.length).toBeGreaterThan(0); + } + }); + + it('defaults to English when no localStorage and English browser', () => { + Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true }); + render( + + + , + ); + expect(screen.getByTestId('locale').textContent).toBe('en'); + }); + + it('auto-detects zh-CN when browser language starts with "zh"', () => { + Object.defineProperty(navigator, 'language', { value: 'zh-TW', configurable: true }); + render( + + + , + ); + // "zh-TW" should match the zh prefix and resolve to our zh-CN bundle + // (we ship only one Chinese variant for now). + expect(screen.getByTestId('locale').textContent).toBe('zh-CN'); + }); + + it('honors a previously saved localStorage choice over auto-detect', () => { + Object.defineProperty(navigator, 'language', { value: 'zh-CN', configurable: true }); + localStorage.setItem('sb_locale', 'en'); + render( + + + , + ); + expect(screen.getByTestId('locale').textContent).toBe('en'); + }); + + it('persists setLocale to localStorage', () => { + render( + + + , + ); + + act(() => { + screen.getByTestId('to-zh').click(); + }); + + expect(screen.getByTestId('locale').textContent).toBe('zh-CN'); + expect(localStorage.getItem('sb_locale')).toBe('zh-CN'); + }); + + it('falls back to auto-detect when localStorage holds an unknown locale', () => { + // Pre-poison localStorage with a value that isn't in LOCALES. The + // isLocale guard at provider init should ignore it and fall through + // to navigator.language detection. + Object.defineProperty(navigator, 'language', { value: 'en-US', configurable: true }); + localStorage.setItem('sb_locale', 'klingon' as unknown as Locale); + + render( + + + , + ); + + expect(screen.getByTestId('locale').textContent).toBe('en'); + }); + + it('renders a real translated string from the zh-CN bundle', () => { + Object.defineProperty(navigator, 'language', { value: 'zh-CN', configurable: true }); + render( + + + , + ); + // The zh-CN bundle has settings.title = "设置". If this assertion + // ever fails after a translation PR, it's a signal that the + // translation surface was significantly altered. + expect(screen.getByTestId('translated').textContent).toBe('设置'); + }); + + it('falls back to the key when a translation is missing', () => { + render( + + + , + ); + expect(screen.getByTestId('translated').textContent).toBe( + 'this.key.intentionally.does.not.exist', + ); + }); +}); diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index b7078aa..11316a1 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -95,7 +95,7 @@ import { setPrivacyStrictPreference, setSessionModePreference, } from '@/lib/privacyBrowserStorage'; -import { useTranslation } from '@/i18n'; +import { useTranslation, LOCALES, type Locale } from '@/i18n'; interface ApiEntry { id: string; @@ -246,7 +246,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ // settings are authenticated through Rust-side admin-key ownership. The // browser admin-session flow is unnecessary and unavailable in packaged mode. const nativeProtected = isNativeProtectedSettingsReady(); - const { t } = useTranslation(); + const { t, locale, setLocale } = useTranslation(); // --- Admin Key (for protected endpoints) --- const [adminKey, setAdminKey] = useState(''); @@ -1136,12 +1136,40 @@ const SettingsPanel = React.memo(function SettingsPanel({ - +
+ {/* + UI language toggle. Locale change is purely client-side + (persists to localStorage('sb_locale')) — no network call, + no telemetry. See frontend/src/i18n/index.ts for the list + of available locales and CONTRIBUTING.md for the + translation-neutrality policy. + */} + + + +
{/* Operator Tools */} diff --git a/frontend/src/i18n/index.ts b/frontend/src/i18n/index.ts deleted file mode 100644 index be22c69..0000000 --- a/frontend/src/i18n/index.ts +++ /dev/null @@ -1,73 +0,0 @@ -'use client'; - -import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; -import en from './translations/en.json'; -import zhCN from './translations/zh-CN.json'; - -export type Locale = 'en' | 'zh-CN'; - -const translations: Record>> = { en, 'zh-CN': zhCN }; - -interface I18nContextValue { - locale: Locale; - setLocale: (locale: Locale) => void; - t: (key: string) => string; -} - -const I18nContext = createContext({ - locale: 'en', - setLocale: () => {}, - t: (key: string) => key, -}); - -function resolve(obj: Record, path: string): string { - const parts = path.split('.'); - let current: unknown = obj; - for (const part of parts) { - if (current && typeof current === 'object' && part in (current as Record)) { - current = (current as Record)[part]; - } else { - return path; // fallback to key - } - } - return typeof current === 'string' ? current : path; -} - -export function I18nProvider({ children }: { children: ReactNode }) { - const [locale, setLocale] = useState(() => { - if (typeof window === 'undefined') return 'en'; - const saved = localStorage.getItem('sb_locale'); - if (saved === 'zh-CN' || saved === 'en') return saved; - // Auto-detect browser language - const browserLang = navigator.language || ''; - return browserLang.startsWith('zh') ? 'zh-CN' : 'en'; - }); - - const handleSetLocale = useCallback((newLocale: Locale) => { - setLocale(newLocale); - if (typeof window !== 'undefined') { - localStorage.setItem('sb_locale', newLocale); - } - }, []); - - const t = useCallback( - (key: string): string => { - const dict = translations[locale] ?? translations.en; - const value = resolve(dict as unknown as Record, key); - return value; - }, - [locale], - ); - - return ( - - {children} - - ); -} - -export function useTranslation() { - return useContext(I18nContext); -} - -export { I18nContext }; diff --git a/frontend/src/i18n/index.tsx b/frontend/src/i18n/index.tsx new file mode 100644 index 0000000..7320bdd --- /dev/null +++ b/frontend/src/i18n/index.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; +import en from './translations/en.json'; +import zhCN from './translations/zh-CN.json'; + +export type Locale = 'en' | 'zh-CN'; + +/** + * Registry of available locales for the UI language toggle. + * + * `label` is the language's NATIVE display name (always rendered in + * itself, regardless of which language the user is currently in) — + * this is the standard convention so the user can recognize their + * own language even when the rest of the UI is unfamiliar. + * + * When adding a new locale: + * 1. Add the translation JSON under translations/ + * 2. Import it above and add to `translations` below + * 3. Add an entry here + * 4. Extend the `Locale` type + * 5. Read CONTRIBUTING.md — translations must be technically faithful + * to the English source. Politically loaded substitutions or + * framing aligned with state propaganda from ANY country will + * be rejected. + */ +export const LOCALES: ReadonlyArray<{ code: Locale; label: string }> = [ + { code: 'en', label: 'English' }, + { code: 'zh-CN', label: '中文 (简体)' }, +]; + +const translations: Record>> = { en, 'zh-CN': zhCN }; + +function isLocale(value: unknown): value is Locale { + return typeof value === 'string' && LOCALES.some((entry) => entry.code === value); +} + +function resolve(obj: Record, path: string): string { + const parts = path.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current && typeof current === 'object' && part in (current as Record)) { + current = (current as Record)[part]; + } else { + return path; // fallback to key + } + } + return typeof current === 'string' ? current : path; +} + +interface I18nContextValue { + locale: Locale; + setLocale: (locale: Locale) => void; + t: (key: string) => string; +} + +/** + * Default context value when useTranslation() is called outside an + * I18nProvider. Resolves keys against the bundled English JSON so + * unwrapped components (and tests that render in isolation) still + * show real English text instead of raw i18n keys. + * + * Without this fallback, every test that renders a translated component + * would need to wrap it in — a real maintenance burden, + * and a footgun because tests would silently start matching "key.path" + * strings instead of failing loud. + * + * This does not hide bugs: if a key is missing from en.json, resolve() + * still returns the literal key (same behavior as the previous default). + */ +const I18nContext = createContext({ + locale: 'en', + setLocale: () => {}, + t: (key: string) => resolve(en as unknown as Record, key), +}); + +export function I18nProvider({ children }: { children: ReactNode }) { + const [locale, setLocale] = useState(() => { + if (typeof window === 'undefined') return 'en'; + const saved = localStorage.getItem('sb_locale'); + if (isLocale(saved)) return saved; + // Auto-detect browser language. Only matches locales we actually + // ship — anything else falls through to English. + const browserLang = (navigator.language || '').toLowerCase(); + const match = LOCALES.find((entry) => + entry.code !== 'en' && browserLang.startsWith(entry.code.toLowerCase().split('-')[0]), + ); + return match ? match.code : 'en'; + }); + + const handleSetLocale = useCallback((newLocale: Locale) => { + if (!isLocale(newLocale)) return; + setLocale(newLocale); + if (typeof window !== 'undefined') { + localStorage.setItem('sb_locale', newLocale); + } + }, []); + + const t = useCallback( + (key: string): string => { + const dict = translations[locale] ?? translations.en; + const value = resolve(dict as unknown as Record, key); + return value; + }, + [locale], + ); + + return ( + + {children} + + ); +} + +export function useTranslation() { + return useContext(I18nContext); +} + +export { I18nContext };