diff --git a/.gitignore b/.gitignore index 968dd52..70fa2b5 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ public/web-vault/ # reference repos vaultwarden/ bw_web_builds/ + +# Generated seed SQL (global equivalent domains) +sql/global_domains_seed.sql diff --git a/migrations/0008_add_eq_domains.sql b/migrations/0008_add_eq_domains.sql new file mode 100644 index 0000000..381364f --- /dev/null +++ b/migrations/0008_add_eq_domains.sql @@ -0,0 +1,31 @@ +-- Migration: Add equivalent domains (eq_domains) functionality +-- +-- Part 1: User settings fields for URI matching +-- - equivalent_domains: JSON string of Vec> (custom groups) +-- - excluded_globals: JSON string of Vec (excluded global group IDs) +-- +-- We keep defaults as "[]", and allow applying this migration multiple times +-- (duplicate column errors are handled gracefully by CI tooling). +-- +-- Part 2: Global equivalent domains table +-- Stores the upstream "global domains" dataset (vaultwarden/Bitwarden compatible), +-- but kept OUT of the Worker bundle for size/perf reasons. +-- +-- Data is seeded separately (see scripts) and may be refreshed over time. +-- Columns: +-- - type: the global group id (matches vaultwarden's `GlobalDomain.type`) +-- - sort_order: preserve upstream file order for stable client UX +-- - domains_json: JSON string of Vec (domain list) + +ALTER TABLE users ADD COLUMN equivalent_domains TEXT NOT NULL DEFAULT '[]'; +ALTER TABLE users ADD COLUMN excluded_globals TEXT NOT NULL DEFAULT '[]'; + +CREATE TABLE IF NOT EXISTS global_equivalent_domains ( + type INTEGER PRIMARY KEY NOT NULL, + sort_order INTEGER NOT NULL, + domains_json TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_global_equivalent_domains_sort_order + ON global_equivalent_domains(sort_order); + diff --git a/scripts/generate-global-domains-seed.py b/scripts/generate-global-domains-seed.py new file mode 100644 index 0000000..60208fc --- /dev/null +++ b/scripts/generate-global-domains-seed.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +Generate a D1/SQLite seed SQL file for Bitwarden/Vaultwarden global equivalent domains. + +Why: +- Avoid bundling `global_domains.json` into the Worker (size + parse CPU). +- Store the dataset in D1 and let SQL generate the final JSON payload. + +Default source: +- Downloads from Vaultwarden upstream (GitHub raw). + +Usage: + python3 scripts/generate-global-domains-seed.py \ + --output sql/global_domains_seed.sql + + # Use a pinned URL (recommended for reproducible deploys) + python3 scripts/generate-global-domains-seed.py \ + --url https://raw.githubusercontent.com/dani-garcia/vaultwarden//src/static/global_domains.json \ + --output sql/global_domains_seed.sql + + # Use a local file + python3 scripts/generate-global-domains-seed.py \ + --input /path/to/global_domains.json \ + --output sql/global_domains_seed.sql +""" + +from __future__ import annotations + +import argparse +import datetime as _dt +import json +import sys +import urllib.request +from pathlib import Path + + +DEFAULT_URL = ( + "https://raw.githubusercontent.com/dani-garcia/vaultwarden/main/src/static/global_domains.json" +) + + +def _read_source(input_path: str | None, url: str | None) -> tuple[str, str]: + if input_path: + p = Path(input_path) + raw = p.read_text(encoding="utf-8") + return raw, str(p) + if not url: + url = DEFAULT_URL + with urllib.request.urlopen(url) as resp: + raw = resp.read().decode("utf-8") + return raw, url + + +def _sql_quote_single(s: str) -> str: + # SQLite single-quote escaping + return s.replace("'", "''") + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--input", help="Local path to global_domains.json") + ap.add_argument( + "--url", + default=None, + help=f"URL to download global_domains.json (default: {DEFAULT_URL})", + ) + ap.add_argument( + "--output", + default="sql/global_domains_seed.sql", + help="Output SQL file path (default: sql/global_domains_seed.sql)", + ) + ap.add_argument( + "--table", + default="global_equivalent_domains", + help="Target table name (default: global_equivalent_domains)", + ) + args = ap.parse_args(argv) + + raw, source = _read_source(args.input, args.url) + data = json.loads(raw) + if not isinstance(data, list): + raise SystemExit("Expected top-level JSON array") + + rows: list[tuple[int, int, str]] = [] + seen_types: set[int] = set() + for idx, item in enumerate(data): + if not isinstance(item, dict): + continue + t = item.get("type") + domains = item.get("domains") + if not isinstance(t, int): + continue + if not isinstance(domains, list) or not all(isinstance(d, str) for d in domains): + continue + if t in seen_types: + raise SystemExit(f"Duplicate global domain type found: {t}") + seen_types.add(t) + + domains_json = json.dumps(domains, ensure_ascii=False, separators=(",", ":")) + rows.append((t, idx, domains_json)) + + out_path = Path(args.output) + out_path.parent.mkdir(parents=True, exist_ok=True) + + now = _dt.datetime.now(_dt.timezone.utc).isoformat() + header = ( + f"-- Generated by scripts/generate-global-domains-seed.py at {now}\n" + f"-- Source: {source}\n" + f"-- Table: {args.table}\n" + "\n" + ) + + # Chunked multi-row INSERT keeps the SQL readable and efficient. + chunk_size = 200 + stmts: list[str] = [] + for i in range(0, len(rows), chunk_size): + chunk = rows[i : i + chunk_size] + values_sql = ",\n".join( + f"({t},{sort},'{_sql_quote_single(domains_json)}')" for (t, sort, domains_json) in chunk + ) + stmts.append( + # D1 does not allow BEGIN/COMMIT in SQL scripts, so we avoid explicit transactions here. + # Use OR REPLACE to keep the operation idempotent without a destructive full-table DELETE. + f"INSERT OR REPLACE INTO {args.table} (type, sort_order, domains_json)\nVALUES\n{values_sql};" + ) + + # Optional cleanup: remove types that are no longer present upstream. + # If this statement fails, the table will still contain a superset of valid groups (harmless). + type_list = ",".join(str(t) for (t, _, _) in rows) + stmts.append(f"DELETE FROM {args.table} WHERE type NOT IN ({type_list});") + stmts.append("") + + out_path.write_text(header + "\n".join(stmts), encoding="utf-8") + print(f"Wrote {len(rows)} global domain groups to {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) + + diff --git a/scripts/seed-global-domains.sh b/scripts/seed-global-domains.sh new file mode 100644 index 0000000..28bbc38 --- /dev/null +++ b/scripts/seed-global-domains.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Seed Vaultwarden/Bitwarden global equivalent domains into Cloudflare D1. + +This: +1) Downloads (or reads local) global_domains.json +2) Generates sql/global_domains_seed.sql +3) Executes it against a D1 database via wrangler + +Usage: + ./scripts/seed-global-domains.sh --db [--env ] [--remote] [--url ] + +Examples: + ./scripts/seed-global-domains.sh --db vault1 --remote + ./scripts/seed-global-domains.sh --db vault1 --env dev --remote + ./scripts/seed-global-domains.sh --db vault1 --remote \ + --url https://raw.githubusercontent.com/dani-garcia/vaultwarden//src/static/global_domains.json +EOF +} + +DB_NAME="" +ENV_NAME="" +REMOTE=0 +URL="" +INPUT="" +OUTPUT="sql/global_domains_seed.sql" + +while [[ $# -gt 0 ]]; do + case "$1" in + --db) + DB_NAME="${2:-}"; shift 2;; + --env) + ENV_NAME="${2:-}"; shift 2;; + --remote) + REMOTE=1; shift;; + --url) + URL="${2:-}"; shift 2;; + --input) + INPUT="${2:-}"; shift 2;; + --output) + OUTPUT="${2:-}"; shift 2;; + -h|--help) + usage; exit 0;; + *) + echo "Unknown arg: $1" >&2 + usage + exit 2;; + esac +done + +if [[ -z "$DB_NAME" ]]; then + echo "Missing required --db " >&2 + usage + exit 2 +fi + +GEN_ARGS=(--output "$OUTPUT") +if [[ -n "$INPUT" ]]; then + GEN_ARGS+=(--input "$INPUT") +elif [[ -n "$URL" ]]; then + GEN_ARGS+=(--url "$URL") +fi + +python3 scripts/generate-global-domains-seed.py "${GEN_ARGS[@]}" + +WRANGLER_ARGS=(d1 execute "$DB_NAME" --file "$OUTPUT") +if [[ -n "$ENV_NAME" ]]; then + WRANGLER_ARGS+=(--env "$ENV_NAME") +fi +if [[ "$REMOTE" -eq 1 ]]; then + WRANGLER_ARGS+=(--remote) +fi + +wrangler "${WRANGLER_ARGS[@]}" + + diff --git a/sql/schema.sql b/sql/schema.sql index 128aa9b..a4db16b 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS users ( kdf_memory INTEGER, -- Argon2 memory parameter in MB (15-1024), NULL for PBKDF2 kdf_parallelism INTEGER, -- Argon2 parallelism parameter (1-16), NULL for PBKDF2 security_stamp TEXT, + equivalent_domains TEXT NOT NULL DEFAULT '[]', -- JSON: Vec> + excluded_globals TEXT NOT NULL DEFAULT '[]', -- JSON: Vec (reserved for future global groups) totp_recover TEXT, -- Recovery code for 2FA created_at TEXT NOT NULL, updated_at TEXT NOT NULL @@ -89,3 +91,12 @@ CREATE TABLE IF NOT EXISTS folders ( updated_at TEXT NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); + +-- Global equivalent domains dataset (seeded separately, not bundled into the Worker) +CREATE TABLE IF NOT EXISTS global_equivalent_domains ( + type INTEGER PRIMARY KEY NOT NULL, + sort_order INTEGER NOT NULL, + domains_json TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_global_equivalent_domains_sort_order + ON global_equivalent_domains(sort_order); diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index e6ee24a..f334fb4 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -263,6 +263,8 @@ pub async fn register( kdf_memory, kdf_parallelism, security_stamp: Uuid::new_v4().to_string(), + equivalent_domains: "[]".to_string(), + excluded_globals: "[]".to_string(), totp_recover: None, created_at: now.clone(), updated_at: now, @@ -270,8 +272,8 @@ pub async fn register( query!( &db, - "INSERT INTO users (id, name, email, master_password_hash, master_password_hint, password_salt, password_iterations, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, totp_recover, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)", + "INSERT INTO users (id, name, email, master_password_hash, master_password_hint, password_salt, password_iterations, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, equivalent_domains, excluded_globals, totp_recover, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)", user.id, user.name, user.email, @@ -287,6 +289,8 @@ pub async fn register( user.kdf_memory, user.kdf_parallelism, user.security_stamp, + user.equivalent_domains, + user.excluded_globals, user.totp_recover, user.created_at, user.updated_at diff --git a/src/handlers/domains.rs b/src/handlers/domains.rs new file mode 100644 index 0000000..9962649 --- /dev/null +++ b/src/handlers/domains.rs @@ -0,0 +1,200 @@ +use axum::{extract::State, Json}; +use chrono::Utc; +use log::warn; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::sync::Arc; +use worker::{query, Env}; + +use crate::handlers::ciphers::RawJson; +use crate::{auth::Claims, db, error::AppError}; + +/// Build `globalEquivalentDomains` JSON (as a raw JSON string) in SQLite/D1. +/// +/// - `include_excluded=true` => returns all groups, each with `excluded` boolean (settings UI). +/// - `include_excluded=false` => returns only non-excluded groups, with `excluded=false` (sync payload). +/// +/// This keeps the Worker from parsing the large upstream dataset. +pub(crate) async fn global_equivalent_domains_json( + db: &worker::D1Database, + excluded_globals_json: &str, + include_excluded: bool, +) -> String { + let sql = if include_excluded { + r#" +SELECT COALESCE( + (SELECT json_group_array(json(value)) + FROM ( + SELECT json_object( + 'type', g.type, + 'domains', json(g.domains_json), + 'excluded', CASE WHEN eg.value IS NULL THEN json('false') ELSE json('true') END + ) AS value + FROM global_equivalent_domains g + LEFT JOIN json_each(?1) eg ON eg.value = g.type + ORDER BY g.sort_order + )), + '[]' +) AS globals +"# + } else { + r#" +SELECT COALESCE( + (SELECT json_group_array(json(value)) + FROM ( + SELECT json_object( + 'type', g.type, + 'domains', json(g.domains_json), + 'excluded', json('false') + ) AS value + FROM global_equivalent_domains g + LEFT JOIN json_each(?1) eg ON eg.value = g.type + WHERE eg.value IS NULL + ORDER BY g.sort_order + )), + '[]' +) AS globals +"# + }; + + async fn run_once(db: &worker::D1Database, sql: &str, excluded: &str) -> Result { + let row: Option = db + .prepare(sql) + .bind(&[excluded.to_string().into()]) + .map_err(|_| ())? + .first(None) + .await + .map_err(|_| ())?; + + Ok(row + .and_then(|r| { + r.get("globals") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "[]".to_string())) + } + + // If excluded_globals is invalid JSON, json_each() can fail. + // Fallback to treating it as empty list. + match run_once(db, sql, excluded_globals_json).await { + Ok(s) => s, + Err(_) => { + if excluded_globals_json != "[]" { + match run_once(db, sql, "[]").await { + Ok(s) => s, + Err(_) => { + warn!("Failed to build globalEquivalentDomains JSON (falling back to [])"); + "[]".to_string() + } + } + } else { + warn!("Failed to build globalEquivalentDomains JSON (falling back to [])"); + "[]".to_string() + } + } + } +} + +/// GET /api/settings/domains +/// +/// Equivalent domains (eq_domains) are used by clients to treat some domains as interchangeable +/// for URI matching (e.g. `google.com` vs `youtube.com` in predefined "global" groups). +/// +/// Vaultwarden persists per-user: +/// - `equivalentDomains`: custom groups set by the user +/// - `excludedGlobalEquivalentDomains`: which predefined groups are disabled +/// +/// This server persists only the per-user settings in `users`. +/// The optional global dataset can be seeded into D1 (see README), and will then be +/// included in responses without parsing the large JSON in the Worker. +#[worker::send] +pub async fn get_domains(claims: Claims, State(env): State>) -> Result { + let db = db::get_db(&env)?; + + let row: Option = db + .prepare("SELECT equivalent_domains, excluded_globals FROM users WHERE id = ?1") + .bind(&[claims.sub.into()])? + .first(None) + .await + .map_err(|_| AppError::Database)?; + + let row = row.ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + + let equivalent_domains = row + .get("equivalent_domains") + .and_then(|v| v.as_str()) + .unwrap_or("[]"); + let excluded_globals = row + .get("excluded_globals") + .and_then(|v| v.as_str()) + .unwrap_or("[]"); + + // Include ALL global groups and mark `excluded` (settings UI semantics). + // Falls back to [] if the dataset isn't seeded yet. + let global_equivalent_domains = + global_equivalent_domains_json(&db, excluded_globals, true).await; + + let response = format!( + r#"{{"equivalentDomains":{},"globalEquivalentDomains":{},"object":"domains"}}"#, + equivalent_domains, global_equivalent_domains + ); + Ok(RawJson(response)) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EquivDomainData { + pub excluded_global_equivalent_domains: Option>, + pub equivalent_domains: Option>>, +} + +/// POST /api/settings/domains +/// +/// Persist per-user eq_domains settings (no notifications/push). +#[worker::send] +pub async fn post_domains( + claims: Claims, + State(env): State>, + Json(payload): Json, +) -> Result, AppError> { + let db = db::get_db(&env)?; + + let excluded_globals = payload + .excluded_global_equivalent_domains + .unwrap_or_default(); + let equivalent_domains = payload.equivalent_domains.unwrap_or_default(); + + let excluded_globals_json = serde_json::to_string(&excluded_globals) + .map_err(|_| AppError::BadRequest("Invalid excluded globals".to_string()))?; + let equivalent_domains_json = serde_json::to_string(&equivalent_domains) + .map_err(|_| AppError::BadRequest("Invalid equivalent domains".to_string()))?; + + let now = Utc::now().to_rfc3339(); + query!( + &db, + "UPDATE users SET equivalent_domains = ?1, excluded_globals = ?2, updated_at = ?3 WHERE id = ?4", + equivalent_domains_json, + excluded_globals_json, + now, + claims.sub + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + + Ok(Json(json!({}))) +} + +/// PUT /api/settings/domains +/// +/// Behaves like POST. +#[worker::send] +pub async fn put_domains( + claims: Claims, + State(env): State>, + payload: Json, +) -> Result, AppError> { + post_domains(claims, State(env), payload).await +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 0f66f59..d03a817 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -3,13 +3,13 @@ pub mod attachments; pub mod ciphers; pub mod config; pub mod devices; +pub mod domains; pub mod emergency_access; pub mod folders; pub mod identity; pub mod import; pub mod meta; pub mod purge; -pub mod settings; pub mod sync; pub mod twofactor; pub mod webauth; @@ -79,5 +79,7 @@ pub(crate) async fn two_factor_enabled( user_id: &str, ) -> Result { let twofactors = crate::handlers::twofactor::list_user_twofactors(db, user_id).await?; - Ok(crate::handlers::twofactor::is_twofactor_enabled(&twofactors)) + Ok(crate::handlers::twofactor::is_twofactor_enabled( + &twofactors, + )) } diff --git a/src/handlers/settings.rs b/src/handlers/settings.rs deleted file mode 100644 index a7cc4e7..0000000 --- a/src/handlers/settings.rs +++ /dev/null @@ -1,61 +0,0 @@ -use axum::Json; -use serde::Deserialize; -use serde_json::{json, Value}; - -use crate::{auth::Claims, error::AppError}; - -/// GET /api/settings/domains -/// -/// Equivalent domains (eq_domains) are used by clients to treat some domains as interchangeable -/// for URI matching (e.g. `google.com` vs `youtube.com` in predefined "global" groups). -/// -/// Vaultwarden persists per-user: -/// - `equivalentDomains`: custom groups set by the user -/// - `excludedGlobalEquivalentDomains`: which predefined groups are disabled -/// -/// This minimal server currently does not persist these settings. We return empty data to -/// prevent 404s. In the future, we can implement storage by adding two `users` columns: -/// - `equivalent_domains` (TEXT JSON, default "[]") -/// - `excluded_globals` (TEXT JSON, default "[]") -/// and (optionally) embedding/updating the global domains dataset. -#[worker::send] -pub async fn get_domains(_claims: Claims) -> Result, AppError> { - Ok(Json(json!({ - "equivalentDomains": [], - "globalEquivalentDomains": [], - "object": "domains" - }))) -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct EquivDomainData { - #[allow(dead_code)] // stub endpoint doesn't persist these yet - pub excluded_global_equivalent_domains: Option>, - #[allow(dead_code)] // stub endpoint doesn't persist these yet - pub equivalent_domains: Option>>, -} - -/// POST /api/settings/domains -/// -/// Stub: accept payload but do not persist yet. -#[worker::send] -pub async fn post_domains( - _claims: Claims, - _payload: Json, -) -> Result, AppError> { - Ok(Json(json!({}))) -} - -/// PUT /api/settings/domains -/// -/// Stub: behaves like POST. -#[worker::send] -pub async fn put_domains( - claims: Claims, - payload: Json, -) -> Result, AppError> { - post_domains(claims, payload).await -} - - diff --git a/src/handlers/sync.rs b/src/handlers/sync.rs index c436504..e652d44 100644 --- a/src/handlers/sync.rs +++ b/src/handlers/sync.rs @@ -1,4 +1,4 @@ -use axum::extract::State; +use axum::extract::{Query, State}; use std::sync::Arc; use worker::Env; @@ -6,7 +6,7 @@ use crate::{ auth::Claims, db, error::AppError, - handlers::{attachments, ciphers, two_factor_enabled}, + handlers::{attachments, ciphers, domains, two_factor_enabled}, models::{ folder::{Folder, FolderResponse}, sync::Profile, @@ -15,12 +15,21 @@ use crate::{ }; use ciphers::RawJson; +use serde::Deserialize; use serde_json::{json, Value}; +#[derive(Debug, Deserialize)] +pub struct SyncQuery { + /// If true, omit domains data from sync (vaultwarden sets domains to null). + #[serde(rename = "excludeDomains", default)] + pub exclude_domains: bool, +} + #[worker::send] pub async fn get_sync_data( claims: Claims, State(env): State>, + Query(query): Query, ) -> Result { let user_id = claims.sub; let db = db::get_db(&env)?; @@ -36,6 +45,8 @@ pub async fn get_sync_data( let two_factor_enabled = two_factor_enabled(&db, &user_id).await?; let has_master_password = !user.master_password_hash.is_empty(); + let equivalent_domains = user.equivalent_domains.clone(); + let excluded_globals = user.excluded_globals.clone(); let master_password_unlock = if has_master_password { // Mirrors vaultwarden's `ciphers::sync` casing (lower camelCase). // We don't support SSO, so this is always derived from the current user record. @@ -91,10 +102,26 @@ pub async fn get_sync_data( })) .map_err(|_| AppError::Internal)?; - let response = format!( - r#"{{"profile":{},"folders":{},"collections":[],"policies":[],"ciphers":{},"domains":{{"equivalentDomains":[],"globalEquivalentDomains":[],"object":"domains"}},"sends":[],"userDecryption":{},"object":"sync"}}"#, - profile_json, folders_json, ciphers_json, user_decryption_json - ); + let response = if query.exclude_domains { + format!( + r#"{{"profile":{},"folders":{},"collections":[],"policies":[],"ciphers":{},"sends":[],"userDecryption":{},"object":"sync"}}"#, + profile_json, folders_json, ciphers_json, user_decryption_json + ) + } else { + // Match vaultwarden sync semantics: + // - mark excluded in /api/settings/domains + // - filter excluded out of sync payload + let global_equivalent_domains = + domains::global_equivalent_domains_json(&db, &excluded_globals, false).await; + let domains_json = format!( + r#"{{"equivalentDomains":{},"globalEquivalentDomains":{},"object":"domains"}}"#, + equivalent_domains, global_equivalent_domains + ); + format!( + r#"{{"profile":{},"folders":{},"collections":[],"policies":[],"ciphers":{},"domains":{},"sends":[],"userDecryption":{},"object":"sync"}}"#, + profile_json, folders_json, ciphers_json, domains_json, user_decryption_json + ) + }; Ok(RawJson(response)) } diff --git a/src/models/user.rs b/src/models/user.rs index c619b52..59f8ef5 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -3,6 +3,10 @@ use serde::{Deserialize, Serialize}; use crate::{crypto::verify_password, error::AppError}; +fn default_json_array_string() -> String { + "[]".to_string() +} + #[derive(Debug, Serialize, Deserialize)] pub struct User { pub id: String, @@ -23,6 +27,12 @@ pub struct User { pub kdf_memory: Option, // Argon2 memory parameter (15-1024 MB) pub kdf_parallelism: Option, // Argon2 parallelism parameter (1-16) pub security_stamp: String, + /// JSON string of `Vec>` storing user-defined equivalent domain groups. + #[serde(default = "default_json_array_string")] + pub equivalent_domains: String, + /// JSON string of `Vec` storing excluded global group IDs (reserved for future global groups). + #[serde(default = "default_json_array_string")] + pub excluded_globals: String, pub totp_recover: Option, // Recovery code for 2FA pub created_at: String, pub updated_at: String, diff --git a/src/router.rs b/src/router.rs index 015ac7f..ccddb58 100644 --- a/src/router.rs +++ b/src/router.rs @@ -6,8 +6,8 @@ use std::sync::Arc; use worker::Env; use crate::handlers::{ - accounts, attachments, ciphers, config, devices, emergency_access, folders, identity, import, - meta, settings, sync, twofactor, webauth, + accounts, attachments, ciphers, config, devices, domains, emergency_access, folders, identity, import, + meta, sync, twofactor, webauth, }; pub fn api_router(env: Env) -> Router { @@ -144,9 +144,9 @@ pub fn api_router(env: Env) -> Router { .route("/api/version", get(meta::version)) .route("/api/hibp/breach", get(meta::hibp_breach)) // Settings (stubbed) - .route("/api/settings/domains", get(settings::get_domains)) - .route("/api/settings/domains", post(settings::post_domains)) - .route("/api/settings/domains", put(settings::put_domains)) + .route("/api/settings/domains", get(domains::get_domains)) + .route("/api/settings/domains", post(domains::post_domains)) + .route("/api/settings/domains", put(domains::put_domains)) // Emergency access (stub - returns empty lists, feature not supported) .route( "/api/emergency-access/trusted",