warden-worker/src/handlers/ciphers.rs
qaz741wsd856 73223bfa94 feat: add file attachment support for ciphers
- Implemented functionality to create, upload, download, and delete attachments associated with ciphers.
- Introduced a new `attachments` module and updated the database schema to include an `attachments` table.
- Enhanced the API routes to handle attachment operations, including legacy support.
- Updated the README and configuration files to reflect the new attachment feature and its usage.
2025-12-07 11:13:06 +00:00

737 lines
22 KiB
Rust

use super::get_batch_size;
use axum::{extract::State, Json};
use chrono::{DateTime, Utc};
use log; // Used for warning logs on parse failures
use serde::Deserialize;
use serde_json::Value;
use std::sync::Arc;
use uuid::Uuid;
use worker::{query, D1PreparedStatement, Env};
use crate::auth::Claims;
use crate::db;
use crate::error::AppError;
use crate::handlers::attachments;
use crate::models::cipher::{
Cipher, CipherDBModel, CipherData, CipherListResponse, CipherRequestData, CreateCipherRequest,
MoveCipherData, PartialCipherData,
};
use crate::models::user::{PasswordOrOtpData, User};
use axum::extract::Path;
/// Helper to fetch a cipher by id for a user or return NotFound.
async fn fetch_cipher_for_user(
db: &worker::D1Database,
cipher_id: &str,
user_id: &str,
) -> Result<CipherDBModel, AppError> {
db.prepare("SELECT * FROM ciphers WHERE id = ?1 AND user_id = ?2")
.bind(&[cipher_id.to_string().into(), user_id.to_string().into()])?
.first(None)
.await
.map_err(|_| AppError::Database)?
.ok_or_else(|| AppError::NotFound("Cipher not found".to_string()))
}
#[worker::send]
pub async fn create_cipher(
claims: Claims,
State(env): State<Arc<Env>>,
Json(payload): Json<CreateCipherRequest>,
) -> Result<Json<Cipher>, AppError> {
let db = db::get_db(&env)?;
let now = Utc::now();
let now = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let cipher_data_req = payload.cipher;
let cipher_data = CipherData {
name: cipher_data_req.name,
notes: cipher_data_req.notes,
type_fields: cipher_data_req.type_fields,
};
let data_value = serde_json::to_value(&cipher_data).map_err(|_| AppError::Internal)?;
let mut cipher = Cipher {
id: Uuid::new_v4().to_string(),
user_id: Some(claims.sub.clone()),
organization_id: cipher_data_req.organization_id.clone(),
r#type: cipher_data_req.r#type,
data: data_value,
favorite: cipher_data_req.favorite.unwrap_or(false),
folder_id: cipher_data_req.folder_id.clone(),
deleted_at: None,
created_at: now.clone(),
updated_at: now.clone(),
object: "cipher".to_string(),
organization_use_totp: false,
edit: true,
view_password: true,
collection_ids: if payload.collection_ids.is_empty() {
None
} else {
Some(payload.collection_ids)
},
attachments: None,
};
let data = serde_json::to_string(&cipher.data).map_err(|_| AppError::Internal)?;
query!(
&db,
"INSERT INTO ciphers (id, user_id, organization_id, type, data, favorite, folder_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
cipher.id,
cipher.user_id,
cipher.organization_id,
cipher.r#type,
data,
cipher.favorite,
cipher.folder_id,
cipher.created_at,
cipher.updated_at,
).map_err(|_|AppError::Database)?
.run()
.await?;
attachments::hydrate_cipher_attachments(&db, env.as_ref(), &mut cipher, &claims.sub).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(cipher))
}
#[worker::send]
pub async fn update_cipher(
claims: Claims,
State(env): State<Arc<Env>>,
Path(id): Path<String>,
Json(payload): Json<CipherRequestData>,
) -> Result<Json<Cipher>, AppError> {
let db = db::get_db(&env)?;
let now = Utc::now();
let now = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let existing_cipher: crate::models::cipher::CipherDBModel = query!(
&db,
"SELECT * FROM ciphers WHERE id = ?1 AND user_id = ?2",
id,
claims.sub
)
.map_err(|_| AppError::Database)?
.first(None)
.await?
.ok_or(AppError::NotFound("Cipher not found".to_string()))?;
// Validate folder ownership if provided
if let Some(ref folder_id) = payload.folder_id {
let folder_exists: Option<serde_json::Value> = db
.prepare("SELECT id FROM folders WHERE id = ?1 AND user_id = ?2")
.bind(&[folder_id.clone().into(), claims.sub.clone().into()])?
.first(None)
.await?;
if folder_exists.is_none() {
return Err(AppError::BadRequest(
"Invalid folder: Folder does not exist or belongs to another user".to_string(),
));
}
}
// Reject updates based on stale client data when the last known revision is provided
if let Some(dt) = payload.last_known_revision_date.as_deref() {
match DateTime::parse_from_rfc3339(dt) {
Ok(client_dt) => match DateTime::parse_from_rfc3339(&existing_cipher.updated_at) {
Ok(server_dt) => {
if server_dt.signed_duration_since(client_dt).num_seconds() > 1 {
return Err(AppError::BadRequest(
"The client copy of this cipher is out of date. Resync the client and try again.".to_string(),
));
}
}
Err(err) => log::warn!(
"Error parsing server revisionDate '{}' for cipher {}: {}",
existing_cipher.updated_at,
existing_cipher.id,
err
),
},
Err(err) => log::warn!("Error parsing lastKnownRevisionDate '{}': {}", dt, err),
}
}
let cipher_data_req = payload;
let cipher_data = CipherData {
name: cipher_data_req.name,
notes: cipher_data_req.notes,
type_fields: cipher_data_req.type_fields,
};
let data_value = serde_json::to_value(&cipher_data).map_err(|_| AppError::Internal)?;
let mut cipher = Cipher {
id: id.clone(),
user_id: Some(claims.sub.clone()),
organization_id: cipher_data_req.organization_id.clone(),
r#type: cipher_data_req.r#type,
data: data_value,
favorite: cipher_data_req.favorite.unwrap_or(false),
folder_id: cipher_data_req.folder_id.clone(),
deleted_at: None,
created_at: existing_cipher.created_at,
updated_at: now.clone(),
object: "cipher".to_string(),
organization_use_totp: false,
edit: true,
view_password: true,
collection_ids: None,
attachments: None,
};
let data = serde_json::to_string(&cipher.data).map_err(|_| AppError::Internal)?;
query!(
&db,
"UPDATE ciphers SET organization_id = ?1, type = ?2, data = ?3, favorite = ?4, folder_id = ?5, updated_at = ?6 WHERE id = ?7 AND user_id = ?8",
cipher.organization_id,
cipher.r#type,
data,
cipher.favorite,
cipher.folder_id,
cipher.updated_at,
id,
claims.sub,
).map_err(|_|AppError::Database)?
.run()
.await?;
attachments::hydrate_cipher_attachments(&db, env.as_ref(), &mut cipher, &claims.sub).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(cipher))
}
/// GET /api/ciphers - list all non-trashed ciphers for current user
#[worker::send]
pub async fn list_ciphers(
claims: Claims,
State(env): State<Arc<Env>>,
) -> Result<Json<CipherListResponse>, AppError> {
let db = db::get_db(&env)?;
let ciphers_db: Vec<CipherDBModel> = db
.prepare(
"SELECT * FROM ciphers
WHERE user_id = ?1 AND deleted_at IS NULL
ORDER BY updated_at DESC",
)
.bind(&[claims.sub.clone().into()])?
.all()
.await?
.results()?;
let mut ciphers: Vec<Cipher> = ciphers_db.into_iter().map(|c| c.into()).collect();
attachments::hydrate_ciphers_attachments(&db, env.as_ref(), &mut ciphers, &claims.sub).await?;
Ok(Json(CipherListResponse {
data: ciphers,
object: "list".to_string(),
continuation_token: None,
}))
}
/// GET /api/ciphers/{id}
#[worker::send]
pub async fn get_cipher(
claims: Claims,
State(env): State<Arc<Env>>,
Path(id): Path<String>,
) -> Result<Json<Cipher>, AppError> {
let db = db::get_db(&env)?;
let cipher = fetch_cipher_for_user(&db, &id, &claims.sub).await?;
let mut cipher: Cipher = cipher.into();
attachments::hydrate_cipher_attachments(&db, env.as_ref(), &mut cipher, &claims.sub).await?;
Ok(Json(cipher))
}
/// GET /api/ciphers/{id}/details
#[worker::send]
pub async fn get_cipher_details(
claims: Claims,
state: State<Arc<Env>>,
id: Path<String>,
) -> Result<Json<Cipher>, AppError> {
get_cipher(claims, state, id).await
}
/// PUT/POST /api/ciphers/{id}/partial
#[worker::send]
pub async fn update_cipher_partial(
claims: Claims,
State(env): State<Arc<Env>>,
Path(id): Path<String>,
Json(payload): Json<PartialCipherData>,
) -> Result<Json<Cipher>, AppError> {
let db = db::get_db(&env)?;
let user_id = &claims.sub;
// Validate folder ownership if provided
if let Some(ref folder_id) = payload.folder_id {
let folder_exists: Option<serde_json::Value> = db
.prepare("SELECT id FROM folders WHERE id = ?1 AND user_id = ?2")
.bind(&[folder_id.clone().into(), user_id.clone().into()])?
.first(None)
.await?;
if folder_exists.is_none() {
return Err(AppError::BadRequest(
"Invalid folder: Folder does not exist or belongs to another user".to_string(),
));
}
}
// Ensure cipher exists and belongs to user
fetch_cipher_for_user(&db, &id, user_id).await?;
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
query!(
&db,
"UPDATE ciphers SET folder_id = ?1, favorite = ?2, updated_at = ?3 WHERE id = ?4 AND user_id = ?5",
payload.folder_id,
payload.favorite,
now,
id,
user_id,
)
.map_err(|_| AppError::Database)?
.run()
.await?;
db::touch_user_updated_at(&db, user_id).await?;
let cipher = fetch_cipher_for_user(&db, &id, user_id).await?;
let mut cipher: Cipher = cipher.into();
attachments::hydrate_cipher_attachments(&db, env.as_ref(), &mut cipher, &claims.sub).await?;
Ok(Json(cipher))
}
/// Request body for bulk cipher operations
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CipherIdsData {
pub ids: Vec<String>,
}
/// Soft delete a single cipher (PUT /api/ciphers/{id}/delete)
/// Sets deleted_at to current timestamp
#[worker::send]
pub async fn soft_delete_cipher(
claims: Claims,
State(env): State<Arc<Env>>,
Path(id): Path<String>,
) -> Result<Json<()>, AppError> {
let db = db::get_db(&env)?;
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
query!(
&db,
"UPDATE ciphers SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND user_id = ?3",
now,
id,
claims.sub
)
.map_err(|_| AppError::Database)?
.run()
.await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(()))
}
/// Soft delete multiple ciphers (PUT /api/ciphers/delete)
#[worker::send]
pub async fn soft_delete_ciphers_bulk(
claims: Claims,
State(env): State<Arc<Env>>,
Json(payload): Json<CipherIdsData>,
) -> Result<Json<()>, AppError> {
let db = db::get_db(&env)?;
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let batch_size = get_batch_size(&env);
let mut statements: Vec<D1PreparedStatement> = Vec::with_capacity(payload.ids.len());
for id in payload.ids {
let stmt = query!(
&db,
"UPDATE ciphers SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND user_id = ?3",
now,
id,
claims.sub
)
.map_err(|_| AppError::Database)?;
statements.push(stmt);
}
db::execute_in_batches(&db, statements, batch_size).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(()))
}
/// Hard delete a single cipher (DELETE /api/ciphers/{id} or POST /api/ciphers/{id}/delete)
/// Permanently removes the cipher from database
#[worker::send]
pub async fn hard_delete_cipher(
claims: Claims,
State(env): State<Arc<Env>>,
Path(id): Path<String>,
) -> Result<Json<()>, AppError> {
let db = db::get_db(&env)?;
query!(
&db,
"DELETE FROM ciphers WHERE id = ?1 AND user_id = ?2",
id,
claims.sub
)
.map_err(|_| AppError::Database)?
.run()
.await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(()))
}
/// Hard delete multiple ciphers (DELETE /api/ciphers or POST /api/ciphers/delete)
#[worker::send]
pub async fn hard_delete_ciphers_bulk(
claims: Claims,
State(env): State<Arc<Env>>,
Json(payload): Json<CipherIdsData>,
) -> Result<Json<()>, AppError> {
let db = db::get_db(&env)?;
let batch_size = get_batch_size(&env);
let mut statements: Vec<D1PreparedStatement> = Vec::with_capacity(payload.ids.len());
for id in payload.ids {
let stmt = query!(
&db,
"DELETE FROM ciphers WHERE id = ?1 AND user_id = ?2",
id,
claims.sub
)
.map_err(|_| AppError::Database)?;
statements.push(stmt);
}
db::execute_in_batches(&db, statements, batch_size).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(()))
}
/// Restore a single cipher (PUT /api/ciphers/{id}/restore)
/// Clears the deleted_at timestamp
#[worker::send]
pub async fn restore_cipher(
claims: Claims,
State(env): State<Arc<Env>>,
Path(id): Path<String>,
) -> Result<Json<Cipher>, AppError> {
let db = db::get_db(&env)?;
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
// Update the cipher to clear deleted_at
query!(
&db,
"UPDATE ciphers SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND user_id = ?3",
now,
id,
claims.sub
)
.map_err(|_| AppError::Database)?
.run()
.await?;
// Fetch and return the restored cipher
let cipher_db: crate::models::cipher::CipherDBModel = query!(
&db,
"SELECT * FROM ciphers WHERE id = ?1 AND user_id = ?2",
id,
claims.sub
)
.map_err(|_| AppError::Database)?
.first(None)
.await?
.ok_or(AppError::NotFound("Cipher not found".to_string()))?;
let mut cipher: Cipher = cipher_db.into();
attachments::hydrate_cipher_attachments(&db, env.as_ref(), &mut cipher, &claims.sub).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(cipher))
}
/// Response for bulk restore operation
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BulkRestoreResponse {
pub data: Vec<Cipher>,
pub object: String,
pub continuation_token: Option<String>,
}
/// Restore multiple ciphers (PUT /api/ciphers/restore)
#[worker::send]
pub async fn restore_ciphers_bulk(
claims: Claims,
State(env): State<Arc<Env>>,
Json(payload): Json<CipherIdsData>,
) -> Result<Json<BulkRestoreResponse>, AppError> {
let db = db::get_db(&env)?;
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let batch_size = get_batch_size(&env);
let ids = payload.ids;
if ids.is_empty() {
return Ok(Json(BulkRestoreResponse {
data: vec![],
object: "list".to_string(),
continuation_token: None,
}));
}
// Batch UPDATE operations
let mut update_statements: Vec<D1PreparedStatement> = Vec::with_capacity(ids.len());
for id in ids.iter() {
let stmt = query!(
&db,
"UPDATE ciphers SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND user_id = ?3",
now,
id.clone(),
claims.sub
)
.map_err(|_| AppError::Database)?;
update_statements.push(stmt);
}
db::execute_in_batches(&db, update_statements, batch_size).await?;
// Batch SELECT using json_each() - avoid N+1 query problem
let ids_json = serde_json::to_string(&ids).map_err(|_| AppError::Internal)?;
let mut restored_ciphers: Vec<Cipher> = db
.prepare(
"SELECT * FROM ciphers WHERE user_id = ?1 AND id IN (SELECT value FROM json_each(?2))",
)
.bind(&[claims.sub.clone().into(), ids_json.into()])?
.all()
.await?
.results::<crate::models::cipher::CipherDBModel>()?
.into_iter()
.map(|cipher| cipher.into())
.collect();
attachments::hydrate_ciphers_attachments(&db, env.as_ref(), &mut restored_ciphers, &claims.sub)
.await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(BulkRestoreResponse {
data: restored_ciphers,
object: "list".to_string(),
continuation_token: None,
}))
}
/// Handler for POST /api/ciphers
/// Accepts flat JSON structure (camelCase) as sent by Bitwarden clients
/// when creating a cipher without collection assignments.
#[worker::send]
pub async fn create_cipher_simple(
claims: Claims,
State(env): State<Arc<Env>>,
Json(payload): Json<CipherRequestData>,
) -> Result<Json<Cipher>, AppError> {
let db = db::get_db(&env)?;
let now = Utc::now();
let now = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let cipher_data = CipherData {
name: payload.name,
notes: payload.notes,
type_fields: payload.type_fields,
};
let data_value = serde_json::to_value(&cipher_data).map_err(|_| AppError::Internal)?;
let mut cipher = Cipher {
id: Uuid::new_v4().to_string(),
user_id: Some(claims.sub.clone()),
organization_id: payload.organization_id.clone(),
r#type: payload.r#type,
data: data_value,
favorite: payload.favorite.unwrap_or(false),
folder_id: payload.folder_id.clone(),
deleted_at: None,
created_at: now.clone(),
updated_at: now.clone(),
object: "cipher".to_string(),
organization_use_totp: false,
edit: true,
view_password: true,
collection_ids: None,
attachments: None,
};
let data = serde_json::to_string(&cipher.data).map_err(|_| AppError::Internal)?;
query!(
&db,
"INSERT INTO ciphers (id, user_id, organization_id, type, data, favorite, folder_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
cipher.id,
cipher.user_id,
cipher.organization_id,
cipher.r#type,
data,
cipher.favorite,
cipher.folder_id,
cipher.created_at,
cipher.updated_at,
).map_err(|_| AppError::Database)?
.run()
.await?;
attachments::hydrate_cipher_attachments(&db, env.as_ref(), &mut cipher, &claims.sub).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(cipher))
}
/// Move selected ciphers to a folder (POST/PUT /api/ciphers/move)
#[worker::send]
pub async fn move_cipher_selected(
claims: Claims,
State(env): State<Arc<Env>>,
Json(payload): Json<MoveCipherData>,
) -> Result<Json<()>, AppError> {
let db = db::get_db(&env)?;
let user_id = &claims.sub;
let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
// Validate folder exists and belongs to user (if folder_id is provided)
if let Some(ref folder_id) = payload.folder_id {
let folder_exists: Option<serde_json::Value> = db
.prepare("SELECT id FROM folders WHERE id = ?1 AND user_id = ?2")
.bind(&[folder_id.clone().into(), user_id.clone().into()])?
.first(None)
.await?;
if folder_exists.is_none() {
return Err(AppError::BadRequest(
"Invalid folder: Folder does not exist or belongs to another user".to_string(),
));
}
}
if payload.ids.is_empty() {
return Ok(Json(()));
}
// Use json_each() to update all matching ciphers in a single query
// This avoids N+1 query problem by updating all ciphers at once
let ids_json = serde_json::to_string(&payload.ids).map_err(|_| AppError::Internal)?;
// Update folder_id for all ciphers that belong to the user and are in the ids list
// Using json_each() allows us to update all matching ciphers in a single query
db.prepare(
"UPDATE ciphers SET folder_id = ?1, updated_at = ?2
WHERE user_id = ?3 AND id IN (SELECT value FROM json_each(?4))",
)
.bind(&[
payload
.folder_id
.clone()
.map(|s| s.into())
.unwrap_or(worker::wasm_bindgen::JsValue::NULL),
now.into(),
user_id.clone().into(),
ids_json.into(),
])?
.run()
.await?;
// Update user's revision date
db::touch_user_updated_at(&db, user_id).await?;
Ok(Json(()))
}
/// Purge the user's vault - delete all ciphers and folders
/// POST /api/ciphers/purge
///
/// This is a destructive operation that requires password verification.
/// In vaultwarden, this endpoint also supports purging organization vaults,
/// but this simplified version only supports personal vault purge.
#[worker::send]
pub async fn purge_vault(
claims: Claims,
State(env): State<Arc<Env>>,
Json(payload): Json<PasswordOrOtpData>,
) -> Result<Json<()>, AppError> {
let db = db::get_db(&env)?;
let user_id = &claims.sub;
// Get the user from the database
let user: Value = db
.prepare("SELECT * FROM users WHERE id = ?1")
.bind(&[user_id.clone().into()])?
.first(None)
.await
.map_err(|_| AppError::Database)?
.ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?;
// Validate password (OTP not supported in this simplified version)
let provided_hash = payload
.master_password_hash
.ok_or_else(|| AppError::BadRequest("Missing master password hash".to_string()))?;
let verification = user.verify_master_password(&provided_hash).await?;
if !verification.is_valid() {
return Err(AppError::Unauthorized("Invalid password".to_string()));
}
// Delete all user's ciphers (both active and soft-deleted)
query!(&db, "DELETE FROM ciphers WHERE user_id = ?1", user_id)
.map_err(|_| AppError::Database)?
.run()
.await?;
// Delete all user's folders
query!(&db, "DELETE FROM folders WHERE user_id = ?1", user_id)
.map_err(|_| AppError::Database)?
.run()
.await?;
// Update user's revision date to trigger client sync
db::touch_user_updated_at(&db, user_id).await?;
Ok(Json(()))
}