feat: enhance cipher management with legacy API routes and partial updates
This commit is contained in:
parent
6d2fb7aa3e
commit
90b6058fb6
4 changed files with 150 additions and 3 deletions
|
|
@ -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.
|
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
|
## Compatibility
|
||||||
|
|
||||||
* **Browser Extensions:** Chrome, Firefox, Safari, etc. (Tested 2025.11.1 on Chrome)
|
* **Browser Extensions:** Chrome, Firefox, Safari, etc. (Tested 2025.11.1 on Chrome)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use super::get_batch_size;
|
use super::get_batch_size;
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
@ -10,10 +10,24 @@ use worker::{query, D1PreparedStatement, Env};
|
||||||
use crate::auth::Claims;
|
use crate::auth::Claims;
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::error::AppError;
|
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 crate::models::user::{PasswordOrOtpData, User};
|
||||||
use axum::extract::Path;
|
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]
|
#[worker::send]
|
||||||
pub async fn create_cipher(
|
pub async fn create_cipher(
|
||||||
claims: Claims,
|
claims: Claims,
|
||||||
|
|
@ -152,6 +166,107 @@ pub async fn update_cipher(
|
||||||
Ok(Json(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
|
/// Request body for bulk cipher operations
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
|
||||||
|
|
@ -313,10 +313,27 @@ pub struct CreateCipherRequest {
|
||||||
pub collection_ids: Vec<String>,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct MoveCipherData {
|
pub struct MoveCipherData {
|
||||||
pub folder_id: Option<String>,
|
pub folder_id: Option<String>,
|
||||||
pub ids: Vec<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,
|
||||||
|
}
|
||||||
|
|
@ -40,10 +40,14 @@ pub fn api_router(env: Env) -> Router {
|
||||||
post(accounts::post_rotatekey),
|
post(accounts::post_rotatekey),
|
||||||
)
|
)
|
||||||
// Ciphers CRUD
|
// Ciphers CRUD
|
||||||
|
.route("/api/ciphers", get(ciphers::list_ciphers))
|
||||||
.route("/api/ciphers", post(ciphers::create_cipher_simple))
|
.route("/api/ciphers", post(ciphers::create_cipher_simple))
|
||||||
.route("/api/ciphers/create", post(ciphers::create_cipher))
|
.route("/api/ciphers/create", post(ciphers::create_cipher))
|
||||||
.route("/api/ciphers/import", post(import::import_data))
|
.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}", put(ciphers::update_cipher))
|
||||||
|
.route("/api/ciphers/{id}", post(ciphers::update_cipher))
|
||||||
// Cipher soft delete (PUT sets deleted_at timestamp)
|
// Cipher soft delete (PUT sets deleted_at timestamp)
|
||||||
.route("/api/ciphers/{id}/delete", put(ciphers::soft_delete_cipher))
|
.route("/api/ciphers/{id}/delete", put(ciphers::soft_delete_cipher))
|
||||||
// Cipher hard delete (DELETE/POST permanently removes cipher)
|
// Cipher hard delete (DELETE/POST permanently removes cipher)
|
||||||
|
|
@ -52,6 +56,15 @@ pub fn api_router(env: Env) -> Router {
|
||||||
"/api/ciphers/{id}/delete",
|
"/api/ciphers/{id}/delete",
|
||||||
post(ciphers::hard_delete_cipher),
|
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
|
// Cipher bulk soft delete
|
||||||
.route(
|
.route(
|
||||||
"/api/ciphers/delete",
|
"/api/ciphers/delete",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue