feat: enhance cipher management with legacy API routes and partial updates

This commit is contained in:
qaz741wsd856 2025-12-05 08:58:26 +00:00
parent 6d2fb7aa3e
commit 90b6058fb6
4 changed files with 150 additions and 3 deletions

View file

@ -36,6 +36,8 @@ Warden aims to solve this problem by leveraging the Cloudflare Workers ecosystem
There are no immediate plans to implement these features. The primary goal of this project is to provide a simple, free, and low-maintenance personal password manager.
Attachments are **not yet implemented**. They will be added later using Cloudflare R2; for now, attachment upload/download endpoints are not available.
## Compatibility
* **Browser Extensions:** Chrome, Firefox, Safari, etc. (Tested 2025.11.1 on Chrome)

View file

@ -1,7 +1,7 @@
use super::get_batch_size;
use axum::{extract::State, Json};
use chrono::Utc;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::sync::Arc;
use uuid::Uuid;
@ -10,10 +10,24 @@ use worker::{query, D1PreparedStatement, Env};
use crate::auth::Claims;
use crate::db;
use crate::error::AppError;
use crate::models::cipher::{Cipher, CipherData, CipherRequestData, CreateCipherRequest, MoveCipherData};
use crate::models::cipher::{Cipher, CipherData, CipherDBModel, 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,
@ -152,6 +166,107 @@ pub async fn update_cipher(
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 ciphers: Vec<Cipher> = ciphers_db.into_iter().map(|c| c.into()).collect();
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?;
Ok(Json(cipher.into()))
}
/// 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?;
Ok(Json(cipher.into()))
}
/// Request body for bulk cipher operations
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]

View file

@ -313,10 +313,27 @@ pub struct CreateCipherRequest {
pub collection_ids: Vec<String>,
}
/// Request body for moving ciphers to a folder
/// Request body for moving ciphers to a folder (POST /api/ciphers/move)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MoveCipherData {
pub folder_id: Option<String>,
pub ids: Vec<String>,
}
/// Response for listing ciphers (GET /api/ciphers)
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CipherListResponse {
pub data: Vec<Cipher>,
pub object: String,
pub continuation_token: Option<String>,
}
/// Request body for updating a cipher partially (PUT /api/ciphers/{id}/partial)
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PartialCipherData {
pub folder_id: Option<String>,
pub favorite: bool,
}

View file

@ -40,10 +40,14 @@ pub fn api_router(env: Env) -> Router {
post(accounts::post_rotatekey),
)
// Ciphers CRUD
.route("/api/ciphers", get(ciphers::list_ciphers))
.route("/api/ciphers", post(ciphers::create_cipher_simple))
.route("/api/ciphers/create", post(ciphers::create_cipher))
.route("/api/ciphers/import", post(import::import_data))
.route("/api/ciphers/{id}", get(ciphers::get_cipher))
.route("/api/ciphers/{id}/details", get(ciphers::get_cipher_details))
.route("/api/ciphers/{id}", put(ciphers::update_cipher))
.route("/api/ciphers/{id}", post(ciphers::update_cipher))
// Cipher soft delete (PUT sets deleted_at timestamp)
.route("/api/ciphers/{id}/delete", put(ciphers::soft_delete_cipher))
// Cipher hard delete (DELETE/POST permanently removes cipher)
@ -52,6 +56,15 @@ pub fn api_router(env: Env) -> Router {
"/api/ciphers/{id}/delete",
post(ciphers::hard_delete_cipher),
)
// Partial update for folder/favorite
.route(
"/api/ciphers/{id}/partial",
put(ciphers::update_cipher_partial),
)
.route(
"/api/ciphers/{id}/partial",
post(ciphers::update_cipher_partial),
)
// Cipher bulk soft delete
.route(
"/api/ciphers/delete",