feat: add domain rules management feature

- Introduced a new DomainRulesPage component for managing custom and global equivalent domains.
- Updated AppMainRoutes to include a route for domain rules.
- Added API functions to fetch and save domain rules.
- Enhanced localization with new strings for domain rules in multiple languages.
- Updated styles for the new domain rules interface and ensured responsiveness.
- Added types for domain rules in the TypeScript definitions.
This commit is contained in:
shuaiplus
2026-05-06 00:33:09 +08:00
parent 246c73a3d3
commit 0a001bebcc
32 changed files with 2045 additions and 32 deletions
+214
View File
@@ -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<GlobalEquivalentDomain> & {
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<number>();
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<string>();
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<string, string>();
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<string, string[]>();
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<string>();
for (const [index, item] of input.entries()) {
const record = Array.isArray(item)
? { domains: item, excluded: false, id: '' }
: item && typeof item === 'object'
? item as Record<string, unknown>
: 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<number>();
const out: number[] = [];
for (const item of input) {
const type = Number(typeof item === 'object' && item !== null ? (item as Record<string, unknown>).type : item);
const excluded = typeof item === 'object' && item !== null
? Boolean((item as Record<string, unknown>).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',
};
}
+67
View File
@@ -0,0 +1,67 @@
import type { UserDomainSettings } from '../types';
import { normalizeCustomEquivalentDomains, normalizeEquivalentDomains } from './domain-rules';
function parseJsonArray<T>(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<UserDomainSettings> {
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<string[]>(row?.equivalent_domains, []));
const storedCustomEquivalentDomains = row?.custom_equivalent_domains
? normalizeCustomEquivalentDomains(parseJsonArray<unknown>(row.custom_equivalent_domains, []))
: [];
const customEquivalentDomains = storedCustomEquivalentDomains.length
? storedCustomEquivalentDomains
: normalizeCustomEquivalentDomains(equivalentDomains);
return {
userId,
equivalentDomains,
customEquivalentDomains,
excludedGlobalEquivalentDomains: parseJsonArray<number>(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<void> {
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();
}
+5
View File
@@ -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)',
+29 -2
View File
@@ -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<void> {
await saveStoredUserDomainSettings(
this.db,
userId,
equivalentDomains,
customEquivalentDomains,
excludedGlobalEquivalentDomains,
new Date().toISOString()
);
await this.updateRevisionDate(userId);
}
// --- Ciphers ---
async getCipher(id: string): Promise<Cipher | null> {