feat: implement equivalent domains functionality

- Added support for equivalent domains in user settings, allowing users to define custom domain groups and exclude specific global groups.
- Introduced new migration script to add necessary database columns and a table for global equivalent domains.
- Created a Python script to generate seed SQL for global domains, which can be executed to populate the database.
- Updated API endpoints to handle equivalent domains, including retrieval and persistence of user-defined settings.
- Refactored existing settings handler to remove stubs and implement full functionality for managing equivalent domains.
This commit is contained in:
qaz741wsd856 2025-12-29 09:11:39 +00:00
parent dfa82a0918
commit f179e4a015
12 changed files with 523 additions and 76 deletions

3
.gitignore vendored
View file

@ -8,3 +8,6 @@ public/web-vault/
# reference repos # reference repos
vaultwarden/ vaultwarden/
bw_web_builds/ bw_web_builds/
# Generated seed SQL (global equivalent domains)
sql/global_domains_seed.sql

View file

@ -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<Vec<String>> (custom groups)
-- - excluded_globals: JSON string of Vec<i32> (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<String> (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);

View file

@ -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/<tag-or-commit>/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:]))

View file

@ -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 <d1_name> [--env <wrangler_env>] [--remote] [--url <raw_json_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/<tag-or-commit>/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 <d1_name>" >&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[@]}"

View file

@ -17,6 +17,8 @@ CREATE TABLE IF NOT EXISTS users (
kdf_memory INTEGER, -- Argon2 memory parameter in MB (15-1024), NULL for PBKDF2 kdf_memory INTEGER, -- Argon2 memory parameter in MB (15-1024), NULL for PBKDF2
kdf_parallelism INTEGER, -- Argon2 parallelism parameter (1-16), NULL for PBKDF2 kdf_parallelism INTEGER, -- Argon2 parallelism parameter (1-16), NULL for PBKDF2
security_stamp TEXT, security_stamp TEXT,
equivalent_domains TEXT NOT NULL DEFAULT '[]', -- JSON: Vec<Vec<String>>
excluded_globals TEXT NOT NULL DEFAULT '[]', -- JSON: Vec<i32> (reserved for future global groups)
totp_recover TEXT, -- Recovery code for 2FA totp_recover TEXT, -- Recovery code for 2FA
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
@ -89,3 +91,12 @@ CREATE TABLE IF NOT EXISTS folders (
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 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);

View file

@ -263,6 +263,8 @@ pub async fn register(
kdf_memory, kdf_memory,
kdf_parallelism, kdf_parallelism,
security_stamp: Uuid::new_v4().to_string(), security_stamp: Uuid::new_v4().to_string(),
equivalent_domains: "[]".to_string(),
excluded_globals: "[]".to_string(),
totp_recover: None, totp_recover: None,
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
@ -270,8 +272,8 @@ pub async fn register(
query!( query!(
&db, &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) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)",
user.id, user.id,
user.name, user.name,
user.email, user.email,
@ -287,6 +289,8 @@ pub async fn register(
user.kdf_memory, user.kdf_memory,
user.kdf_parallelism, user.kdf_parallelism,
user.security_stamp, user.security_stamp,
user.equivalent_domains,
user.excluded_globals,
user.totp_recover, user.totp_recover,
user.created_at, user.created_at,
user.updated_at user.updated_at

200
src/handlers/domains.rs Normal file
View file

@ -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<String, ()> {
let row: Option<Value> = 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<Arc<Env>>) -> Result<RawJson, AppError> {
let db = db::get_db(&env)?;
let row: Option<Value> = 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<Vec<i32>>,
pub equivalent_domains: Option<Vec<Vec<String>>>,
}
/// 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<Arc<Env>>,
Json(payload): Json<EquivDomainData>,
) -> Result<Json<Value>, 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<Arc<Env>>,
payload: Json<EquivDomainData>,
) -> Result<Json<Value>, AppError> {
post_domains(claims, State(env), payload).await
}

View file

@ -3,13 +3,13 @@ pub mod attachments;
pub mod ciphers; pub mod ciphers;
pub mod config; pub mod config;
pub mod devices; pub mod devices;
pub mod domains;
pub mod emergency_access; pub mod emergency_access;
pub mod folders; pub mod folders;
pub mod identity; pub mod identity;
pub mod import; pub mod import;
pub mod meta; pub mod meta;
pub mod purge; pub mod purge;
pub mod settings;
pub mod sync; pub mod sync;
pub mod twofactor; pub mod twofactor;
pub mod webauth; pub mod webauth;
@ -79,5 +79,7 @@ pub(crate) async fn two_factor_enabled(
user_id: &str, user_id: &str,
) -> Result<bool, crate::error::AppError> { ) -> Result<bool, crate::error::AppError> {
let twofactors = crate::handlers::twofactor::list_user_twofactors(db, user_id).await?; 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,
))
} }

View file

