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:
parent
dfa82a0918
commit
f179e4a015
12 changed files with 523 additions and 76 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -8,3 +8,6 @@ public/web-vault/
|
|||
# reference repos
|
||||
vaultwarden/
|
||||
bw_web_builds/
|
||||
|
||||
# Generated seed SQL (global equivalent domains)
|
||||
sql/global_domains_seed.sql
|
||||
|
|
|
|||
31
migrations/0008_add_eq_domains.sql
Normal file
31
migrations/0008_add_eq_domains.sql
Normal 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);
|
||||
|
||||
141
scripts/generate-global-domains-seed.py
Normal file
141
scripts/generate-global-domains-seed.py
Normal 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:]))
|
||||
|
||||
|
||||
79
scripts/seed-global-domains.sh
Normal file
79
scripts/seed-global-domains.sh
Normal 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[@]}"
|
||||
|
||||
|
||||
|
|
@ -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<Vec<String>>
|
||||
excluded_globals TEXT NOT NULL DEFAULT '[]', -- JSON: Vec<i32> (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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
200
src/handlers/domains.rs
Normal file
200
src/handlers/domains.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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<bool, crate::error::AppError> {
|
||||
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,
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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<Arc<Env>>,
|
||||
Query(query): Query<SyncQuery>,
|
||||
) -> Result<RawJson, AppError> {
|
||||
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"}}"#,
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<i32>, // Argon2 memory parameter (15-1024 MB)
|
||||
pub kdf_parallelism: Option<i32>, // Argon2 parallelism parameter (1-16)
|
||||
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 created_at: String,
|
||||
pub updated_at: String,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue