diff --git a/.github/workflows/sync-global-domains.yml b/.github/workflows/sync-global-domains.yml new file mode 100644 index 0000000..15dd781 --- /dev/null +++ b/.github/workflows/sync-global-domains.yml @@ -0,0 +1,51 @@ +name: Sync Bitwarden global domains + +on: + schedule: + - cron: "17 4 * * 1" + workflow_dispatch: + inputs: + bitwarden_ref: + description: "bitwarden/server ref to sync" + required: false + default: "main" + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + sync-global-domains: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Sync generated Bitwarden domains + run: npm run domains:sync -- --ref "${{ inputs.bitwarden_ref || 'main' }}" + + - name: Verify custom domains were not touched + run: git diff --exit-code -- src/static/global_domains.custom.json + + - name: Create pull request + uses: peter-evans/create-pull-request@v6 + with: + branch: chore/sync-bitwarden-global-domains + delete-branch: true + title: "chore: sync Bitwarden global domain rules" + commit-message: "chore: sync Bitwarden global domain rules" + body: | + Automated sync from bitwarden/server. + + This PR only updates: + - `src/static/global_domains.bitwarden.json` + - `src/static/global_domains.bitwarden.meta.json` + + `src/static/global_domains.custom.json` is intentionally left untouched. + add-paths: | + src/static/global_domains.bitwarden.json + src/static/global_domains.bitwarden.meta.json diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 86bad23..d6bbcb4 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -33,6 +33,15 @@ CREATE TABLE IF NOT EXISTS users ( updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS domain_settings ( + user_id TEXT PRIMARY KEY, + equivalent_domains TEXT NOT NULL DEFAULT '[]', + custom_equivalent_domains TEXT NOT NULL DEFAULT '[]', + excluded_global_equivalent_domains TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + -- Per-user sync revision date CREATE TABLE IF NOT EXISTS user_revisions ( user_id TEXT PRIMARY KEY, diff --git a/package.json b/package.json index bf466e3..f2d9602 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "dev:demo": "vite --config webapp/vite.config.ts --mode demo --host 127.0.0.1 --port 5174", "build": "vite build --config webapp/vite.config.ts", "build:demo": "vite build --config webapp/vite.config.ts --mode demo && node scripts/pages-spa-redirects.cjs", + "domains:sync": "node scripts/sync-global-domains.mjs", "i18n": "node scripts/i18n-validate.cjs", "i18n:validate": "node scripts/i18n-validate.cjs", "deploy": "wrangler deploy", diff --git a/scripts/sync-global-domains.mjs b/scripts/sync-global-domains.mjs new file mode 100644 index 0000000..2fed2dc --- /dev/null +++ b/scripts/sync-global-domains.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +const DEFAULT_REF = 'main'; +const OUTPUT_DIR = path.join(process.cwd(), 'src', 'static'); +const OUT_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.json'); +const META_FILE = path.join(OUTPUT_DIR, 'global_domains.bitwarden.meta.json'); +const ENUM_PATH = 'src/Core/Enums/GlobalEquivalentDomainsType.cs'; +const STATIC_STORE_PATH = 'src/Core/Utilities/StaticStore.cs'; + +function parseArgs(argv) { + const args = { ref: process.env.BITWARDEN_SERVER_REF || DEFAULT_REF }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === '--ref' && argv[i + 1]) { + args.ref = argv[i + 1]; + i += 1; + } else if (arg.startsWith('--ref=')) { + args.ref = arg.slice('--ref='.length); + } + } + return args; +} + +function rawUrl(ref, filePath) { + return `https://raw.githubusercontent.com/bitwarden/server/${encodeURIComponent(ref)}/${filePath}`; +} + +async function fetchText(url) { + const response = await fetch(url, { + headers: { + 'User-Agent': 'NodeWarden global domains sync', + Accept: 'text/plain', + }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`); + } + return response.text(); +} + +function parseEnumTypes(source) { + const map = new Map(); + const enumMatch = source.match(/enum\s+GlobalEquivalentDomainsType\b[\s\S]*?\{([\s\S]*?)\}/); + if (!enumMatch) { + throw new Error('GlobalEquivalentDomainsType enum was not found'); + } + + const body = enumMatch[1].replace(/\/\/.*$/gm, ''); + const entryRe = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(\d+)\b/g; + let match; + while ((match = entryRe.exec(body)) !== null) { + map.set(match[1], Number(match[2])); + } + + if (!map.size) { + throw new Error('No enum values were parsed from GlobalEquivalentDomainsType'); + } + return map; +} + +function parseStringList(source) { + const domains = []; + const stringRe = /"((?:\\.|[^"\\])*)"/g; + let match; + while ((match = stringRe.exec(source)) !== null) { + domains.push(match[1].replace(/\\"/g, '"').trim().toLowerCase()); + } + return Array.from(new Set(domains.filter(Boolean))); +} + +function parseGlobalDomains(source, enumTypes) { + const out = []; + const addRe = /GlobalDomains\.Add\s*\(\s*GlobalEquivalentDomainsType\.([A-Za-z_][A-Za-z0-9_]*)\s*,\s*new\s+List(?:<\s*string\s*>)?\s*\{([\s\S]*?)\}\s*\)\s*;/g; + let match; + while ((match = addRe.exec(source)) !== null) { + const name = match[1]; + const type = enumTypes.get(name); + if (!Number.isInteger(type)) { + throw new Error(`GlobalDomains references unknown enum value ${name}`); + } + + const domains = parseStringList(match[2]); + if (domains.length < 2) { + throw new Error(`GlobalDomains.${name} has fewer than two domains`); + } + + out.push({ + type, + domains, + excluded: false, + }); + } + + if (!out.length) { + throw new Error('No GlobalDomains.Add(...) rules were parsed from StaticStore.cs'); + } + return out; +} + +const { ref } = parseArgs(process.argv.slice(2)); +const enumUrl = rawUrl(ref, ENUM_PATH); +const staticStoreUrl = rawUrl(ref, STATIC_STORE_PATH); + +const [enumSource, staticStoreSource] = await Promise.all([ + fetchText(enumUrl), + fetchText(staticStoreUrl), +]); + +const enumTypes = parseEnumTypes(enumSource); +const rules = parseGlobalDomains(staticStoreSource, enumTypes); +const domainsCount = rules.reduce((sum, rule) => sum + rule.domains.length, 0); +const rulesJson = JSON.stringify(rules, null, 2); + +async function readJsonFile(filePath) { + try { + return JSON.parse(await readFile(filePath, 'utf8')); + } catch { + return null; + } +} + +const existingRules = await readJsonFile(OUT_FILE); +const existingMeta = await readJsonFile(META_FILE); +const unchangedRules = JSON.stringify(existingRules) === JSON.stringify(rules); +const unchangedRef = existingMeta?.ref === ref; + +const meta = { + source: 'https://github.com/bitwarden/server', + ref, + generatedAt: unchangedRules && unchangedRef && existingMeta?.generatedAt + ? existingMeta.generatedAt + : new Date().toISOString(), + rulesCount: rules.length, + domainsCount, + sourceFiles: [ + ENUM_PATH, + STATIC_STORE_PATH, + ], + sourceUrls: [ + enumUrl, + staticStoreUrl, + ], +}; + +await mkdir(OUTPUT_DIR, { recursive: true }); +await writeFile(OUT_FILE, `${rulesJson}\n`, 'utf8'); +await writeFile(META_FILE, `${JSON.stringify(meta, null, 2)}\n`, 'utf8'); + +console.log(`Wrote ${rules.length} global domain rules (${domainsCount} domains) from bitwarden/server@${ref}.`); diff --git a/shared/domain-normalize.ts b/shared/domain-normalize.ts new file mode 100644 index 0000000..b478f9d --- /dev/null +++ b/shared/domain-normalize.ts @@ -0,0 +1,151 @@ +const MULTI_LABEL_PUBLIC_SUFFIXES = new Set([ + 'ac.cn', + 'com.cn', + 'edu.cn', + 'gov.cn', + 'net.cn', + 'org.cn', + 'ah.cn', + 'bj.cn', + 'cq.cn', + 'fj.cn', + 'gd.cn', + 'gs.cn', + 'gx.cn', + 'gz.cn', + 'ha.cn', + 'hb.cn', + 'he.cn', + 'hi.cn', + 'hk.cn', + 'hl.cn', + 'hn.cn', + 'jl.cn', + 'js.cn', + 'jx.cn', + 'ln.cn', + 'mo.cn', + 'nm.cn', + 'nx.cn', + 'qh.cn', + 'sc.cn', + 'sd.cn', + 'sh.cn', + 'sn.cn', + 'sx.cn', + 'tj.cn', + 'tw.cn', + 'xj.cn', + 'xz.cn', + 'yn.cn', + 'zj.cn', + 'co.uk', + 'org.uk', + 'net.uk', + 'ac.uk', + 'gov.uk', + 'com.au', + 'net.au', + 'org.au', + 'edu.au', + 'gov.au', + 'co.nz', + 'org.nz', + 'net.nz', + 'com.br', + 'com.mx', + 'com.ar', + 'com.tr', + 'com.sg', + 'com.my', + 'com.hk', + 'com.tw', + 'co.jp', + 'ne.jp', + 'or.jp', + 'co.kr', + 'or.kr', + 'co.in', + 'firm.in', + 'net.in', + 'org.in', + 'co.id', + 'or.id', + 'web.id', + 'co.il', + 'org.il', + 'co.za', + 'com.sa', + 'com.ph', + 'com.vn', + 'com.pk', + 'com.bd', + 'com.ng', + 'github.io', + 'pages.dev', + 'workers.dev', + 'cloudflareaccess.com', + 'vercel.app', + 'netlify.app', + 'web.app', + 'firebaseapp.com', + 'herokuapp.com', + 'fly.dev', + 'railway.app', + 'render.com', + 'onrender.com', +]); + +function extractHost(input: string): string { + let raw = input.trim().toLowerCase(); + if (!raw) return ''; + raw = raw.replace(/\\/g, '/'); + + try { + const candidate = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`; + const parsed = new URL(candidate); + raw = parsed.hostname; + } catch { + raw = raw.split(/[/?#]/, 1)[0] || ''; + const atIndex = raw.lastIndexOf('@'); + if (atIndex >= 0) raw = raw.slice(atIndex + 1); + if (raw.startsWith('[')) return ''; + const colonIndex = raw.lastIndexOf(':'); + if (colonIndex > -1 && raw.indexOf(':') === colonIndex) raw = raw.slice(0, colonIndex); + } + + return raw + .replace(/^\*+\./, '') + .replace(/^\.+/, '') + .replace(/\.+$/, ''); +} + +function isValidHost(host: string): boolean { + if (!host || host.length > 253 || !host.includes('.')) return false; + if (host.includes('..') || /[:/\s]/.test(host)) return false; + if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return false; + return host.split('.').every((label) => ( + label.length > 0 + && label.length <= 63 + && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label) + )); +} + +export function normalizeEquivalentDomain(value: unknown): string { + const host = extractHost(String(value || '')); + if (!isValidHost(host)) return ''; + + const labels = host.split('.'); + for (let index = 0; index < labels.length; index += 1) { + const suffix = labels.slice(index).join('.'); + if (!MULTI_LABEL_PUBLIC_SUFFIXES.has(suffix)) continue; + if (index === 0) return ''; + return labels.slice(index - 1).join('.'); + } + + return labels.length >= 2 ? labels.slice(-2).join('.') : ''; +} + +export function isValidEquivalentDomain(value: unknown): boolean { + return !!normalizeEquivalentDomain(value); +} diff --git a/src/handlers/domains.ts b/src/handlers/domains.ts new file mode 100644 index 0000000..78a92d1 --- /dev/null +++ b/src/handlers/domains.ts @@ -0,0 +1,80 @@ +import type { Env } from '../types'; +import { StorageService } from '../services/storage'; +import { + buildDomainsResponse, + customRulesToActiveEquivalentDomains, + normalizeCustomEquivalentDomains, + normalizeEquivalentDomains, + normalizeExcludedGlobalTypes, +} from '../services/domain-rules'; +import { errorResponse, jsonResponse } from '../utils/response'; + +function firstPresent(payload: Record, keys: string[]): unknown { + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(payload, key)) return payload[key]; + } + return undefined; +} + +async function readPayload(request: Request): Promise> { + try { + const parsed = await request.json(); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : {}; + } catch { + return {}; + } +} + +export async function handleGetDomains(env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const settings = await storage.getUserDomainSettings(userId); + return jsonResponse(buildDomainsResponse( + settings.equivalentDomains, + settings.customEquivalentDomains, + settings.excludedGlobalEquivalentDomains + )); +} + +export async function handleUpdateDomains(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const payload = await readPayload(request); + const current = await storage.getUserDomainSettings(userId); + const equivalentDomainsRaw = firstPresent(payload, [ + 'equivalentDomains', + 'EquivalentDomains', + ]); + const customEquivalentDomainsRaw = firstPresent(payload, [ + 'customEquivalentDomains', + 'CustomEquivalentDomains', + ]); + const excludedGlobalEquivalentDomainsRaw = firstPresent(payload, [ + 'excludedGlobalEquivalentDomains', + 'ExcludedGlobalEquivalentDomains', + // Some older compatible clients send the excluded type list under this key. + 'globalEquivalentDomains', + 'GlobalEquivalentDomains', + ]); + const customEquivalentDomains = customEquivalentDomainsRaw === undefined + ? (equivalentDomainsRaw === undefined + ? current.customEquivalentDomains + : normalizeCustomEquivalentDomains(normalizeEquivalentDomains(equivalentDomainsRaw))) + : normalizeCustomEquivalentDomains(customEquivalentDomainsRaw); + const equivalentDomains = customRulesToActiveEquivalentDomains(customEquivalentDomains); + const excludedGlobalEquivalentDomains = excludedGlobalEquivalentDomainsRaw === undefined + ? current.excludedGlobalEquivalentDomains + : normalizeExcludedGlobalTypes(excludedGlobalEquivalentDomainsRaw); + + await storage.saveUserDomainSettings(userId, equivalentDomains, customEquivalentDomains, excludedGlobalEquivalentDomains); + + const settings = await storage.getUserDomainSettings(userId); + if (!settings) { + return errorResponse('Domain settings unavailable', 500); + } + return jsonResponse(buildDomainsResponse( + settings.equivalentDomains, + settings.customEquivalentDomains, + settings.excludedGlobalEquivalentDomains + )); +} diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index 7e7dd76..5dc6f9a 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -9,6 +9,7 @@ import { buildUserDecryptionCompat, buildUserDecryptionOptions, } from '../utils/user-decryption'; +import { buildDomainsResponse } from '../services/domain-rules'; function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean, excludeSends: boolean): Request { const url = new URL(request.url); @@ -50,11 +51,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr return cachedResponse; } - const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([ + const [ciphers, folders, sends, attachmentsByCipher, domainSettings] = await Promise.all([ storage.getAllCiphers(userId), storage.getAllFolders(userId), excludeSends ? Promise.resolve([]) : storage.getAllSends(userId), storage.getAttachmentsByUserId(userId), + excludeDomains ? Promise.resolve(null) : storage.getUserDomainSettings(userId), ]); const accountKeys = buildAccountKeys(user); const userDecryptionOptions = buildUserDecryptionOptions(user); @@ -111,11 +113,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr ciphers: cipherResponses, domains: excludeDomains ? null - : { - equivalentDomains: [], - globalEquivalentDomains: [], - object: 'domains', - }, + : buildDomainsResponse( + domainSettings?.equivalentDomains || [], + domainSettings?.customEquivalentDomains || [], + domainSettings?.excludedGlobalEquivalentDomains || [], + { omitExcludedGlobals: true } + ), policies: [], sends: sendResponses, UserDecryption: { diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts index 7c983b3..36672a2 100644 --- a/src/router-authenticated.ts +++ b/src/router-authenticated.ts @@ -65,6 +65,7 @@ import { } from './handlers/attachments'; import { handleAuthenticatedDeviceRoute } from './router-devices'; import { handleAdminRoute } from './router-admin'; +import { handleGetDomains, handleUpdateDomains } from './handlers/domains'; export async function handleAuthenticatedRoute( request: Request, @@ -297,14 +298,9 @@ export async function handleAuthenticatedRoute( return null; } - if (path === '/api/settings/domains') { - if (method === 'GET' || method === 'PUT' || method === 'POST') { - return jsonResponse({ - equivalentDomains: [], - globalEquivalentDomains: [], - object: 'domains', - }); - } + if (path === '/api/settings/domains' || path === '/settings/domains') { + if (method === 'GET') return handleGetDomains(env, userId); + if (method === 'PUT' || method === 'POST') return handleUpdateDomains(request, env, userId); return null; } diff --git a/src/services/domain-rules.ts b/src/services/domain-rules.ts new file mode 100644 index 0000000..45934a6 --- /dev/null +++ b/src/services/domain-rules.ts @@ -0,0 +1,214 @@ +import bitwardenGlobalDomainsRaw from '../static/global_domains.bitwarden.json'; +import customGlobalDomainsRaw from '../static/global_domains.custom.json'; +import type { CustomEquivalentDomain, DomainRulesResponse, GlobalEquivalentDomain } from '../types'; +import { normalizeEquivalentDomain } from '../../shared/domain-normalize'; + +type RawGlobalDomain = Partial & { + Type?: unknown; + Domains?: unknown; + Excluded?: unknown; +}; + +function normalizeDomain(value: unknown): string { + return normalizeEquivalentDomain(value); +} + +function normalizeGlobalDomain(entry: RawGlobalDomain): GlobalEquivalentDomain | null { + const type = Number(entry.type ?? entry.Type); + if (!Number.isInteger(type)) return null; + + const rawDomains = entry.domains ?? entry.Domains; + if (!Array.isArray(rawDomains)) return null; + + const domains = Array.from(new Set(rawDomains.map(normalizeDomain).filter(Boolean))); + if (domains.length < 2) return null; + + return { + type, + domains, + excluded: Boolean(entry.excluded ?? entry.Excluded ?? false), + }; +} + +function normalizeGlobalDomains(input: unknown): GlobalEquivalentDomain[] { + if (!Array.isArray(input)) return []; + + const seen = new Set(); + const out: GlobalEquivalentDomain[] = []; + for (const entry of input) { + const normalized = normalizeGlobalDomain(entry as RawGlobalDomain); + if (!normalized || seen.has(normalized.type)) continue; + seen.add(normalized.type); + out.push(normalized); + } + return out; +} + +const bitwardenGlobalDomains = normalizeGlobalDomains(bitwardenGlobalDomainsRaw); +const customGlobalDomains = normalizeGlobalDomains(customGlobalDomainsRaw); + +export const globalDomains: readonly GlobalEquivalentDomain[] = [ + ...bitwardenGlobalDomains, + ...customGlobalDomains, +]; + +export function normalizeEquivalentDomains(input: unknown): string[][] { + if (!Array.isArray(input)) return []; + + const groups: string[][] = []; + const seenGroups = new Set(); + for (const group of input) { + if (!Array.isArray(group)) continue; + const domains = Array.from(new Set(group.map(normalizeDomain).filter(Boolean))); + if (domains.length < 2) continue; + const key = domains.slice().sort().join('\n'); + if (seenGroups.has(key)) continue; + seenGroups.add(key); + groups.push(domains); + } + return groups; +} + +export function mergeEquivalentDomainGroups(input: string[][]): string[][] { + const parent = new Map(); + + function find(domain: string): string { + const current = parent.get(domain); + if (!current) { + parent.set(domain, domain); + return domain; + } + if (current === domain) return domain; + const root = find(current); + parent.set(domain, root); + return root; + } + + function union(a: string, b: string): void { + const rootA = find(a); + const rootB = find(b); + if (rootA !== rootB) parent.set(rootB, rootA); + } + + for (const group of normalizeEquivalentDomains(input)) { + if (group.length < 2) continue; + const [first, ...rest] = group; + find(first); + for (const domain of rest) union(first, domain); + } + + const components = new Map(); + for (const domain of parent.keys()) { + const root = find(domain); + const group = components.get(root) || []; + group.push(domain); + components.set(root, group); + } + + return Array.from(components.values()) + .map((group) => group.sort()) + .filter((group) => group.length >= 2) + .sort((a, b) => a[0].localeCompare(b[0])); +} + +export function expandCustomEquivalentDomainsWithGlobals( + customGroups: string[][], + activeGlobalGroups: string[][] +): string[][] { + const normalizedCustomGroups = normalizeEquivalentDomains(customGroups); + if (!normalizedCustomGroups.length) return []; + + const customDomains = new Set(normalizedCustomGroups.flat()); + return mergeEquivalentDomainGroups([ + ...activeGlobalGroups, + ...normalizedCustomGroups, + ]).filter((group) => group.some((domain) => customDomains.has(domain))); +} + +function createCustomDomainId(domains: string[], index: number): string { + return `custom:${domains.slice().sort().join('|')}:${index}`; +} + +export function normalizeCustomEquivalentDomains(input: unknown): CustomEquivalentDomain[] { + if (!Array.isArray(input)) return []; + + const rules: CustomEquivalentDomain[] = []; + const seenGroups = new Set(); + for (const [index, item] of input.entries()) { + const record = Array.isArray(item) + ? { domains: item, excluded: false, id: '' } + : item && typeof item === 'object' + ? item as Record + : null; + if (!record) continue; + + const domains = normalizeEquivalentDomains([record.domains ?? record.Domains])[0]; + if (!domains) continue; + + const key = domains.slice().sort().join('\n'); + if (seenGroups.has(key)) continue; + seenGroups.add(key); + + const rawId = String(record.id ?? record.Id ?? '').trim(); + rules.push({ + id: rawId || createCustomDomainId(domains, index), + domains, + excluded: Boolean(record.excluded ?? record.Excluded ?? false), + }); + } + return rules; +} + +export function customRulesToActiveEquivalentDomains(rules: CustomEquivalentDomain[]): string[][] { + return mergeEquivalentDomainGroups(rules + .filter((rule) => !rule.excluded) + .map((rule) => rule.domains)); +} + +export function normalizeExcludedGlobalTypes(input: unknown): number[] { + if (!Array.isArray(input)) return []; + + const validTypes = new Set(globalDomains.map((entry) => entry.type)); + const seen = new Set(); + const out: number[] = []; + for (const item of input) { + const type = Number(typeof item === 'object' && item !== null ? (item as Record).type : item); + const excluded = typeof item === 'object' && item !== null + ? Boolean((item as Record).excluded) + : true; + if (!excluded || !Number.isInteger(type) || !validTypes.has(type) || seen.has(type)) continue; + seen.add(type); + out.push(type); + } + return out; +} + +export function buildDomainsResponse( + equivalentDomains: string[][], + customEquivalentDomains: CustomEquivalentDomain[], + excludedGlobalEquivalentDomains: number[], + options: { omitExcludedGlobals?: boolean } = {} +): DomainRulesResponse { + const excluded = new Set(excludedGlobalEquivalentDomains); + const activeGlobalDomainGroups = globalDomains + .filter((entry) => !excluded.has(entry.type)) + .map((entry) => entry.domains); + const mergedEquivalentDomains = expandCustomEquivalentDomainsWithGlobals( + equivalentDomains, + activeGlobalDomainGroups + ); + const globals = globalDomains + .map((entry) => ({ + type: entry.type, + domains: entry.domains, + excluded: excluded.has(entry.type), + })) + .filter((entry) => !options.omitExcludedGlobals || !entry.excluded); + + return { + equivalentDomains: mergedEquivalentDomains, + customEquivalentDomains, + globalEquivalentDomains: globals, + object: 'domains', + }; +} diff --git a/src/services/storage-domain-rules-repo.ts b/src/services/storage-domain-rules-repo.ts new file mode 100644 index 0000000..50aa674 --- /dev/null +++ b/src/services/storage-domain-rules-repo.ts @@ -0,0 +1,67 @@ +import type { UserDomainSettings } from '../types'; +import { normalizeCustomEquivalentDomains, normalizeEquivalentDomains } from './domain-rules'; + +function parseJsonArray(raw: string | null | undefined, fallback: T[]): T[] { + if (!raw) return fallback; + try { + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? parsed as T[] : fallback; + } catch { + return fallback; + } +} + +export async function getUserDomainSettings(db: D1Database, userId: string): Promise { + const row = await db + .prepare('SELECT equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at FROM domain_settings WHERE user_id = ?') + .bind(userId) + .first<{ + equivalent_domains: string | null; + custom_equivalent_domains: string | null; + excluded_global_equivalent_domains: string | null; + updated_at: string | null; + }>(); + const equivalentDomains = normalizeEquivalentDomains(parseJsonArray(row?.equivalent_domains, [])); + const storedCustomEquivalentDomains = row?.custom_equivalent_domains + ? normalizeCustomEquivalentDomains(parseJsonArray(row.custom_equivalent_domains, [])) + : []; + const customEquivalentDomains = storedCustomEquivalentDomains.length + ? storedCustomEquivalentDomains + : normalizeCustomEquivalentDomains(equivalentDomains); + + return { + userId, + equivalentDomains, + customEquivalentDomains, + excludedGlobalEquivalentDomains: parseJsonArray(row?.excluded_global_equivalent_domains, []), + updatedAt: row?.updated_at || null, + }; +} + +export async function saveUserDomainSettings( + db: D1Database, + userId: string, + equivalentDomains: string[][], + customEquivalentDomains: UserDomainSettings['customEquivalentDomains'], + excludedGlobalEquivalentDomains: number[], + updatedAt: string +): Promise { + await db + .prepare( + 'INSERT INTO domain_settings(user_id, equivalent_domains, custom_equivalent_domains, excluded_global_equivalent_domains, updated_at) ' + + 'VALUES(?, ?, ?, ?, ?) ' + + 'ON CONFLICT(user_id) DO UPDATE SET ' + + 'equivalent_domains = excluded.equivalent_domains, ' + + 'custom_equivalent_domains = excluded.custom_equivalent_domains, ' + + 'excluded_global_equivalent_domains = excluded.excluded_global_equivalent_domains, ' + + 'updated_at = excluded.updated_at' + ) + .bind( + userId, + JSON.stringify(equivalentDomains), + JSON.stringify(customEquivalentDomains), + JSON.stringify(excludedGlobalEquivalentDomains), + updatedAt + ) + .run(); +} diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index 01d474f..4fdafd1 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -15,6 +15,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT', 'ALTER TABLE users ADD COLUMN api_key TEXT', + 'CREATE TABLE IF NOT EXISTS domain_settings (' + + 'user_id TEXT PRIMARY KEY, equivalent_domains TEXT NOT NULL DEFAULT \'[]\', custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', excluded_global_equivalent_domains TEXT NOT NULL DEFAULT \'[]\', updated_at TEXT NOT NULL, ' + + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + 'ALTER TABLE domain_settings ADD COLUMN custom_equivalent_domains TEXT NOT NULL DEFAULT \'[]\'', + 'CREATE TABLE IF NOT EXISTS user_revisions (' + 'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', diff --git a/src/services/storage.ts b/src/services/storage.ts index 38ab182..fef6a65 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,4 +1,4 @@ -import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types'; +import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain } from '../types'; import { LIMITS } from '../config/limits'; import { ensureStorageSchema } from './storage-schema'; import { @@ -105,10 +105,14 @@ import { getRevisionDate as getStoredRevisionDate, updateRevisionDate as updateStoredRevisionDate, } from './storage-revision-repo'; +import { + getUserDomainSettings as getStoredUserDomainSettings, + saveUserDomainSettings as saveStoredUserDomainSettings, +} from './storage-domain-rules-repo'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; -const STORAGE_SCHEMA_VERSION = '2026-04-28'; +const STORAGE_SCHEMA_VERSION = '2026-05-05-domain-rules-v2'; // D1-backed storage. // Contract: @@ -270,6 +274,29 @@ export class StorageService { await createStoredAuditLog(this.db, log); } + // --- Domain rules --- + + async getUserDomainSettings(userId: string) { + return getStoredUserDomainSettings(this.db, userId); + } + + async saveUserDomainSettings( + userId: string, + equivalentDomains: string[][], + customEquivalentDomains: CustomEquivalentDomain[], + excludedGlobalEquivalentDomains: number[] + ): Promise { + await saveStoredUserDomainSettings( + this.db, + userId, + equivalentDomains, + customEquivalentDomains, + excludedGlobalEquivalentDomains, + new Date().toISOString() + ); + await this.updateRevisionDate(userId); + } + // --- Ciphers --- async getCipher(id: string): Promise { diff --git a/src/static/global_domains.bitwarden.json b/src/static/global_domains.bitwarden.json new file mode 100644 index 0000000..6c3505c --- /dev/null +++ b/src/static/global_domains.bitwarden.json @@ -0,0 +1,93 @@ +[ + { "type": 2, "domains": ["ameritrade.com", "tdameritrade.com"], "excluded": false }, + { "type": 3, "domains": ["bankofamerica.com", "bofa.com", "mbna.com", "usecfo.com"], "excluded": false }, + { "type": 4, "domains": ["sprint.com", "sprintpcs.com", "nextel.com"], "excluded": false }, + { "type": 0, "domains": ["youtube.com", "google.com", "gmail.com"], "excluded": false }, + { "type": 1, "domains": ["apple.com", "icloud.com"], "excluded": false }, + { "type": 5, "domains": ["wellsfargo.com", "wf.com", "wellsfargoadvisors.com"], "excluded": false }, + { "type": 6, "domains": ["mymerrill.com", "ml.com", "merrilledge.com"], "excluded": false }, + { "type": 7, "domains": ["accountonline.com", "citi.com", "citibank.com", "citicards.com", "citibankonline.com"], "excluded": false }, + { "type": 8, "domains": ["cnet.com", "cnettv.com", "com.com", "download.com", "news.com", "search.com", "upload.com"], "excluded": false }, + { "type": 9, "domains": ["bananarepublic.com", "gap.com", "oldnavy.com", "piperlime.com"], "excluded": false }, + { "type": 10, "domains": ["bing.com", "hotmail.com", "live.com", "microsoft.com", "msn.com", "passport.net", "windows.com", "microsoftonline.com", "office.com", "office365.com", "microsoftstore.com", "xbox.com", "azure.com", "windowsazure.com", "cloud.microsoft"], "excluded": false }, + { "type": 11, "domains": ["ua2go.com", "ual.com", "united.com", "unitedwifi.com"], "excluded": false }, + { "type": 12, "domains": ["overture.com", "yahoo.com"], "excluded": false }, + { "type": 13, "domains": ["zonealarm.com", "zonelabs.com"], "excluded": false }, + { "type": 14, "domains": ["paypal.com", "paypal-search.com"], "excluded": false }, + { "type": 15, "domains": ["avon.com", "youravon.com"], "excluded": false }, + { "type": 16, "domains": ["diapers.com", "soap.com", "wag.com", "yoyo.com", "beautybar.com", "casa.com", "afterschool.com", "vine.com", "bookworm.com", "look.com", "vinemarket.com"], "excluded": false }, + { "type": 17, "domains": ["1800contacts.com", "800contacts.com"], "excluded": false }, + { "type": 18, "domains": ["amazon.com", "amazon.com.be", "amazon.ae", "amazon.ca", "amazon.co.uk", "amazon.com.au", "amazon.com.br", "amazon.com.mx", "amazon.com.tr", "amazon.de", "amazon.es", "amazon.fr", "amazon.in", "amazon.it", "amazon.nl", "amazon.pl", "amazon.sa", "amazon.se", "amazon.sg"], "excluded": false }, + { "type": 19, "domains": ["cox.com", "cox.net", "coxbusiness.com"], "excluded": false }, + { "type": 20, "domains": ["mynortonaccount.com", "norton.com"], "excluded": false }, + { "type": 21, "domains": ["verizon.com", "verizon.net"], "excluded": false }, + { "type": 22, "domains": ["rakuten.com", "buy.com"], "excluded": false }, + { "type": 23, "domains": ["siriusxm.com", "sirius.com"], "excluded": false }, + { "type": 24, "domains": ["ea.com", "origin.com", "play4free.com", "tiberiumalliance.com"], "excluded": false }, + { "type": 25, "domains": ["37signals.com", "basecamp.com", "basecamphq.com", "highrisehq.com"], "excluded": false }, + { "type": 26, "domains": ["steampowered.com", "steamcommunity.com", "steamgames.com"], "excluded": false }, + { "type": 27, "domains": ["chart.io", "chartio.com"], "excluded": false }, + { "type": 28, "domains": ["gotomeeting.com", "citrixonline.com"], "excluded": false }, + { "type": 29, "domains": ["gogoair.com", "gogoinflight.com"], "excluded": false }, + { "type": 30, "domains": ["mysql.com", "oracle.com"], "excluded": false }, + { "type": 31, "domains": ["discover.com", "discovercard.com"], "excluded": false }, + { "type": 32, "domains": ["dcu.org", "dcu-online.org"], "excluded": false }, + { "type": 33, "domains": ["healthcare.gov", "cuidadodesalud.gov", "cms.gov"], "excluded": false }, + { "type": 34, "domains": ["pepco.com", "pepcoholdings.com"], "excluded": false }, + { "type": 35, "domains": ["century21.com", "21online.com"], "excluded": false }, + { "type": 36, "domains": ["comcast.com", "comcast.net", "xfinity.com"], "excluded": false }, + { "type": 37, "domains": ["cricketwireless.com", "aiowireless.com"], "excluded": false }, + { "type": 38, "domains": ["mandtbank.com", "mtb.com"], "excluded": false }, + { "type": 39, "domains": ["dropbox.com", "getdropbox.com"], "excluded": false }, + { "type": 40, "domains": ["snapfish.com", "snapfish.ca"], "excluded": false }, + { "type": 41, "domains": ["alibaba.com", "aliexpress.com", "aliyun.com", "net.cn"], "excluded": false }, + { "type": 42, "domains": ["playstation.com", "sonyentertainmentnetwork.com"], "excluded": false }, + { "type": 43, "domains": ["mercadolivre.com", "mercadolivre.com.br", "mercadolibre.com", "mercadolibre.com.ar", "mercadolibre.com.mx"], "excluded": false }, + { "type": 44, "domains": ["zendesk.com", "zopim.com"], "excluded": false }, + { "type": 45, "domains": ["autodesk.com", "tinkercad.com"], "excluded": false }, + { "type": 46, "domains": ["railnation.ru", "railnation.de", "rail-nation.com", "railnation.gr", "railnation.us", "trucknation.de", "traviangames.com"], "excluded": false }, + { "type": 47, "domains": ["wpcu.coop", "wpcuonline.com"], "excluded": false }, + { "type": 48, "domains": ["mathletics.com", "mathletics.com.au", "mathletics.co.uk"], "excluded": false }, + { "type": 49, "domains": ["discountbank.co.il", "telebank.co.il"], "excluded": false }, + { "type": 50, "domains": ["mi.com", "xiaomi.com"], "excluded": false }, + { "type": 52, "domains": ["postepay.it", "poste.it"], "excluded": false }, + { "type": 51, "domains": ["facebook.com", "messenger.com"], "excluded": false }, + { "type": 53, "domains": ["skysports.com", "skybet.com", "skyvegas.com"], "excluded": false }, + { "type": 54, "domains": ["disneymoviesanywhere.com", "go.com", "disney.com", "dadt.com", "disneyplus.com"], "excluded": false }, + { "type": 55, "domains": ["pokemon-gl.com", "pokemon.com"], "excluded": false }, + { "type": 56, "domains": ["myuv.com", "uvvu.com"], "excluded": false }, + { "type": 58, "domains": ["mdsol.com", "imedidata.com"], "excluded": false }, + { "type": 57, "domains": ["bank-yahav.co.il", "bankhapoalim.co.il"], "excluded": false }, + { "type": 59, "domains": ["sears.com", "shld.net"], "excluded": false }, + { "type": 60, "domains": ["xiami.com", "alipay.com"], "excluded": false }, + { "type": 61, "domains": ["belkin.com", "seedonk.com"], "excluded": false }, + { "type": 62, "domains": ["turbotax.com", "intuit.com"], "excluded": false }, + { "type": 63, "domains": ["shopify.com", "myshopify.com"], "excluded": false }, + { "type": 64, "domains": ["ebay.com", "ebay.at", "ebay.be", "ebay.ca", "ebay.ch", "ebay.cn", "ebay.co.jp", "ebay.co.th", "ebay.co.uk", "ebay.com.au", "ebay.com.hk", "ebay.com.my", "ebay.com.sg", "ebay.com.tw", "ebay.de", "ebay.es", "ebay.fr", "ebay.ie", "ebay.in", "ebay.it", "ebay.nl", "ebay.ph", "ebay.pl"], "excluded": false }, + { "type": 65, "domains": ["techdata.com", "techdata.ch"], "excluded": false }, + { "type": 66, "domains": ["schwab.com", "schwabplan.com"], "excluded": false }, + { "type": 68, "domains": ["tesla.com", "teslamotors.com"], "excluded": false }, + { "type": 69, "domains": ["morganstanley.com", "morganstanleyclientserv.com", "stockplanconnect.com", "ms.com"], "excluded": false }, + { "type": 70, "domains": ["taxact.com", "taxactonline.com"], "excluded": false }, + { "type": 71, "domains": ["mediawiki.org", "wikibooks.org", "wikidata.org", "wikimedia.org", "wikinews.org", "wikipedia.org", "wikiquote.org", "wikisource.org", "wikiversity.org", "wikivoyage.org", "wiktionary.org"], "excluded": false }, + { "type": 72, "domains": ["airbnb.at", "airbnb.be", "airbnb.ca", "airbnb.ch", "airbnb.cl", "airbnb.co.cr", "airbnb.co.id", "airbnb.co.in", "airbnb.co.kr", "airbnb.co.nz", "airbnb.co.uk", "airbnb.co.ve", "airbnb.com", "airbnb.com.ar", "airbnb.com.au", "airbnb.com.bo", "airbnb.com.br", "airbnb.com.bz", "airbnb.com.co", "airbnb.com.ec", "airbnb.com.gt", "airbnb.com.hk", "airbnb.com.hn", "airbnb.com.mt", "airbnb.com.my", "airbnb.com.ni", "airbnb.com.pa", "airbnb.com.pe", "airbnb.com.py", "airbnb.com.sg", "airbnb.com.sv", "airbnb.com.tr", "airbnb.com.tw", "airbnb.cz", "airbnb.de", "airbnb.dk", "airbnb.es", "airbnb.fi", "airbnb.fr", "airbnb.gr", "airbnb.gy", "airbnb.hu", "airbnb.ie", "airbnb.is", "airbnb.it", "airbnb.jp", "airbnb.mx", "airbnb.nl", "airbnb.no", "airbnb.pl", "airbnb.pt", "airbnb.ru", "airbnb.se"], "excluded": false }, + { "type": 73, "domains": ["eventbrite.at", "eventbrite.be", "eventbrite.ca", "eventbrite.ch", "eventbrite.cl", "eventbrite.co", "eventbrite.co.nz", "eventbrite.co.uk", "eventbrite.com", "eventbrite.com.ar", "eventbrite.com.au", "eventbrite.com.br", "eventbrite.com.mx", "eventbrite.com.pe", "eventbrite.de", "eventbrite.dk", "eventbrite.es", "eventbrite.fi", "eventbrite.fr", "eventbrite.hk", "eventbrite.ie", "eventbrite.it", "eventbrite.nl", "eventbrite.pt", "eventbrite.se", "eventbrite.sg"], "excluded": false }, + { "type": 74, "domains": ["stackexchange.com", "superuser.com", "stackoverflow.com", "serverfault.com", "mathoverflow.net", "askubuntu.com", "stackapps.com"], "excluded": false }, + { "type": 75, "domains": ["docusign.com", "docusign.net"], "excluded": false }, + { "type": 76, "domains": ["envato.com", "themeforest.net", "codecanyon.net", "videohive.net", "audiojungle.net", "graphicriver.net", "photodune.net", "3docean.net"], "excluded": false }, + { "type": 77, "domains": ["x10hosting.com", "x10premium.com"], "excluded": false }, + { "type": 78, "domains": ["dnsomatic.com", "opendns.com", "umbrella.com"], "excluded": false }, + { "type": 79, "domains": ["cagreatamerica.com", "canadaswonderland.com", "carowinds.com", "cedarfair.com", "cedarpoint.com", "dorneypark.com", "kingsdominion.com", "knotts.com", "miadventure.com", "schlitterbahn.com", "valleyfair.com", "visitkingsisland.com", "worldsoffun.com"], "excluded": false }, + { "type": 80, "domains": ["ubnt.com", "ui.com"], "excluded": false }, + { "type": 81, "domains": ["discordapp.com", "discord.com"], "excluded": false }, + { "type": 82, "domains": ["netcup.de", "netcup.eu", "customercontrolpanel.de"], "excluded": false }, + { "type": 83, "domains": ["yandex.com", "ya.ru", "yandex.az", "yandex.by", "yandex.co.il", "yandex.com.am", "yandex.com.ge", "yandex.com.tr", "yandex.ee", "yandex.fi", "yandex.fr", "yandex.kg", "yandex.kz", "yandex.lt", "yandex.lv", "yandex.md", "yandex.pl", "yandex.ru", "yandex.tj", "yandex.tm", "yandex.ua", "yandex.uz"], "excluded": false }, + { "type": 84, "domains": ["sonyentertainmentnetwork.com", "sony.com"], "excluded": false }, + { "type": 85, "domains": ["proton.me", "protonmail.com", "protonvpn.com"], "excluded": false }, + { "type": 86, "domains": ["ubisoft.com", "ubi.com"], "excluded": false }, + { "type": 87, "domains": ["transferwise.com", "wise.com"], "excluded": false }, + { "type": 88, "domains": ["takeaway.com", "just-eat.dk", "just-eat.no", "just-eat.fr", "just-eat.ch", "lieferando.de", "lieferando.at", "thuisbezorgd.nl", "pyszne.pl"], "excluded": false }, + { "type": 89, "domains": ["atlassian.com", "bitbucket.org", "trello.com", "statuspage.io", "atlassian.net", "jira.com"], "excluded": false }, + { "type": 90, "domains": ["pinterest.com", "pinterest.com.au", "pinterest.cl", "pinterest.de", "pinterest.dk", "pinterest.es", "pinterest.fr", "pinterest.co.uk", "pinterest.jp", "pinterest.co.kr", "pinterest.nz", "pinterest.pt", "pinterest.se"], "excluded": false }, + { "type": 91, "domains": ["twitter.com", "x.com"], "excluded": false } +] diff --git a/src/static/global_domains.bitwarden.meta.json b/src/static/global_domains.bitwarden.meta.json new file mode 100644 index 0000000..494b1f7 --- /dev/null +++ b/src/static/global_domains.bitwarden.meta.json @@ -0,0 +1,15 @@ +{ + "source": "https://github.com/bitwarden/server", + "ref": "main", + "generatedAt": "2026-05-05T00:00:00.000Z", + "rulesCount": 91, + "domainsCount": 436, + "sourceFiles": [ + "src/Core/Enums/GlobalEquivalentDomainsType.cs", + "src/Core/Utilities/StaticStore.cs" + ], + "sourceUrls": [ + "https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Enums/GlobalEquivalentDomainsType.cs", + "https://raw.githubusercontent.com/bitwarden/server/main/src/Core/Utilities/StaticStore.cs" + ] +} diff --git a/src/static/global_domains.custom.json b/src/static/global_domains.custom.json new file mode 100644 index 0000000..a2a079f --- /dev/null +++ b/src/static/global_domains.custom.json @@ -0,0 +1,8 @@ +[ + { + "type": -10001, + "domains": ["nodewarden.example", "nw.example"], + "excluded": false, + "source": "nodewarden" + } +] diff --git a/src/types/index.ts b/src/types/index.ts index 583c5ee..1ccb9ef 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -55,6 +55,34 @@ export interface User { updatedAt: string; } +export interface UserDomainSettings { + userId: string; + equivalentDomains: string[][]; + customEquivalentDomains: CustomEquivalentDomain[]; + excludedGlobalEquivalentDomains: number[]; + updatedAt: string | null; +} + +export interface CustomEquivalentDomain { + id: string; + domains: string[]; + excluded: boolean; +} + +export interface GlobalEquivalentDomain { + type: number; + domains: string[]; + excluded: boolean; + [key: string]: unknown; +} + +export interface DomainRulesResponse { + equivalentDomains: string[][]; + customEquivalentDomains: CustomEquivalentDomain[]; + globalEquivalentDomains: GlobalEquivalentDomain[]; + object: 'domains'; +} + export interface Invite { code: string; createdBy: string; diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 2bd34db..48079e2 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -23,6 +23,7 @@ import { stripProfileSecrets, } from '@/lib/api/auth'; import { listAdminInvites, listAdminUsers } from '@/lib/api/admin'; +import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; import { getSends } from '@/lib/api/send'; import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; @@ -68,7 +69,7 @@ import { createDemoMainRoutesProps, } from '@/lib/demo'; import type { AdminBackupSettings } from '@/lib/api/backup'; -import type { AdminInvite, AdminUser, AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; +import type { AdminInvite, AdminUser, AppPhase, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; import type { VaultCoreSnapshot } from '@/lib/vault-cache'; function isBackupProgressDetail(value: unknown): value is BackupProgressDetail { @@ -87,6 +88,7 @@ const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export const IMPORT_ROUTE_ALIASES: ReadonlySet = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE)); const SETTINGS_HOME_ROUTE = '/settings'; const SETTINGS_ACCOUNT_ROUTE = '/settings/account'; +const SETTINGS_DOMAIN_RULES_ROUTE = '/settings/domain-rules'; const AUTH_ROUTE_PATHS = ['/', '/login', '/register', '/lock', '/recover-2fa'] as const; const APP_ROUTE_PATHS = [ '/', @@ -98,6 +100,7 @@ const APP_ROUTE_PATHS = [ '/backup', '/settings', SETTINGS_ACCOUNT_ROUTE, + SETTINGS_DOMAIN_RULES_ROUTE, '/help', ...IMPORT_ROUTE_PATHS, ] as const; @@ -227,6 +230,7 @@ export default function App() { const pendingVaultCoreQueryRefreshRef = useRef | null>(null); const pendingVaultCoreRefreshRef = useRef | null>(null); const notificationRefreshTimerRef = useRef(null); + const domainRulesSaveSeqRef = useRef(0); const { toasts, pushToast, removeToast } = useToastManager(); useEffect(() => { @@ -953,6 +957,45 @@ export default function App() { enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, staleTime: 30_000, }); + const domainRulesQueryKey = useMemo(() => ['domain-rules', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]); + const domainRulesQuery = useQuery({ + queryKey: domainRulesQueryKey, + queryFn: () => getDomainRules(authedFetch), + enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone && location === SETTINGS_DOMAIN_RULES_ROUTE, + staleTime: 30_000, + }); + function handleSaveDomainRules(customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]): Promise { + const equivalentDomains = customEquivalentDomains.filter((rule) => !rule.excluded).map((rule) => rule.domains); + const excludedGlobalTypes = new Set(excludedGlobalEquivalentDomains); + const currentRules = queryClient.getQueryData(domainRulesQueryKey) || domainRulesQuery.data; + const optimisticRules: DomainRules = { + object: 'domains', + equivalentDomains, + customEquivalentDomains, + globalEquivalentDomains: (currentRules?.globalEquivalentDomains || []).map((rule) => ({ + ...rule, + excluded: excludedGlobalTypes.has(rule.type), + })), + }; + const saveSeq = ++domainRulesSaveSeqRef.current; + queryClient.setQueryData(domainRulesQueryKey, optimisticRules); + + void saveDomainRules(authedFetch, { + customEquivalentDomains, + equivalentDomains, + excludedGlobalEquivalentDomains, + }).then((updated) => { + if (domainRulesSaveSeqRef.current !== saveSeq) return; + queryClient.setQueryData(domainRulesQueryKey, updated); + void queryClient.invalidateQueries({ queryKey: ['vault-core', vaultCacheKey] }); + }).catch((error) => { + if (domainRulesSaveSeqRef.current !== saveSeq) return; + pushToast('error', error instanceof Error ? error.message : t('txt_domain_rules_save_failed')); + void domainRulesQuery.refetch(); + }); + + return Promise.resolve(); + } useQuery({ queryKey: ['admin-backup-settings', vaultCacheKey], queryFn: () => backupActions.loadSettings(), @@ -1317,6 +1360,23 @@ export default function App() { const isImportRoute = routeLocation === IMPORT_ROUTE || IMPORT_ROUTE_ALIASES.has(routeLocation); const showSidebarToggle = mobileLayout && (location === '/vault' || location === '/sends'); const sidebarToggleTitle = location === '/vault' ? t('txt_folders') : t('txt_type'); + const demoDomainRules = useMemo(() => ({ + equivalentDomains: [ + ['nodewarden.example', 'nw.example'], + ['staging.nodewarden.example', 'preview.nodewarden.example'], + ], + customEquivalentDomains: [ + { id: 'demo-custom-1', domains: ['nodewarden.example', 'nw.example'], excluded: false }, + { id: 'demo-custom-2', domains: ['staging.nodewarden.example', 'preview.nodewarden.example'], excluded: false }, + ], + globalEquivalentDomains: [ + { type: 0, domains: ['youtube.com', 'google.com', 'gmail.com'], excluded: false }, + { type: 1, domains: ['apple.com', 'icloud.com'], excluded: false }, + { type: 10, domains: ['microsoft.com', 'office.com', 'xbox.com'], excluded: true }, + { type: -10001, domains: ['nodewarden.example', 'nw.example'], excluded: false }, + ], + object: 'domains', + }), []); const mobilePrimaryRoute = location === '/sends' ? '/sends' @@ -1330,6 +1390,7 @@ export default function App() { if (location === '/sends') return t('nav_sends'); if (location === '/admin') return t('nav_admin_panel'); if (location === '/security/devices') return t('nav_device_management'); + if (location === SETTINGS_DOMAIN_RULES_ROUTE) return t('nav_domain_rules'); if (location === '/backup') return t('nav_backup_strategy'); if (isImportRoute) return t('nav_import_export'); if (location === SETTINGS_ACCOUNT_ROUTE) return t('nav_account_settings'); @@ -1385,6 +1446,9 @@ export default function App() { authorizedDevices: authorizedDevicesQuery.data || [], authorizedDevicesLoading: authorizedDevicesQuery.isFetching, authorizedDevicesError: authorizedDevicesQuery.isError && !authorizedDevicesQuery.data ? t('txt_load_devices_failed') : '', + domainRules: IS_DEMO_MODE ? demoDomainRules : domainRulesQuery.data || null, + domainRulesLoading: domainRulesQuery.isFetching && !domainRulesQuery.data, + domainRulesError: domainRulesQuery.isError && !domainRulesQuery.data ? t('txt_domain_rules_load_failed') : '', onNavigate: navigate, onLogout: handleLogout, onNotify: pushToast, @@ -1432,6 +1496,10 @@ export default function App() { onLockTimeoutChange: setLockTimeoutMinutes, onSessionTimeoutActionChange: setSessionTimeoutAction, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, + onRefreshDomainRules: () => { + void domainRulesQuery.refetch(); + }, + onSaveDomainRules: handleSaveDomainRules, onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, onRemoveDevice: accountSecurityActions.openRemoveDevice, diff --git a/webapp/src/components/AppAuthenticatedShell.tsx b/webapp/src/components/AppAuthenticatedShell.tsx index 1e86c3c..7afd533 100644 --- a/webapp/src/components/AppAuthenticatedShell.tsx +++ b/webapp/src/components/AppAuthenticatedShell.tsx @@ -1,4 +1,4 @@ -import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; +import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import { Link } from 'wouter'; import AppMainRoutes from '@/components/AppMainRoutes'; import ThemeSwitch from '@/components/ThemeSwitch'; @@ -102,6 +102,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {t('nav_device_management')} + + + {t('nav_domain_rules')} + {isAdmin && ( @@ -114,7 +118,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
-
+
diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index b63c7cb..4fa84f9 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -1,19 +1,20 @@ import { lazy, Suspense } from 'preact/compat'; import { useEffect } from 'preact/hooks'; import { Link, Route, Switch } from 'wouter'; -import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; +import { ArrowUpDown, Cloud, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import LoadingState from '@/components/LoadingState'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { CiphersImportPayload } from '@/lib/api/vault'; import { t } from '@/lib/i18n'; -import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; +import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; import type { ExportRequest } from '@/lib/export-formats'; const VaultPage = lazy(() => import('@/components/VaultPage')); const SendsPage = lazy(() => import('@/components/SendsPage')); const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage')); const SettingsPage = lazy(() => import('@/components/SettingsPage')); +const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage')); const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage')); const AdminPage = lazy(() => import('@/components/AdminPage')); const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage')); @@ -56,6 +57,9 @@ export interface AppMainRoutesProps { authorizedDevices: AuthorizedDevice[]; authorizedDevicesLoading: boolean; authorizedDevicesError: string; + domainRules: DomainRules | null; + domainRulesLoading: boolean; + domainRulesError: string; onNavigate: (path: string) => void; onLogout: () => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; @@ -108,6 +112,8 @@ export interface AppMainRoutesProps { onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void; onRefreshAuthorizedDevices: () => Promise; + onRefreshDomainRules: () => void; + onSaveDomainRules: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise; onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise; onRevokeDeviceTrust: (device: AuthorizedDevice) => void; onRemoveDevice: (device: AuthorizedDevice) => void; @@ -268,6 +274,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { {t('nav_device_management')} + + + {t('nav_domain_rules')} + {t('nav_import_export')} @@ -319,6 +329,28 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { + +
+ {props.mobileLayout && ( +
+ +
+ )} + }> + + +
+
{props.mobileLayout && ( diff --git a/webapp/src/components/DomainRulesPage.tsx b/webapp/src/components/DomainRulesPage.tsx new file mode 100644 index 0000000..025bd03 --- /dev/null +++ b/webapp/src/components/DomainRulesPage.tsx @@ -0,0 +1,529 @@ +import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { Check, ChevronDown, ChevronUp, ExternalLink, Pencil, Plus, RefreshCw, Save, Trash2, X } from 'lucide-preact'; +import LoadingState from '@/components/LoadingState'; +import { t } from '@/lib/i18n'; +import type { CustomEquivalentDomain, DomainRules } from '@/lib/types'; +import { normalizeEquivalentDomain } from '@shared/domain-normalize'; + +const CUSTOM_GLOBAL_DOMAINS_PR_URL = 'https://github.com/shuaiplus/nodewarden/edit/main/src/static/global_domains.custom.json'; + +interface DomainRulesPageProps { + rules: DomainRules | null; + loading: boolean; + error: string; + onRefresh: () => void; + onSave: (customEquivalentDomains: CustomEquivalentDomain[], excludedGlobalEquivalentDomains: number[]) => Promise; + onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; +} + +interface DomainRuleSummaryProps { + text: string; + expanded: boolean; + onToggle: () => void; +} + +function normalizeDomain(value: string): string { + return normalizeEquivalentDomain(value); +} + +function normalizeDomainList(domains: string[]): string[] { + return Array.from(new Set(domains.map(normalizeDomain).filter(Boolean))); +} + +function isValidDomainName(value: string): boolean { + return !!normalizeEquivalentDomain(value); +} + +function getInvalidDomainIndexes(domains: string[]): Set { + const invalid = new Set(); + domains.forEach((domain, index) => { + if (!isValidDomainName(domain)) invalid.add(index); + }); + return invalid; +} + +function createDraftId(): string { + return `custom-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function createEmptyDomains(): string[] { + return ['', '']; +} + +function DomainRuleSummary(props: DomainRuleSummaryProps) { + const textRef = useRef(null); + const [canExpand, setCanExpand] = useState(false); + + useEffect(() => { + const node = textRef.current; + if (!node) return undefined; + + const measure = () => { + const width = node.getBoundingClientRect().width; + if (!width || typeof document === 'undefined') { + setCanExpand(false); + return; + } + + const probe = document.createElement('span'); + const styles = window.getComputedStyle(node); + probe.textContent = props.text; + probe.style.position = 'absolute'; + probe.style.visibility = 'hidden'; + probe.style.whiteSpace = 'nowrap'; + probe.style.font = styles.font; + probe.style.letterSpacing = styles.letterSpacing; + probe.style.left = '-9999px'; + probe.style.top = '-9999px'; + document.body.appendChild(probe); + const fullWidth = probe.getBoundingClientRect().width; + probe.remove(); + setCanExpand(fullWidth > width + 1); + }; + + measure(); + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + } + + const observer = new ResizeObserver(measure); + observer.observe(node); + return () => observer.disconnect(); + }, [props.text]); + + return ( + <> + + {props.text} + + {canExpand && ( + + )} + + ); +} + +function toEditableCustomRules(rules: DomainRules | null): CustomEquivalentDomain[] { + const source = rules?.customEquivalentDomains?.length + ? rules.customEquivalentDomains + : (rules?.equivalentDomains || []).map((domains, index) => ({ + id: `custom-${index}`, + domains, + excluded: false, + })); + return source.map((rule, index) => ({ + id: String(rule.id || `custom-${index}`), + domains: rule.domains.length >= 2 ? [...rule.domains] : createEmptyDomains(), + excluded: !!rule.excluded, + })); +} + +export default function DomainRulesPage(props: DomainRulesPageProps) { + const [customRules, setCustomRules] = useState([]); + const [newRuleDomains, setNewRuleDomains] = useState(null); + const [editingRuleId, setEditingRuleId] = useState(null); + const [editingDomains, setEditingDomains] = useState(createEmptyDomains); + const [newRuleInvalidIndexes, setNewRuleInvalidIndexes] = useState>(new Set()); + const [editingInvalidIndexes, setEditingInvalidIndexes] = useState>(new Set()); + const [excludedTypes, setExcludedTypes] = useState>(new Set()); + const [expandedCustomRules, setExpandedCustomRules] = useState>(new Set()); + const [expandedGlobalRules, setExpandedGlobalRules] = useState>(new Set()); + const [saving, setSaving] = useState(false); + const [filter, setFilter] = useState(''); + + useEffect(() => { + setCustomRules(toEditableCustomRules(props.rules)); + setNewRuleDomains(null); + setEditingRuleId(null); + setEditingDomains(createEmptyDomains()); + setNewRuleInvalidIndexes(new Set()); + setEditingInvalidIndexes(new Set()); + setExpandedCustomRules(new Set()); + setExpandedGlobalRules(new Set()); + setExcludedTypes(new Set((props.rules?.globalEquivalentDomains || []).filter((entry) => entry.excluded).map((entry) => entry.type))); + }, [props.rules]); + + const sortedGlobals = useMemo(() => { + return [...(props.rules?.globalEquivalentDomains || [])].sort((a, b) => { + const aKey = a.domains[0] || ''; + const bKey = b.domains[0] || ''; + return aKey.localeCompare(bKey, undefined, { sensitivity: 'base' }); + }); + }, [props.rules]); + + const filteredGlobals = useMemo(() => { + const needle = filter.trim().toLowerCase(); + if (!needle) return sortedGlobals; + return sortedGlobals.filter((entry) => entry.domains.some((domain) => domain.includes(needle))); + }, [filter, sortedGlobals]); + + function setCustomRuleEnabled(index: number, enabled: boolean): void { + setCustomRules((rules) => rules.map((rule, ruleIndex) => ruleIndex === index ? { ...rule, excluded: !enabled } : rule)); + } + + function beginEditCustomRule(rule: CustomEquivalentDomain): void { + setNewRuleDomains(null); + setEditingRuleId(rule.id); + setEditingDomains(rule.domains.length >= 2 ? [...rule.domains] : createEmptyDomains()); + setEditingInvalidIndexes(new Set()); + } + + function confirmEditCustomRule(): void { + if (!editingRuleId) return; + const invalidIndexes = getInvalidDomainIndexes(editingDomains); + setEditingInvalidIndexes(invalidIndexes); + if (invalidIndexes.size) { + props.onNotify('warning', t('txt_domain_rule_invalid_domains')); + return; + } + const domains = normalizeDomainList(editingDomains); + if (domains.length < 2) { + props.onNotify('warning', t('txt_domain_rule_needs_two_domains')); + return; + } + setCustomRules((rules) => rules.map((rule) => rule.id === editingRuleId ? { ...rule, domains } : rule)); + setEditingRuleId(null); + setEditingDomains(createEmptyDomains()); + } + + function cancelEditCustomRule(): void { + setEditingRuleId(null); + setEditingDomains(createEmptyDomains()); + setEditingInvalidIndexes(new Set()); + } + + function addNewRule(): void { + const invalidIndexes = getInvalidDomainIndexes(newRuleDomains || []); + setNewRuleInvalidIndexes(invalidIndexes); + if (invalidIndexes.size) { + props.onNotify('warning', t('txt_domain_rule_invalid_domains')); + return; + } + const domains = normalizeDomainList(newRuleDomains || []); + if (domains.length < 2) { + props.onNotify('warning', t('txt_domain_rule_needs_two_domains')); + return; + } + setCustomRules((rules) => [ + { + id: createDraftId(), + domains, + excluded: false, + }, + ...rules, + ]); + setNewRuleDomains(null); + setNewRuleInvalidIndexes(new Set()); + } + + function removeCustomRule(index: number): void { + setCustomRules((rules) => rules.filter((_, currentIndex) => currentIndex !== index)); + } + + function toggleGlobal(type: number): void { + setExcludedTypes((current) => { + const next = new Set(current); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + return next; + }); + } + + function toggleExpandedCustomRule(id: string): void { + setExpandedCustomRules((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function toggleExpandedGlobalRule(type: number): void { + setExpandedGlobalRules((current) => { + const next = new Set(current); + if (next.has(type)) next.delete(type); + else next.add(type); + return next; + }); + } + + async function save(): Promise { + const normalizedCustomRules = customRules.map((rule) => ({ + ...rule, + domains: normalizeDomainList(rule.domains), + })); + if (normalizedCustomRules.some((rule) => rule.domains.some((domain) => !isValidDomainName(domain)))) { + props.onNotify('warning', t('txt_domain_rule_invalid_domains')); + return; + } + if (normalizedCustomRules.some((rule) => rule.domains.length < 2)) { + props.onNotify('warning', t('txt_domain_rule_needs_two_domains')); + return; + } + + const excludedGlobalEquivalentDomains = (props.rules?.globalEquivalentDomains || []) + .filter((entry) => excludedTypes.has(entry.type)) + .map((entry) => entry.type); + + setSaving(true); + try { + await props.onSave(normalizedCustomRules, excludedGlobalEquivalentDomains); + props.onNotify('success', t('txt_domain_rules_saved')); + } catch (error) { + props.onNotify('error', error instanceof Error ? error.message : t('txt_domain_rules_save_failed')); + } finally { + setSaving(false); + } + } + + function renderDomainInputs(domains: string[], invalidIndexes: Set, onChange: (index: number, value: string) => void, onAdd: () => void, onRemove?: (index: number) => void) { + return ( +
+ {domains.map((domain, index) => ( +
+ onChange(index, (event.currentTarget as HTMLInputElement).value)} + /> + {domains.length > 2 && onRemove && ( + + )} + {index < domains.length - 1 && ,} +
+ ))} + +
+ ); + } + + if (props.loading && !props.rules) { + return ; + } + + return ( +
+
+
+
{t('nav_domain_rules')}
+

{t('txt_domain_rules_description')}

+
+
+ + + + + {t('txt_submit_pr')} + +
+
+ +
+
+
+

{t('txt_custom_equivalent_domains')}

+ +
+ + {props.error &&
{props.error}
} + + {newRuleDomains && ( +
+
+ {renderDomainInputs( + newRuleDomains, + newRuleInvalidIndexes, + (index, value) => { + setNewRuleDomains((domains) => (domains || createEmptyDomains()).map((domain, currentIndex) => currentIndex === index ? value : domain)); + setNewRuleInvalidIndexes((current) => { + const next = new Set(current); + next.delete(index); + return next; + }); + }, + () => { + setNewRuleDomains((domains) => [...(domains || createEmptyDomains()), '']); + setNewRuleInvalidIndexes(new Set()); + }, + (index) => setNewRuleDomains((domains) => { + const current = domains || createEmptyDomains(); + setNewRuleInvalidIndexes(new Set()); + return current.length > 2 ? current.filter((_, currentIndex) => currentIndex !== index) : current; + }) + )} +
+
+ + +
+
+ )} + +
+ {customRules.map((rule, ruleIndex) => ( + editingRuleId === rule.id ? ( +
+
+ {renderDomainInputs( + editingDomains, + editingInvalidIndexes, + (domainIndex, value) => { + setEditingDomains((domains) => domains.map((domain, currentIndex) => currentIndex === domainIndex ? value : domain)); + setEditingInvalidIndexes((current) => { + const next = new Set(current); + next.delete(domainIndex); + return next; + }); + }, + () => { + setEditingDomains((domains) => [...domains, '']); + setEditingInvalidIndexes(new Set()); + }, + (domainIndex) => { + setEditingInvalidIndexes(new Set()); + setEditingDomains((domains) => domains.length > 2 ? domains.filter((_, currentIndex) => currentIndex !== domainIndex) : domains); + } + )} +
+
+ + +
+
+ ) : ( +
+ setCustomRuleEnabled(ruleIndex, (event.currentTarget as HTMLInputElement).checked)} + /> + toggleExpandedCustomRule(rule.id)} + /> +
+ + +
+
+ ) + ))} + {!customRules.length && !newRuleDomains &&
{t('txt_no_custom_domain_rules')}
} +
+
+ +
+
+

{t('txt_global_equivalent_domains')}

+
+ setFilter((event.currentTarget as HTMLInputElement).value)} + /> +
+
+ +
+ {filteredGlobals.map((entry) => ( +
+ toggleGlobal(entry.type)} + /> + toggleExpandedGlobalRule(entry.type)} + /> +
+ ))} + {!filteredGlobals.length &&
{t('txt_no_domain_rules_found')}
} +
+
+
+
+ ); +} diff --git a/webapp/src/lib/api/domains.ts b/webapp/src/lib/api/domains.ts new file mode 100644 index 0000000..d1ab2b0 --- /dev/null +++ b/webapp/src/lib/api/domains.ts @@ -0,0 +1,62 @@ +import { t } from '@/lib/i18n'; +import type { DomainRules, TokenError } from '@/lib/types'; +import { parseErrorMessage, parseJson, type AuthedFetch } from './shared'; + +function normalizeDomainsResponse(body: Partial & Record): DomainRules { + const equivalentDomains = Array.isArray(body.equivalentDomains) + ? body.equivalentDomains + : Array.isArray(body.EquivalentDomains) + ? body.EquivalentDomains as string[][] + : []; + const globalEquivalentDomains = Array.isArray(body.globalEquivalentDomains) + ? body.globalEquivalentDomains + : Array.isArray(body.GlobalEquivalentDomains) + ? body.GlobalEquivalentDomains as DomainRules['globalEquivalentDomains'] + : []; + const customEquivalentDomains = Array.isArray(body.customEquivalentDomains) + ? body.customEquivalentDomains as DomainRules['customEquivalentDomains'] + : Array.isArray(body.CustomEquivalentDomains) + ? body.CustomEquivalentDomains as DomainRules['customEquivalentDomains'] + : equivalentDomains.map((domains, index) => ({ + id: `custom:${index}`, + domains, + excluded: false, + })); + + return { + equivalentDomains, + customEquivalentDomains, + globalEquivalentDomains, + object: 'domains', + }; +} + +export async function getDomainRules(authedFetch: AuthedFetch): Promise { + const resp = await authedFetch('/api/settings/domains'); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_domain_rules_load_failed'))); + const body = await parseJson & Record>(resp); + if (!body) throw new Error(t('txt_domain_rules_invalid_response')); + return normalizeDomainsResponse(body); +} + +export async function saveDomainRules( + authedFetch: AuthedFetch, + payload: { + customEquivalentDomains: DomainRules['customEquivalentDomains']; + equivalentDomains: string[][]; + excludedGlobalEquivalentDomains: number[]; + } +): Promise { + const resp = await authedFetch('/api/settings/domains', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + const body = await parseJson(resp); + throw new Error(body?.error_description || body?.error || t('txt_domain_rules_save_failed')); + } + const body = await parseJson & Record>(resp); + if (!body) throw new Error(t('txt_domain_rules_invalid_response')); + return normalizeDomainsResponse(body); +} diff --git a/webapp/src/lib/demo.ts b/webapp/src/lib/demo.ts index f43dd86..dfb410d 100644 --- a/webapp/src/lib/demo.ts +++ b/webapp/src/lib/demo.ts @@ -909,6 +909,8 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti authorizedDevices: state.authorizedDevices, authorizedDevicesLoading: false, authorizedDevicesError: '', + domainRulesLoading: false, + domainRulesError: '', onImport: async () => { await readonly(); return createDemoImportResult(); @@ -1055,6 +1057,10 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti onRefreshAuthorizedDevices: async () => { notify('success', t('txt_demo_devices_refreshed')); }, + onRefreshDomainRules: () => { + notify('success', t('txt_domain_rules_refreshed')); + }, + onSaveDomainRules: readonly, onRenameAuthorizedDevice: async (device, name) => { const normalized = String(name || '').trim(); if (!normalized) { diff --git a/webapp/src/lib/i18n/locales/en.ts b/webapp/src/lib/i18n/locales/en.ts index 35ad25c..97fe7f5 100644 --- a/webapp/src/lib/i18n/locales/en.ts +++ b/webapp/src/lib/i18n/locales/en.ts @@ -874,7 +874,28 @@ const en: Record = { "txt_status_inactive": "Inactive", "txt_language": "Language", "txt_display_language": "Display language", - "txt_language_saved_locally": "This preference is saved in this browser and used before the app loads next time." + "txt_language_saved_locally": "This preference is saved in this browser and used before the app loads next time.", + "nav_domain_rules": "Domain Rules", + "txt_domain_rules_description": "Mark sites that share one login as equivalent domains. Global rules come from the preset list; custom rules only affect your own matching.", + "txt_submit_pr": "Submit PR", + "txt_custom_equivalent_domains": "Custom equivalent domains", + "txt_global_equivalent_domains": "Global equivalent domains", + "txt_domain_group": "Domain group", + "txt_no_custom_domain_rules": "No custom domain rules", + "txt_no_domain_rules_found": "No domain rules found", + "txt_search_domains": "Search domains", + "txt_domain_rules_saved": "Domain rules saved", + "txt_domain_rules_save_failed": "Saving domain rules failed", + "txt_domain_rules_load_failed": "Loading domain rules failed", + "txt_domain_rules_invalid_response": "Invalid domain rules response", + "txt_domain_rules_refreshed": "Domain rules refreshed", + "txt_saving": "Saving...", + "txt_domain_rule_needs_two_domains": "Each domain rule needs at least two domains.", + "txt_domain_rule_invalid_domains": "Please enter valid domains, such as example.com.", + "txt_add_domain": "Add domain", + "txt_expand": "Expand", + "txt_collapse": "Collapse", + "txt_remove_domain": "Remove domain" }; export default en; diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts index 2d88e51..9b6f7d7 100644 --- a/webapp/src/lib/i18n/locales/es.ts +++ b/webapp/src/lib/i18n/locales/es.ts @@ -874,7 +874,28 @@ const es: Record = { "txt_status_inactive": "Inactivo", "txt_language": "Idioma", "txt_display_language": "Idioma de visualización", - "txt_language_saved_locally": "Esta preferencia se guarda en este navegador y se usa antes de que la aplicación cargue la próxima vez." + "txt_language_saved_locally": "Esta preferencia se guarda en este navegador y se usa antes de que la aplicación cargue la próxima vez.", + "nav_domain_rules": "Reglas de dominio", + "txt_domain_rules_description": "Marca los sitios que comparten un inicio de sesión como dominios equivalentes. Las reglas globales vienen de la lista predefinida; las personalizadas solo afectan tus coincidencias.", + "txt_submit_pr": "Enviar PR", + "txt_custom_equivalent_domains": "Dominios equivalentes personalizados", + "txt_global_equivalent_domains": "Dominios equivalentes globales", + "txt_domain_group": "Grupo de dominios", + "txt_no_custom_domain_rules": "No hay reglas de dominio personalizadas", + "txt_no_domain_rules_found": "No se encontraron reglas de dominio", + "txt_search_domains": "Buscar dominios", + "txt_domain_rules_saved": "Reglas de dominio guardadas", + "txt_domain_rules_save_failed": "No se pudieron guardar las reglas de dominio", + "txt_domain_rules_load_failed": "No se pudieron cargar las reglas de dominio", + "txt_domain_rules_invalid_response": "Respuesta de reglas de dominio no válida", + "txt_domain_rules_refreshed": "Reglas de dominio actualizadas", + "txt_saving": "Guardando...", + "txt_domain_rule_needs_two_domains": "Cada regla de dominio necesita al menos dos dominios.", + "txt_domain_rule_invalid_domains": "Introduce dominios válidos, como example.com.", + "txt_add_domain": "Añadir dominio", + "txt_expand": "Expandir", + "txt_collapse": "Contraer", + "txt_remove_domain": "Quitar dominio" }; export default es; diff --git a/webapp/src/lib/i18n/locales/ru.ts b/webapp/src/lib/i18n/locales/ru.ts index 20933f9..043fb31 100644 --- a/webapp/src/lib/i18n/locales/ru.ts +++ b/webapp/src/lib/i18n/locales/ru.ts @@ -874,7 +874,28 @@ const ru: Record = { "txt_status_inactive": "Неактивный", "txt_language": "Язык", "txt_display_language": "Язык дисплея", - "txt_language_saved_locally": "Этот выбор сохраняется в текущем браузере и применяется при следующей загрузке приложения." + "txt_language_saved_locally": "Этот выбор сохраняется в текущем браузере и применяется при следующей загрузке приложения.", + "nav_domain_rules": "Правила доменов", + "txt_domain_rules_description": "Отмечайте сайты с одним логином как эквивалентные домены. Глобальные правила берутся из готового списка, а пользовательские влияют только на ваши совпадения.", + "txt_submit_pr": "Отправить PR", + "txt_custom_equivalent_domains": "Пользовательские эквивалентные домены", + "txt_global_equivalent_domains": "Глобальные эквивалентные домены", + "txt_domain_group": "Группа доменов", + "txt_no_custom_domain_rules": "Нет пользовательских правил доменов", + "txt_no_domain_rules_found": "Правила доменов не найдены", + "txt_search_domains": "Поиск доменов", + "txt_domain_rules_saved": "Правила доменов сохранены", + "txt_domain_rules_save_failed": "Не удалось сохранить правила доменов", + "txt_domain_rules_load_failed": "Не удалось загрузить правила доменов", + "txt_domain_rules_invalid_response": "Недопустимый ответ правил доменов", + "txt_domain_rules_refreshed": "Правила доменов обновлены", + "txt_saving": "Сохранение...", + "txt_domain_rule_needs_two_domains": "В каждом правиле доменов должно быть не менее двух доменов.", + "txt_domain_rule_invalid_domains": "Введите корректные домены, например example.com.", + "txt_add_domain": "Добавить домен", + "txt_expand": "Развернуть", + "txt_collapse": "Свернуть", + "txt_remove_domain": "Удалить домен" }; export default ru; diff --git a/webapp/src/lib/i18n/locales/zh-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts index f78517c..6255159 100644 --- a/webapp/src/lib/i18n/locales/zh-CN.ts +++ b/webapp/src/lib/i18n/locales/zh-CN.ts @@ -874,7 +874,28 @@ const zhCN: Record = { "txt_status_inactive": "未激活", "txt_language": "语言", "txt_display_language": "显示语言", - "txt_language_saved_locally": "此偏好会保存在当前浏览器中,下次打开应用前就会生效。" + "txt_language_saved_locally": "此偏好会保存在当前浏览器中,下次打开应用前就会生效。", + "nav_domain_rules": "域名规则", + "txt_domain_rules_description": "多个网站共用同一登录信息时,可将它们设为等效域名;全局规则来自预置列表,自定义规则只影响你自己的匹配。", + "txt_submit_pr": "提交 PR", + "txt_custom_equivalent_domains": "自定义等效域名", + "txt_global_equivalent_domains": "全局等效域名", + "txt_domain_group": "域名组", + "txt_no_custom_domain_rules": "暂无自定义域名规则", + "txt_no_domain_rules_found": "未找到域名规则", + "txt_search_domains": "搜索域名", + "txt_domain_rules_saved": "域名规则已保存", + "txt_domain_rules_save_failed": "保存域名规则失败", + "txt_domain_rules_load_failed": "加载域名规则失败", + "txt_domain_rules_invalid_response": "域名规则响应无效", + "txt_domain_rules_refreshed": "域名规则已刷新", + "txt_saving": "保存中...", + "txt_domain_rule_needs_two_domains": "每条域名规则至少需要两个域名。", + "txt_domain_rule_invalid_domains": "请输入有效域名,例如 example.com。", + "txt_add_domain": "新增域名", + "txt_expand": "展开", + "txt_collapse": "收起", + "txt_remove_domain": "移除域名" }; export default zhCN; diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts index a3ae5e4..5c755f6 100644 --- a/webapp/src/lib/i18n/locales/zh-TW.ts +++ b/webapp/src/lib/i18n/locales/zh-TW.ts @@ -874,7 +874,28 @@ const zhTW: Record = { "txt_status_inactive": "未激活", "txt_language": "語言", "txt_display_language": "顯示語言", - "txt_language_saved_locally": "此偏好會保存在當前瀏覽器中,下次打開應用前就會生效。" + "txt_language_saved_locally": "此偏好會保存在當前瀏覽器中,下次打開應用前就會生效。", + "nav_domain_rules": "域名規則", + "txt_domain_rules_description": "多個網站共用同一登入資訊時,可將它們設為等效域名;全局規則來自預置列表,自定義規則只影響你自己的匹配。", + "txt_submit_pr": "提交 PR", + "txt_custom_equivalent_domains": "自定義等效域名", + "txt_global_equivalent_domains": "全局等效域名", + "txt_domain_group": "域名組", + "txt_no_custom_domain_rules": "暫無自定義域名規則", + "txt_no_domain_rules_found": "未找到域名規則", + "txt_search_domains": "搜索域名", + "txt_domain_rules_saved": "域名規則已保存", + "txt_domain_rules_save_failed": "保存域名規則失敗", + "txt_domain_rules_load_failed": "加載域名規則失敗", + "txt_domain_rules_invalid_response": "域名規則響應無效", + "txt_domain_rules_refreshed": "域名規則已刷新", + "txt_saving": "保存中...", + "txt_domain_rule_needs_two_domains": "每條域名規則至少需要兩個域名。", + "txt_domain_rule_invalid_domains": "請輸入有效域名,例如 example.com。", + "txt_add_domain": "新增域名", + "txt_expand": "展開", + "txt_collapse": "收起", + "txt_remove_domain": "移除域名" }; export default zhTW; diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 470fceb..bda75fe 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -359,3 +359,22 @@ export interface AuthorizedDevice { trustedTokenCount: number; trustedUntil: string | null; } + +export interface GlobalEquivalentDomain { + type: number; + domains: string[]; + excluded: boolean; +} + +export interface CustomEquivalentDomain { + id: string; + domains: string[]; + excluded: boolean; +} + +export interface DomainRules { + equivalentDomains: string[][]; + customEquivalentDomains: CustomEquivalentDomain[]; + globalEquivalentDomains: GlobalEquivalentDomain[]; + object: 'domains'; +} diff --git a/webapp/src/styles/management.css b/webapp/src/styles/management.css index bb1bf64..18ca47e 100644 --- a/webapp/src/styles/management.css +++ b/webapp/src/styles/management.css @@ -2,6 +2,13 @@ @apply grid gap-3; } +.domain-rules-route { + height: 100%; + min-height: 0; + overflow: hidden; + grid-template-rows: minmax(0, 1fr); +} + .import-export-page { @apply grid gap-3; } @@ -19,9 +26,8 @@ .backup-operations-sidebar, .backup-destination-sidebar, .backup-detail-panel { - @apply min-w-0 rounded-xl bg-white p-3; - border: 1px solid #d8dee8; - box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); + @apply min-w-0 rounded-2xl border bg-panel p-3 shadow-soft; + border-color: var(--line); } .backup-actions-stack { @@ -305,7 +311,7 @@ } .backup-browser-list { - @apply overflow-hidden rounded-xl bg-white; + @apply overflow-hidden rounded-xl border bg-white; border: 1px solid var(--line); } @@ -875,3 +881,275 @@ background: #e2e8f0; color: #475569; } + +.section-heading-row { + @apply mb-3.5 flex items-center justify-between gap-3; +} + +.section-heading-row h3 { + @apply mb-0; +} + +.domain-rules-page { + @apply grid min-h-0 gap-3.5; + height: 100%; + overflow: hidden; + grid-template-rows: auto minmax(0, 1fr); +} + +.domain-rules-toolbar { + @apply flex flex-wrap items-start justify-between gap-3; +} + +.domain-rules-toolbar-copy { + max-width: 760px; +} + +.domain-rules-toolbar-title { + @apply text-base font-bold; + color: var(--text); +} + +.domain-rules-toolbar-copy p { + @apply mt-1.5 text-sm leading-6; + color: var(--muted-strong); +} + +.domain-rules-grid { + min-height: 0; + grid-template-columns: minmax(380px, 1fr) minmax(420px, 1.08fr); +} + +.domain-rules-custom, +.domain-rules-global { + @apply flex min-h-0 flex-col rounded-2xl border bg-panel shadow-soft; + border-color: var(--line); +} + +.domain-rules-heading-actions { + @apply flex flex-wrap items-center justify-end gap-2; +} + +.domain-rules-filter { + width: min(240px, 100%); +} + +.domain-rules-table { + @apply grid min-h-0 flex-1 content-start gap-2 overflow-auto pr-0.5; + overflow-anchor: none; +} + +.domain-rule-row { + @apply grid items-center gap-2.5 rounded-md px-2.5 py-2.5; + grid-template-columns: 18px minmax(0, 1fr) auto auto; + border: 1px solid var(--line); + background: var(--panel); +} + +.domain-rule-row > input[type='checkbox'] { + align-self: center; +} + +.domain-rule-readonly-row { + grid-template-columns: 18px minmax(0, 1fr) auto; +} + +.domain-rule-editing-row { + @apply items-start; + grid-template-columns: minmax(360px, 1fr) auto; + column-gap: 12px; +} + +.domain-rule-domains { + display: block; + line-height: 20px; + min-width: 0; + max-height: 20px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + transition: + max-height 180ms var(--ease-out-soft), + opacity 140ms var(--ease-smooth); +} + +.domain-rule-row-expanded { + @apply items-start; +} + +.domain-rule-row-expanded > input[type='checkbox'], +.domain-rule-row-expanded .domain-rule-expand-btn, +.domain-rule-row-expanded .domain-rule-row-actions { + margin-top: 1px; +} + +.domain-rule-row-expanded .domain-rule-domains { + overflow: visible; + text-overflow: clip; + white-space: normal; + overflow-wrap: anywhere; +} + +.domain-rule-domains-expanded { + max-height: 220px; +} + +.domain-rule-expand-btn { + @apply flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-0 p-0; + background: transparent; + color: var(--muted-strong); + transition: + background-color var(--dur-fast) var(--ease-smooth), + color var(--dur-fast) var(--ease-smooth), + transform var(--dur-fast) var(--ease-out-soft); +} + +.domain-rule-expand-btn:hover { + background: var(--panel-soft); + color: var(--primary); + transform: translateY(-1px); +} + +.domain-rule-main { + width: 100%; + min-width: 0; +} + +.domain-rule-inputs { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 18px; + align-items: center; +} + +.domain-rule-input-piece { + position: relative; + min-width: 0; + @apply flex items-center; +} + +.domain-rule-inline-input { + width: 100%; + min-width: 0; + padding-right: 52px; +} + +.domain-rule-inline-input.domain-rule-input-invalid { + border-color: rgba(217, 45, 87, 0.78); + background: color-mix(in srgb, var(--danger) 5%, var(--panel)); + box-shadow: 0 0 0 3px rgba(217, 45, 87, 0.10), inset 0 1px 0 rgba(255, 255, 255, 0.72); +} + +.domain-rule-inline-input.domain-rule-input-invalid:focus { + border-color: rgba(217, 45, 87, 0.86); + box-shadow: 0 0 0 4px rgba(217, 45, 87, 0.14), inset 0 1px 0 rgba(255, 255, 255, 0.78); +} + +.domain-rule-operator { + position: absolute; + top: 50%; + right: -12px; + transform: translateY(-50%); + color: var(--muted); + font-weight: 700; + pointer-events: none; +} + +.domain-rule-input-piece:nth-child(even) .domain-rule-operator { + display: none; +} + +.domain-rule-mini-btn, +.domain-rule-icon-btn { + @apply h-9 w-9 justify-center p-0; +} + +.domain-rule-input-remove { + @apply absolute top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-full border-0 p-0; + right: 1rem; + background: var(--panel-soft); + color: var(--primary); + transition: + background-color var(--dur-fast) var(--ease-smooth), + color var(--dur-fast) var(--ease-smooth), + transform var(--dur-fast) var(--ease-out-soft); +} + +.domain-rule-input-remove:hover { + background: color-mix(in srgb, var(--danger) 12%, var(--panel)); + color: var(--danger); + transform: translateY(-50%) scale(1.04); +} + +.domain-rule-row-actions { + @apply flex items-center self-center gap-2; +} + +.domain-rule-row-actions .btn { + white-space: nowrap; +} + +.domain-rule-editing-row .domain-rule-row-actions { + align-self: start; + padding-top: 6px; +} + +.domain-rule-new-row { + @apply mb-2; + background: var(--panel-soft); +} + +@media (max-width: 1180px) { + .route-stage-fixed { + overflow: auto; + } + + .domain-rules-route { + height: auto; + overflow: visible; + grid-template-rows: auto; + } + + .domain-rules-page { + height: auto; + overflow: visible; + } + + .domain-rules-grid { + grid-auto-rows: minmax(320px, min(54vh, 560px)); + } + + .domain-rules-table { + max-height: none; + } +} + +@media (max-width: 560px) { + .domain-rules-page { + height: auto; + } + + .domain-rules-grid { + grid-auto-rows: auto; + } + + .domain-rules-table { + max-height: 56vh; + } + + .domain-rule-editing-row { + grid-template-columns: 1fr; + } + + .domain-rule-inputs { + grid-template-columns: 1fr; + } + + .domain-rule-operator { + display: none; + } + + .domain-rule-editing-row .domain-rule-row-actions { + padding-top: 0; + } +} diff --git a/webapp/src/styles/responsive.css b/webapp/src/styles/responsive.css index 04ed68c..06ec779 100644 --- a/webapp/src/styles/responsive.css +++ b/webapp/src/styles/responsive.css @@ -417,6 +417,10 @@ @apply rounded-2xl; } + .detail-col { + @apply overflow-visible rounded-2xl border-0 bg-transparent p-0 shadow-none; + } + .card { @apply p-3.5; } @@ -477,6 +481,7 @@ } .settings-modules-grid, + .domain-rules-grid, .password-settings-grid { grid-template-columns: 1fr; } diff --git a/webapp/src/styles/shell.css b/webapp/src/styles/shell.css index 22a23f2..652a565 100644 --- a/webapp/src/styles/shell.css +++ b/webapp/src/styles/shell.css @@ -189,6 +189,10 @@ @apply h-full min-h-0 overflow-auto; } +.route-stage-fixed { + overflow: hidden; +} + .mobile-sidebar-mask { @apply pointer-events-none invisible fixed inset-0 opacity-0; background: rgba(15, 23, 42, 0.36); diff --git a/webapp/src/styles/vault.css b/webapp/src/styles/vault.css index 70ebe63..2f0305e 100644 --- a/webapp/src/styles/vault.css +++ b/webapp/src/styles/vault.css @@ -5,7 +5,8 @@ .sidebar, .list-panel, -.card { +.card, +.detail-col { @apply rounded-2xl border bg-panel shadow-soft; border-color: var(--line); } @@ -483,7 +484,7 @@ } .detail-col { - @apply min-h-0 overflow-auto; + @apply min-h-0 overflow-auto p-2; } .mobile-panel-head {