@ -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<Json<Value>, 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<Vec<i32>>,
#[allow(dead_code)] // stub endpoint doesn't persist these yet
pub equivalent_domains: Option<Vec<Vec<String>>>,
}
/// POST /api/settings/domains
///
/// Stub: accept payload but do not persist yet.
#[worker::send]
pub async fn post_domains(
_claims: Claims,
_payload: Json<EquivDomainData>,
) -> Result<Json<Value>, AppError> {
Ok(Json(json!({})))
}
/// PUT /api/settings/domains
///
/// Stub: behaves like POST.
#[worker::send]
pub async fn put_domains(
claims: Claims,
payload: Json<EquivDomainData>,
) -> Result<Json<Value>, AppError> {
post_domains(claims, payload).await
}

View file

@ -1,4 +1,4 @@
use axum::extract::State; use axum::extract::{Query, State};
use std::sync::Arc; use std::sync::Arc;
use worker::Env; use worker::Env;
@ -6,7 +6,7 @@ use crate::{
auth::Claims, auth::Claims,
db, db,
error::AppError, error::AppError,
handlers::{attachments, ciphers, two_factor_enabled}, handlers::{attachments, ciphers, domains, two_factor_enabled},
models::{ models::{
folder::{Folder, FolderResponse}, folder::{Folder, FolderResponse},
sync::Profile, sync::Profile,
@ -15,12 +15,21 @@ use crate::{
}; };
use ciphers::RawJson; use ciphers::RawJson;
use serde::Deserialize;
use serde_json::{json, Value}; 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] #[worker::send]
pub async fn get_sync_data( pub async fn get_sync_data(
claims: Claims, claims: Claims,
State(env): State<Arc<Env>>, State(env): State<Arc<Env>>,
Query(query): Query<SyncQuery>,
) -> Result<RawJson, AppError> { ) -> Result<RawJson, AppError> {
let user_id = claims.sub; let user_id = claims.sub;
let db = db::get_db(&env)?; 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 two_factor_enabled = two_factor_enabled(&db, &user_id).await?;
let has_master_password = !user.master_password_hash.is_empty(); 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 { let master_password_unlock = if has_master_password {
// Mirrors vaultwarden's `ciphers::sync` casing (lower camelCase). // Mirrors vaultwarden's `ciphers::sync` casing (lower camelCase).
// We don't support SSO, so this is always derived from the current user record. // 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)?; .map_err(|_| AppError::Internal)?;
let response = format!( let response = if query.exclude_domains {
r#"{{"profile":{},"folders":{},"collections":[],"policies":[],"ciphers":{},"domains":{{"equivalentDomains":[],"globalEquivalentDomains":[],"object":"domains"}},"sends":[],"userDecryption":{},"object":"sync"}}"#, format!(
profile_json, folders_json, ciphers_json, user_decryption_json 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)) Ok(RawJson(response))
} }

View file

@ -3,6 +3,10 @@ use serde::{Deserialize, Serialize};
use crate::{crypto::verify_password, error::AppError}; use crate::{crypto::verify_password, error::AppError};
fn default_json_array_string() -> String {
"[]".to_string()
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct User { pub struct User {
pub id: String, pub id: String,
@ -23,6 +27,12 @@ pub struct User {
pub kdf_memory: Option<i32>, // Argon2 memory parameter (15-1024 MB) pub kdf_memory: Option<i32>, // Argon2 memory parameter (15-1024 MB)
pub kdf_parallelism: Option<i32>, // Argon2 parallelism parameter (1-16) pub kdf_parallelism: Option<i32>, // Argon2 parallelism parameter (1-16)
pub security_stamp: String, pub security_stamp: String,
/// JSON string of `Vec<Vec<String>>` storing user-defined equivalent domain groups.
#[serde(default = "default_json_array_string")]
pub equivalent_domains: String,
/// JSON string of `Vec<i32>` storing excluded global group IDs (reserved for future global groups).
#[serde(default = "default_json_array_string")]
pub excluded_globals: String,
pub totp_recover: Option<String>, // Recovery code for 2FA pub totp_recover: Option<String>, // Recovery code for 2FA
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,

View file

@ -6,8 +6,8 @@ use std::sync::Arc;
use worker::Env; use worker::Env;
use crate::handlers::{ use crate::handlers::{
accounts, attachments, ciphers, config, devices, emergency_access, folders, identity, import, accounts, attachments, ciphers, config, devices, domains, emergency_access, folders, identity, import,
meta, settings, sync, twofactor, webauth, meta, sync, twofactor, webauth,
}; };
pub fn api_router(env: Env) -> Router { 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/version", get(meta::version))
.route("/api/hibp/breach", get(meta::hibp_breach)) .route("/api/hibp/breach", get(meta::hibp_breach))
// Settings (stubbed) // Settings (stubbed)
.route("/api/settings/domains", get(settings::get_domains)) .route("/api/settings/domains", get(domains::get_domains))
.route("/api/settings/domains", post(settings::post_domains)) .route("/api/settings/domains", post(domains::post_domains))
.route("/api/settings/domains", put(settings::put_domains)) .route("/api/settings/domains", put(domains::put_domains))
// Emergency access (stub - returns empty lists, feature not supported) // Emergency access (stub - returns empty lists, feature not supported)
.route( .route(
"/api/emergency-access/trusted", "/api/emergency-access/trusted",