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.
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue