From 392317a602bff7ffea4de1d84624b9d2bf6decb6 Mon Sep 17 00:00:00 2001 From: qaz741wsd856 Date: Sat, 17 Jan 2026 11:46:25 +0000 Subject: [PATCH] feat: Implement Cloudflare KV as an alternative attachment storage backend --- README.md | 14 ++- docs/deployment.md | 6 +- src/attachments.js | 190 ++++++++++++++++++++++++------------ src/handlers/accounts.rs | 3 +- src/handlers/attachments.rs | 179 ++++++++++++++++++++++++++------- src/handlers/ciphers.rs | 9 +- src/handlers/purge.rs | 4 +- wrangler.toml | 19 +++- 8 files changed, 303 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index f311c8f..503ef3e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Warden aims to solve this problem by leveraging the Cloudflare Workers ecosystem ## Features * **Core Vault Functionality:** Create, read, update, and delete ciphers and folders. -* **File Attachments:** Optional Cloudflare R2 storage for attachments. +* **File Attachments:** Optional Cloudflare KV or R2 storage for attachments. * **TOTP Support:** Store and generate Time-based One-Time Passwords. * **Bitwarden Compatible:** Works with official Bitwarden clients. * **Free to Host:** Runs on Cloudflare's free tier. @@ -25,7 +25,17 @@ Warden aims to solve this problem by leveraging the Cloudflare Workers ecosystem ### Attachments Support -Warden supports file attachments using Cloudflare R2 storage. Attachments are optional and require manual configuration to enable. See the deployment guide for setup details. Be aware that R2 may incur additional costs; see [Cloudflare R2 pricing](https://developers.cloudflare.com/r2/pricing/). +Warden supports file attachments using either **Cloudflare KV** or **Cloudflare R2** as the storage backend: + +| Feature | KV | R2 | +|---------|----|----| +| Max file size | **25 MB** (hard limit) | 100 MB (By request body size limit of Workers) | +| Credit card required | **No** | Yes | +| Streaming I/O | Yes | Yes | + +**Backend selection:** R2 takes priority — if R2 is configured, it will be used. Otherwise, KV is used. + +See the [deployment guide](docs/deployment.md) for setup details. R2 may incur additional costs; see [Cloudflare R2 pricing](https://developers.cloudflare.com/r2/pricing/). ## Current Status diff --git a/docs/deployment.md b/docs/deployment.md index 20da682..c085bde 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -19,7 +19,7 @@ This page covers the two deployment paths. Pick the one that fits your workflow 3. **(Optional) Enable R2 Bucket for Attachments:** - If you want to use file attachments: + Warden uses KV for attachments storage by default. If you want to use R2 as storage backend: ```bash # Create the production bucket @@ -28,7 +28,7 @@ This page covers the two deployment paths. Pick the one that fits your workflow Then enable the R2 binding in `wrangler.toml` by uncommenting the R2 bucket configuration sections. - **Note:** Attachments are optional. If you don't enable R2 bindings, attachment functionality will be disabled but all other features will work normally. + **Note:** Attachments are optional. If you remove both KV and R2 bindings, attachment functionality will be disabled but all other features will work normally. 4. **Configure your Database ID:** @@ -168,7 +168,7 @@ If you skip seeding, `/api/settings/domains` and `/api/sync` will return `global 3. **(Optional) Enable R2 bucket for attachments:** - If you want to use file attachments: + Warden uses KV for attachments storage by default. If you want to use R2 as storage backend: 1. **Create R2 buckets in Cloudflare Dashboard before running the action:** - Go to **Storage & databases** → **R2** → **Create bucket** diff --git a/src/attachments.js b/src/attachments.js index 6053ab2..6f23e7b 100644 --- a/src/attachments.js +++ b/src/attachments.js @@ -63,11 +63,11 @@ async function verifyJwt(token, secret) { if (!payload || typeof payload !== "object") { throw new Error("Invalid token payload"); } - + if (typeof payload.exp !== "number") { throw new Error("Invalid token exp"); } - + const now = Math.floor(Date.now() / 1000); if (payload.exp < now - JWT_VALIDATION_LEEWAY_SECS) { throw new Error("Token expired"); @@ -109,6 +109,26 @@ function getEnvVar(env, name, defaultValue = null) { } } +// Storage backend constants +const KV_MAX_VALUE_BYTES = 25 * 1024 * 1024; // 25 MiB (KV hard limit) + +// Detect which storage backend is available. +// Priority: R2 if bound, otherwise KV. +function getStorageBackend(env) { + if (env.ATTACHMENTS_BUCKET) { + return "r2"; + } + if (env.ATTACHMENTS_KV) { + return "kv"; + } + return null; +} + +// Check if using KV backend (for behavior differences) +function isKvBackend(env) { + return getStorageBackend(env) === "kv"; +} + // Get attachment size limits from env function getAttachmentMaxBytes(env) { const value = getEnvVar(env, "ATTACHMENT_MAX_BYTES"); @@ -163,11 +183,17 @@ async function enforceLimits(db, env, userId, newSize, excludeAttachmentId) { throw new Error("Attachment size cannot be negative"); } + // KV has a hard 25MB limit per value + if (isKvBackend(env) && newSize > KV_MAX_VALUE_BYTES) { + throw new Error(`Attachment size exceeds KV limit (max ${KV_MAX_VALUE_BYTES / 1024 / 1024}MB)`); + } + const maxBytes = getAttachmentMaxBytes(env); if (maxBytes !== null && newSize > maxBytes) { throw new Error("Attachment size exceeds limit"); } + // Check total storage limit const limitBytes = getTotalLimitBytes(env); if (limitBytes !== null) { const used = await getUserAttachmentUsage(db, userId, excludeAttachmentId); @@ -178,11 +204,11 @@ async function enforceLimits(db, env, userId, newSize, excludeAttachmentId) { } } -// Handle azure-upload with zero-copy streaming +// Handle azure-upload with zero-copy streaming (R2) or arrayBuffer (KV) export async function handleAzureUpload(request, env, cipherId, attachmentId, token) { - // Get R2 bucket - const bucket = env.ATTACHMENTS_BUCKET; - if (!bucket) { + // Check storage backend + const backend = getStorageBackend(env); + if (!backend) { return new Response(JSON.stringify({ error: "Attachments are not enabled" }), { status: 400, headers: { "Content-Type": "application/json" }, @@ -297,46 +323,63 @@ export async function handleAzureUpload(request, env, cipherId, attachmentId, to }); } - // Build R2 key - const r2Key = `${cipherId}/${attachmentId}`; + // Build storage key + const storageKey = `${cipherId}/${attachmentId}`; + let uploadedSize; - // Prepare R2 put options - const putOptions = {}; - const contentType = request.headers.get("Content-Type"); - if (contentType) { - putOptions.httpMetadata = { contentType }; - } - - // Upload to R2 directly using request.body (zero-copy streaming) - let r2Object; - try { - r2Object = await bucket.put(r2Key, request.body, putOptions); - } catch (err) { - // Try to clean up on failure + if (backend === "kv") { + // KV backend: streaming upload (KV.put accepts ReadableStream) + // Note: KV doesn't return the uploaded size, so we trust Content-Length + const kv = env.ATTACHMENTS_KV; try { - await bucket.delete(r2Key); - } catch { - // Ignore cleanup errors + await kv.put(storageKey, request.body); + } catch (err) { + return new Response(JSON.stringify({ error: `Upload failed: ${err.message}` }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + // KV doesn't return actual size, trust Content-Length + uploadedSize = contentLength; + } else { + // R2 backend: streaming upload (zero-copy) + const bucket = env.ATTACHMENTS_BUCKET; + const putOptions = {}; + const contentType = request.headers.get("Content-Type"); + if (contentType) { + putOptions.httpMetadata = { contentType }; } - return new Response(JSON.stringify({ error: `Upload failed: ${err.message}` }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); - } - const uploadedSize = r2Object.size; - - // Verify uploaded size matches Content-Length - if (uploadedSize !== contentLength) { + let r2Object; try { - await bucket.delete(r2Key); - } catch { - // Ignore cleanup errors + r2Object = await bucket.put(storageKey, request.body, putOptions); + } catch (err) { + // Try to clean up on failure + try { + await bucket.delete(storageKey); + } catch { + // Ignore cleanup errors + } + return new Response(JSON.stringify({ error: `Upload failed: ${err.message}` }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + + uploadedSize = r2Object.size; + + // Verify uploaded size matches Content-Length + if (uploadedSize !== contentLength) { + try { + await bucket.delete(storageKey); + } catch { + // Ignore cleanup errors + } + return new Response(JSON.stringify({ error: "Content-Length does not match uploaded size" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); } - return new Response(JSON.stringify({ error: "Content-Length does not match uploaded size" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); } // Finalize upload: move pending -> attachments and touch revision timestamps @@ -364,11 +407,11 @@ export async function handleAzureUpload(request, env, cipherId, attachmentId, to return new Response(null, { status: 201 }); } -// Handle download with zero-copy streaming +// Handle download with zero-copy streaming (R2) or ArrayBuffer (KV) export async function handleDownload(request, env, cipherId, attachmentId, token) { - // Get R2 bucket - const bucket = env.ATTACHMENTS_BUCKET; - if (!bucket) { + // Check storage backend + const backend = getStorageBackend(env); + if (!backend) { return new Response(JSON.stringify({ error: "Attachments are not enabled" }), { status: 400, headers: { "Content-Type": "application/json" }, @@ -442,27 +485,44 @@ export async function handleDownload(request, env, cipherId, attachmentId, token }); } - // Build R2 key - const r2Key = `${cipherId}/${attachmentId}`; + // Build storage key + const storageKey = `${cipherId}/${attachmentId}`; - // Get object from R2 - const r2Object = await bucket.get(r2Key); - if (!r2Object) { - return new Response(JSON.stringify({ error: "Attachment not found in storage" }), { - status: 404, - headers: { "Content-Type": "application/json" }, - }); + if (backend === "kv") { + // KV backend: streaming download + // Note: KV stream doesn't provide size info, use attachment.file_size from DB + const kv = env.ATTACHMENTS_KV; + const stream = await kv.get(storageKey, { type: "stream" }); + if (!stream) { + return new Response(JSON.stringify({ error: "Attachment not found in storage" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + const headers = new Headers(); + headers.set("Content-Type", "application/octet-stream"); + headers.set("Content-Length", attachment.file_size.toString()); + + return new Response(stream, { status: 200, headers }); + } else { + // R2 backend: streaming download (zero-copy) + const bucket = env.ATTACHMENTS_BUCKET; + const r2Object = await bucket.get(storageKey); + if (!r2Object) { + return new Response(JSON.stringify({ error: "Attachment not found in storage" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }); + } + + // Build response headers + const headers = new Headers(); + const contentType = r2Object.httpMetadata?.contentType || "application/octet-stream"; + headers.set("Content-Type", contentType); + headers.set("Content-Length", r2Object.size.toString()); + + // Return response with R2 object body directly - zero-copy streaming + return new Response(r2Object.body, { status: 200, headers }); } - - // Build response headers - const headers = new Headers(); - const contentType = r2Object.httpMetadata?.contentType || "application/octet-stream"; - headers.set("Content-Type", contentType); - headers.set("Content-Length", r2Object.size.toString()); - - // Return response with R2 object body directly - zero-copy streaming - return new Response(r2Object.body, { - status: 200, - headers, - }); } diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index f8f1cb9..12b39a0 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -584,9 +584,8 @@ pub async fn delete_account( } if attachments::attachments_enabled(env.as_ref()) { - let bucket = attachments::require_bucket(env.as_ref())?; let keys = attachments::list_attachment_keys_for_user(&db, user_id).await?; - attachments::delete_r2_objects(&bucket, &keys).await?; + attachments::delete_storage_objects(env.as_ref(), &keys).await?; } // Delete all user's ciphers diff --git a/src/handlers/attachments.rs b/src/handlers/attachments.rs index cf7af19..d07acf5 100644 --- a/src/handlers/attachments.rs +++ b/src/handlers/attachments.rs @@ -25,8 +25,36 @@ use crate::{ }; const ATTACHMENTS_BUCKET: &str = "ATTACHMENTS_BUCKET"; +const ATTACHMENTS_KV: &str = "ATTACHMENTS_KV"; const SIZE_LEEWAY_BYTES: i64 = 1024 * 1024; // 1 MiB const DEFAULT_ATTACHMENT_TTL_SECS: i64 = 300; // 5 minutes +const KV_MAX_VALUE_BYTES: i64 = 25 * 1024 * 1024; // 25 MiB (KV hard limit) + +/// Storage backend for attachments +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum StorageBackend { + /// Cloudflare KV - no credit card required, 25MB limit per value + KV, + /// Cloudflare R2 - requires credit card, no practical size limit + R2, +} + +/// Detect which storage backend is available. +/// Priority: R2 if bound, otherwise KV. +pub(crate) fn get_storage_backend(env: &Env) -> Option { + if env.bucket(ATTACHMENTS_BUCKET).is_ok() { + Some(StorageBackend::R2) + } else if env.kv(ATTACHMENTS_KV).is_ok() { + Some(StorageBackend::KV) + } else { + None + } +} + +/// Check if using KV backend (for behavior differences) +pub(crate) fn is_kv_backend(env: &Env) -> bool { + get_storage_backend(env) == Some(StorageBackend::KV) +} #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -112,8 +140,12 @@ pub async fn create_attachment_v2( Path(cipher_id): Path, Json(payload): Json, ) -> Result, AppError> { - // Require bucket; fail directly if missing - let _bucket = require_bucket(&env)?; + // Require storage backend; fail directly if missing + if !attachments_enabled(&env) { + return Err(AppError::BadRequest( + "Attachments are not enabled".to_string(), + )); + } let db = db::get_db(&env)?; let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?; @@ -203,7 +235,11 @@ pub async fn upload_attachment_v2_data( Path((cipher_id, attachment_id)): Path<(String, String)>, mut multipart: Multipart, ) -> Result, AppError> { - let bucket = require_bucket(&env)?; + if !attachments_enabled(&env) { + return Err(AppError::BadRequest( + "Attachments are not enabled".to_string(), + )); + } let db = db::get_db(&env)?; let _cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?; @@ -219,17 +255,20 @@ pub async fn upload_attachment_v2_data( read_multipart(&mut multipart).await?; let actual_size = file_bytes.len() as i64; - // Validate actual size against declared value deviation - if let Err(e) = validate_size_within_declared(&pending, actual_size) { - query!( - &db, - "DELETE FROM attachments_pending WHERE id = ?1", - pending.id - ) - .map_err(|_| AppError::Database)? - .run() - .await?; - return Err(e); + // For R2 backend: validate actual size against declared value deviation + // For KV backend: skip this check (we trust the actual upload size) + if !is_kv_backend(&env) { + if let Err(e) = validate_size_within_declared(&pending, actual_size) { + query!( + &db, + "DELETE FROM attachments_pending WHERE id = ?1", + pending.id + ) + .map_err(|_| AppError::Database)? + .run() + .await?; + return Err(e); + } } // Validate capacity limits (replace with actual size) @@ -245,14 +284,8 @@ pub async fn upload_attachment_v2_data( pending.akey = Some(k); } - // Save to R2 - upload_to_r2( - &bucket, - &pending.r2_key(), - content_type, - file_bytes.to_vec(), - ) - .await?; + // Save to storage (KV or R2) + upload_to_storage(&env, &pending.r2_key(), content_type, file_bytes.to_vec()).await?; // Finalize: move pending -> attachments and touch timestamps let now = now_string(); @@ -297,7 +330,11 @@ pub async fn upload_attachment_legacy( Path(cipher_id): Path, mut multipart: Multipart, ) -> Result, AppError> { - let bucket = require_bucket(&env)?; + if !attachments_enabled(&env) { + return Err(AppError::BadRequest( + "Attachments are not enabled".to_string(), + )); + } let db = db::get_db(&env)?; let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?; @@ -336,9 +373,9 @@ pub async fn upload_attachment_legacy( .run() .await?; - // Save to R2 - upload_to_r2( - &bucket, + // Save to storage (KV or R2) + upload_to_storage( + &env, &format!("{}/{}", cipher_id, attachment_id), content_type, file_bytes.to_vec(), @@ -363,7 +400,11 @@ pub async fn get_attachment( Extension(BaseUrl(base_url)): Extension, Path((cipher_id, attachment_id)): Path<(String, String)>, ) -> Result, AppError> { - let _bucket = require_bucket(&env)?; + if !attachments_enabled(&env) { + return Err(AppError::BadRequest( + "Attachments are not enabled".to_string(), + )); + } let db = db::get_db(&env)?; let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?; @@ -386,7 +427,11 @@ pub async fn delete_attachment( State(env): State>, Path((cipher_id, attachment_id)): Path<(String, String)>, ) -> Result, AppError> { - let bucket = require_bucket(&env)?; + if !attachments_enabled(&env) { + return Err(AppError::BadRequest( + "Attachments are not enabled".to_string(), + )); + } let db = db::get_db(&env)?; let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?; @@ -398,8 +443,8 @@ pub async fn delete_attachment( )); } - // Delete R2 object; ignore missing objects - delete_r2_objects(&bucket, &[attachment.r2_key()]).await?; + // Delete storage object; ignore missing objects + delete_storage_objects(&env, &[attachment.r2_key()]).await?; query!(&db, "DELETE FROM attachments WHERE id = ?1", attachment.id) .map_err(|_| AppError::Database)? @@ -453,12 +498,7 @@ pub async fn hydrate_cipher_attachments( } pub(crate) fn attachments_enabled(env: &Env) -> bool { - env.bucket(ATTACHMENTS_BUCKET).is_ok() -} - -pub(crate) fn require_bucket(env: &Env) -> Result { - env.bucket(ATTACHMENTS_BUCKET) - .map_err(|_| AppError::BadRequest("Attachments are not enabled".to_string())) + get_storage_backend(env).is_some() } fn is_not_found_error(err: &worker::Error) -> bool { @@ -477,6 +517,30 @@ pub(crate) async fn delete_r2_objects(bucket: &Bucket, keys: &[String]) -> Resul Ok(()) } +/// Delete objects from storage (KV or R2 based on configured backend) +pub(crate) async fn delete_storage_objects(env: &Env, keys: &[String]) -> Result<(), AppError> { + match get_storage_backend(env) { + Some(StorageBackend::KV) => { + let kv = env.kv(ATTACHMENTS_KV).map_err(|_| AppError::Internal)?; + for key in keys { + // KV delete is idempotent - no error if key doesn't exist + if let Err(e) = kv.delete(key).await { + log::error!("KV delete error for key '{}': {:?}", key, e); + return Err(AppError::Internal); + } + } + Ok(()) + } + Some(StorageBackend::R2) => { + let bucket = env + .bucket(ATTACHMENTS_BUCKET) + .map_err(|_| AppError::Internal)?; + delete_r2_objects(&bucket, keys).await + } + None => Ok(()), // No-op if attachments not enabled + } +} + fn map_rows_to_keys(rows: Vec) -> Vec { rows.into_iter() .map(|row| format!("{}/{}", row.cipher_id, row.id)) @@ -672,6 +736,40 @@ async fn upload_to_r2( Ok(()) } +/// Upload data to storage (KV or R2 based on configured backend) +async fn upload_to_storage( + env: &Env, + key: &str, + _content_type: Option, + data: Vec, +) -> Result<(), AppError> { + match get_storage_backend(env) { + Some(StorageBackend::KV) => { + let kv = env.kv(ATTACHMENTS_KV).map_err(|_| AppError::Internal)?; + // KV put_bytes stores raw binary data + if let Err(e) = kv + .put_bytes(key, &data) + .map_err(|_| AppError::Internal)? + .execute() + .await + { + log::error!("KV put error for key '{}': {:?}", key, e); + return Err(AppError::Internal); + } + Ok(()) + } + Some(StorageBackend::R2) => { + let bucket = env + .bucket(ATTACHMENTS_BUCKET) + .map_err(|_| AppError::Internal)?; + upload_to_r2(&bucket, key, _content_type, data).await + } + None => Err(AppError::BadRequest( + "Attachments are not enabled".to_string(), + )), + } +} + async fn read_multipart( multipart: &mut Multipart, ) -> Result<(Bytes, Option, Option, Option), AppError> { @@ -823,6 +921,14 @@ async fn enforce_limits( )); } + // KV has a hard 25MB limit per value + if is_kv_backend(env) && new_size > KV_MAX_VALUE_BYTES { + return Err(AppError::BadRequest(format!( + "Attachment size exceeds KV limit (max {}MB)", + KV_MAX_VALUE_BYTES / 1024 / 1024 + ))); + } + let max_bytes = attachment_max_bytes(env)?; if let Some(max_bytes) = max_bytes { if new_size as u64 > max_bytes { @@ -832,6 +938,7 @@ async fn enforce_limits( } } + // Check total storage limit let limit_bytes = total_limit_bytes(env)?; if let Some(limit_bytes) = limit_bytes { let used = user_attachment_usage(db, user_id, exclude_attachment).await?; diff --git a/src/handlers/ciphers.rs b/src/handlers/ciphers.rs index 5fc6934..b7f29b1 100644 --- a/src/handlers/ciphers.rs +++ b/src/handlers/ciphers.rs @@ -398,7 +398,6 @@ pub async fn hard_delete_cipher( let db = db::get_db(&env)?; if attachments::attachments_enabled(env.as_ref()) { - let bucket = attachments::require_bucket(env.as_ref())?; let id_json = serde_json::to_string(&[&id]).map_err(|_| AppError::Internal)?; let keys = attachments::list_attachment_keys_for_cipher_ids_json( &db, @@ -407,7 +406,7 @@ pub async fn hard_delete_cipher( Some(&claims.sub), ) .await?; - attachments::delete_r2_objects(&bucket, &keys).await?; + attachments::delete_storage_objects(env.as_ref(), &keys).await?; } query!( @@ -437,7 +436,6 @@ pub async fn hard_delete_ciphers_bulk( let db = db::get_db(&env)?; if attachments::attachments_enabled(env.as_ref()) { - let bucket = attachments::require_bucket(env.as_ref())?; let keys = attachments::list_attachment_keys_for_cipher_ids_json( &db, &body, @@ -445,7 +443,7 @@ pub async fn hard_delete_ciphers_bulk( Some(&claims.sub), ) .await?; - attachments::delete_r2_objects(&bucket, &keys).await?; + attachments::delete_storage_objects(env.as_ref(), &keys).await?; } query!( @@ -710,9 +708,8 @@ pub async fn purge_vault( } if attachments::attachments_enabled(env.as_ref()) { - let bucket = attachments::require_bucket(env.as_ref())?; let keys = attachments::list_attachment_keys_for_user(&db, user_id).await?; - attachments::delete_r2_objects(&bucket, &keys).await?; + attachments::delete_storage_objects(env.as_ref(), &keys).await?; } // Delete all user's ciphers (both active and soft-deleted) diff --git a/src/handlers/purge.rs b/src/handlers/purge.rs index 3d56a85..da0e067 100644 --- a/src/handlers/purge.rs +++ b/src/handlers/purge.rs @@ -119,13 +119,11 @@ pub async fn purge_deleted_ciphers(env: &Env) -> Result { if count > 0 { if attachments::attachments_enabled(env) { - let bucket = attachments::require_bucket(env) - .map_err(|e| worker::Error::RustError(e.to_string()))?; let keys = attachments::list_attachment_keys_for_soft_deleted_before(&db, &cutoff_str) .await .map_err(|e| worker::Error::RustError(e.to_string()))?; - attachments::delete_r2_objects(&bucket, &keys) + attachments::delete_storage_objects(env, &keys) .await .map_err(|e| worker::Error::RustError(e.to_string()))?; } diff --git a/wrangler.toml b/wrangler.toml index d1cfeb3..aca4e05 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -90,12 +90,20 @@ database_name = "vault1" database_id = "${D1_DATABASE_ID}" migrations_dir = "migrations" -# R2 bucket for file attachments (optional) -# Uncomment and configure this section to enable file attachments +# R2 bucket for file attachments (optional, requires credit card) +# R2 has priority: if R2 is configured, KV is ignored. +# R2 supports larger files and streaming uploads. +# Uncomment and configure this section to enable file attachments via R2: # [[r2_buckets]] # binding = "ATTACHMENTS_BUCKET" # bucket_name = "warden-attachments" +# KV namespace for file attachments (optional, no credit card required) +# KV has a 25MB limit per file, but doesn't require credit card binding. +# If R2 is not configured, KV will be used for attachments. +[[kv_namespaces]] +binding = "ATTACHMENTS_KV" + [env.dev] name = "warden-worker-dev" keep_vars = true @@ -125,12 +133,15 @@ database_name = "vault1-dev" database_id = "${D1_DATABASE_ID_DEV}" migrations_dir = "migrations" -# R2 bucket for file attachments in dev environment (optional) -# Uncomment and configure this section to enable file attachments +# R2 bucket for file attachments in dev environment (optional, requires credit card) # [[env.dev.r2_buckets]] # binding = "ATTACHMENTS_BUCKET" # bucket_name = "warden-attachments-dev" +# KV namespace for file attachments in dev environment (optional, no credit card required) +[[env.dev.kv_namespaces]] +binding = "ATTACHMENTS_KV" + # logs [env.dev.observability] [env.dev.observability.logs]