diff --git a/README.md b/README.md index 6cc4db7..9cf3636 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/handlers/ciphers.rs b/src/handlers/ciphers.rs index 175b0de..ffe7c87 100644 --- a/src/handlers/ciphers.rs +++ b/src/handlers/ciphers.rs @@ -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 { + 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>, +) -> Result, AppError> { + let db = db::get_db(&env)?; + + let ciphers_db: Vec = 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 = 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>, + Path(id): Path, +) -> Result, 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>, + id: Path, +) -> Result, 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>, + Path(id): Path, + Json(payload): Json, +) -> Result, 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 = 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")] diff --git a/src/models/cipher.rs b/src/models/cipher.rs index e723665..328ea5e 100644 --- a/src/models/cipher.rs +++ b/src/models/cipher.rs @@ -313,10 +313,27 @@ pub struct CreateCipherRequest { pub collection_ids: Vec, } -/// 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, pub ids: Vec, +} + +/// Response for listing ciphers (GET /api/ciphers) +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CipherListResponse { + pub data: Vec, + pub object: String, + pub continuation_token: Option, +} + +/// 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, + pub favorite: bool, } \ No newline at end of file diff --git a/src/router.rs b/src/router.rs index 8406be7..37ff8a0 100644 --- a/src/router.rs +++ b/src/router.rs @@ -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",