feat: Implement Cloudflare KV as an alternative attachment storage backend

This commit is contained in:
qaz741wsd856 2026-01-17 11:46:25 +00:00
parent 291b21015f
commit 392317a602
8 changed files with 303 additions and 121 deletions

View file

@ -15,7 +15,7 @@ Warden aims to solve this problem by leveraging the Cloudflare Workers ecosystem
## Features ## Features
* **Core Vault Functionality:** Create, read, update, and delete ciphers and folders. * **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. * **TOTP Support:** Store and generate Time-based One-Time Passwords.
* **Bitwarden Compatible:** Works with official Bitwarden clients. * **Bitwarden Compatible:** Works with official Bitwarden clients.
* **Free to Host:** Runs on Cloudflare's free tier. * **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 ### 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 ## Current Status

View file

@ -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:** 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 ```bash
# Create the production bucket # 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. 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:** 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:** 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:** 1. **Create R2 buckets in Cloudflare Dashboard before running the action:**
- Go to **Storage & databases****R2** → **Create bucket** - Go to **Storage & databases****R2** → **Create bucket**

View file

@ -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 // Get attachment size limits from env
function getAttachmentMaxBytes(env) { function getAttachmentMaxBytes(env) {
const value = getEnvVar(env, "ATTACHMENT_MAX_BYTES"); 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"); 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); const maxBytes = getAttachmentMaxBytes(env);
if (maxBytes !== null && newSize > maxBytes) { if (maxBytes !== null && newSize > maxBytes) {
throw new Error("Attachment size exceeds limit"); throw new Error("Attachment size exceeds limit");
} }
// Check total storage limit
const limitBytes = getTotalLimitBytes(env); const limitBytes = getTotalLimitBytes(env);
if (limitBytes !== null) { if (limitBytes !== null) {
const used = await getUserAttachmentUsage(db, userId, excludeAttachmentId); 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) { export async function handleAzureUpload(request, env, cipherId, attachmentId, token) {
// Get R2 bucket // Check storage backend
const bucket = env.ATTACHMENTS_BUCKET; const backend = getStorageBackend(env);
if (!bucket) { if (!backend) {
return new Response(JSON.stringify({ error: "Attachments are not enabled" }), { return new Response(JSON.stringify({ error: "Attachments are not enabled" }), {
status: 400, status: 400,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -297,24 +323,40 @@ export async function handleAzureUpload(request, env, cipherId, attachmentId, to
}); });
} }
// Build R2 key // Build storage key
const r2Key = `${cipherId}/${attachmentId}`; const storageKey = `${cipherId}/${attachmentId}`;
let uploadedSize;
// Prepare R2 put options 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 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 putOptions = {};
const contentType = request.headers.get("Content-Type"); const contentType = request.headers.get("Content-Type");
if (contentType) { if (contentType) {
putOptions.httpMetadata = { contentType }; putOptions.httpMetadata = { contentType };
} }
// Upload to R2 directly using request.body (zero-copy streaming)
let r2Object; let r2Object;
try { try {
r2Object = await bucket.put(r2Key, request.body, putOptions); r2Object = await bucket.put(storageKey, request.body, putOptions);
} catch (err) { } catch (err) {
// Try to clean up on failure // Try to clean up on failure
try { try {
await bucket.delete(r2Key); await bucket.delete(storageKey);
} catch { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
@ -324,12 +366,12 @@ export async function handleAzureUpload(request, env, cipherId, attachmentId, to
}); });
} }
const uploadedSize = r2Object.size; uploadedSize = r2Object.size;
// Verify uploaded size matches Content-Length // Verify uploaded size matches Content-Length
if (uploadedSize !== contentLength) { if (uploadedSize !== contentLength) {
try { try {
await bucket.delete(r2Key); await bucket.delete(storageKey);
} catch { } catch {
// Ignore cleanup errors // Ignore cleanup errors
} }
@ -338,6 +380,7 @@ export async function handleAzureUpload(request, env, cipherId, attachmentId, to
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
} }
}
// Finalize upload: move pending -> attachments and touch revision timestamps // Finalize upload: move pending -> attachments and touch revision timestamps
const now = nowString(); const now = nowString();
@ -364,11 +407,11 @@ export async function handleAzureUpload(request, env, cipherId, attachmentId, to
return new Response(null, { status: 201 }); 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) { export async function handleDownload(request, env, cipherId, attachmentId, token) {
// Get R2 bucket // Check storage backend
const bucket = env.ATTACHMENTS_BUCKET; const backend = getStorageBackend(env);
if (!bucket) { if (!backend) {
return new Response(JSON.stringify({ error: "Attachments are not enabled" }), { return new Response(JSON.stringify({ error: "Attachments are not enabled" }), {
status: 400, status: 400,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -442,11 +485,30 @@ export async function handleDownload(request, env, cipherId, attachmentId, token
}); });
} }
// Build R2 key // Build storage key
const r2Key = `${cipherId}/${attachmentId}`; const storageKey = `${cipherId}/${attachmentId}`;
// Get object from R2 if (backend === "kv") {
const r2Object = await bucket.get(r2Key); // 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) { if (!r2Object) {
return new Response(JSON.stringify({ error: "Attachment not found in storage" }), { return new Response(JSON.stringify({ error: "Attachment not found in storage" }), {
status: 404, status: 404,
@ -461,8 +523,6 @@ export async function handleDownload(request, env, cipherId, attachmentId, token
headers.set("Content-Length", r2Object.size.toString()); headers.set("Content-Length", r2Object.size.toString());
// Return response with R2 object body directly - zero-copy streaming // Return response with R2 object body directly - zero-copy streaming
return new Response(r2Object.body, { return new Response(r2Object.body, { status: 200, headers });
status: 200, }
headers,
});
} }

View file

@ -584,9 +584,8 @@ pub async fn delete_account(
} }
if attachments::attachments_enabled(env.as_ref()) { 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?; 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 // Delete all user's ciphers

View file

@ -25,8 +25,36 @@ use crate::{
}; };
const ATTACHMENTS_BUCKET: &str = "ATTACHMENTS_BUCKET"; const ATTACHMENTS_BUCKET: &str = "ATTACHMENTS_BUCKET";
const ATTACHMENTS_KV: &str = "ATTACHMENTS_KV";
const SIZE_LEEWAY_BYTES: i64 = 1024 * 1024; // 1 MiB const SIZE_LEEWAY_BYTES: i64 = 1024 * 1024; // 1 MiB
const DEFAULT_ATTACHMENT_TTL_SECS: i64 = 300; // 5 minutes 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<StorageBackend> {
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)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
@ -112,8 +140,12 @@ pub async fn create_attachment_v2(
Path(cipher_id): Path<String>, Path(cipher_id): Path<String>,
Json(payload): Json<AttachmentCreateRequest>, Json(payload): Json<AttachmentCreateRequest>,
) -> Result<Json<AttachmentUploadResponse>, AppError> { ) -> Result<Json<AttachmentUploadResponse>, AppError> {
// Require bucket; fail directly if missing // Require storage backend; fail directly if missing
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 db = db::get_db(&env)?;
let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?; 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)>, Path((cipher_id, attachment_id)): Path<(String, String)>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Json<()>, AppError> { ) -> Result<Json<()>, 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 db = db::get_db(&env)?;
let _cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?; let _cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?;
@ -219,7 +255,9 @@ pub async fn upload_attachment_v2_data(
read_multipart(&mut multipart).await?; read_multipart(&mut multipart).await?;
let actual_size = file_bytes.len() as i64; let actual_size = file_bytes.len() as i64;
// Validate actual size against declared value deviation // 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) { if let Err(e) = validate_size_within_declared(&pending, actual_size) {
query!( query!(
&db, &db,
@ -231,6 +269,7 @@ pub async fn upload_attachment_v2_data(
.await?; .await?;
return Err(e); return Err(e);
} }
}
// Validate capacity limits (replace with actual size) // Validate capacity limits (replace with actual size)
enforce_limits(&db, &env, &claims.sub, actual_size, Some(&pending.id)).await?; enforce_limits(&db, &env, &claims.sub, actual_size, Some(&pending.id)).await?;
@ -245,14 +284,8 @@ pub async fn upload_attachment_v2_data(
pending.akey = Some(k); pending.akey = Some(k);
} }
// Save to R2 // Save to storage (KV or R2)
upload_to_r2( upload_to_storage(&env, &pending.r2_key(), content_type, file_bytes.to_vec()).await?;
&bucket,
&pending.r2_key(),
content_type,
file_bytes.to_vec(),
)
.await?;
// Finalize: move pending -> attachments and touch timestamps // Finalize: move pending -> attachments and touch timestamps
let now = now_string(); let now = now_string();
@ -297,7 +330,11 @@ pub async fn upload_attachment_legacy(
Path(cipher_id): Path<String>, Path(cipher_id): Path<String>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Json<Cipher>, AppError> { ) -> Result<Json<Cipher>, 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 db = db::get_db(&env)?;
let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?; let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?;
@ -336,9 +373,9 @@ pub async fn upload_attachment_legacy(
.run() .run()
.await?; .await?;
// Save to R2 // Save to storage (KV or R2)
upload_to_r2( upload_to_storage(
&bucket, &env,
&format!("{}/{}", cipher_id, attachment_id), &format!("{}/{}", cipher_id, attachment_id),
content_type, content_type,
file_bytes.to_vec(), file_bytes.to_vec(),
@ -363,7 +400,11 @@ pub async fn get_attachment(
Extension(BaseUrl(base_url)): Extension<BaseUrl>, Extension(BaseUrl(base_url)): Extension<BaseUrl>,
Path((cipher_id, attachment_id)): Path<(String, String)>, Path((cipher_id, attachment_id)): Path<(String, String)>,
) -> Result<Json<AttachmentResponse>, AppError> { ) -> Result<Json<AttachmentResponse>, 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 db = db::get_db(&env)?;
let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?; let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?;
@ -386,7 +427,11 @@ pub async fn delete_attachment(
State(env): State<Arc<Env>>, State(env): State<Arc<Env>>,
Path((cipher_id, attachment_id)): Path<(String, String)>, Path((cipher_id, attachment_id)): Path<(String, String)>,
) -> Result<Json<AttachmentDeleteResponse>, AppError> { ) -> Result<Json<AttachmentDeleteResponse>, 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 db = db::get_db(&env)?;
let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?; 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 storage object; ignore missing objects
delete_r2_objects(&bucket, &[attachment.r2_key()]).await?; delete_storage_objects(&env, &[attachment.r2_key()]).await?;
query!(&db, "DELETE FROM attachments WHERE id = ?1", attachment.id) query!(&db, "DELETE FROM attachments WHERE id = ?1", attachment.id)
.map_err(|_| AppError::Database)? .map_err(|_| AppError::Database)?
@ -453,12 +498,7 @@ pub async fn hydrate_cipher_attachments(
} }
pub(crate) fn attachments_enabled(env: &Env) -> bool { pub(crate) fn attachments_enabled(env: &Env) -> bool {
env.bucket(ATTACHMENTS_BUCKET).is_ok() get_storage_backend(env).is_some()
}
pub(crate) fn require_bucket(env: &Env) -> Result<Bucket, AppError> {
env.bucket(ATTACHMENTS_BUCKET)
.map_err(|_| AppError::BadRequest("Attachments are not enabled".to_string()))
} }
fn is_not_found_error(err: &worker::Error) -> bool { 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(()) 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<AttachmentKeyRow>) -> Vec<String> { fn map_rows_to_keys(rows: Vec<AttachmentKeyRow>) -> Vec<String> {
rows.into_iter() rows.into_iter()
.map(|row| format!("{}/{}", row.cipher_id, row.id)) .map(|row| format!("{}/{}", row.cipher_id, row.id))
@ -672,6 +736,40 @@ async fn upload_to_r2(
Ok(()) Ok(())
} }
/// Upload data to storage (KV or R2 based on configured backend)
async fn upload_to_storage(
env: &Env,
key: &str,
_content_type: Option<String>,
data: Vec<u8>,
) -> 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( async fn read_multipart(
multipart: &mut Multipart, multipart: &mut Multipart,
) -> Result<(Bytes, Option<String>, Option<String>, Option<String>), AppError> { ) -> Result<(Bytes, Option<String>, Option<String>, Option<String>), 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)?; let max_bytes = attachment_max_bytes(env)?;
if let Some(max_bytes) = max_bytes { if let Some(max_bytes) = max_bytes {
if new_size as u64 > 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)?; let limit_bytes = total_limit_bytes(env)?;
if let Some(limit_bytes) = limit_bytes { if let Some(limit_bytes) = limit_bytes {
let used = user_attachment_usage(db, user_id, exclude_attachment).await?; let used = user_attachment_usage(db, user_id, exclude_attachment).await?;

View file

@ -398,7 +398,6 @@ pub async fn hard_delete_cipher(
let db = db::get_db(&env)?; let db = db::get_db(&env)?;
if attachments::attachments_enabled(env.as_ref()) { 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 id_json = serde_json::to_string(&[&id]).map_err(|_| AppError::Internal)?;
let keys = attachments::list_attachment_keys_for_cipher_ids_json( let keys = attachments::list_attachment_keys_for_cipher_ids_json(
&db, &db,
@ -407,7 +406,7 @@ pub async fn hard_delete_cipher(
Some(&claims.sub), Some(&claims.sub),
) )
.await?; .await?;
attachments::delete_r2_objects(&bucket, &keys).await?; attachments::delete_storage_objects(env.as_ref(), &keys).await?;
} }
query!( query!(
@ -437,7 +436,6 @@ pub async fn hard_delete_ciphers_bulk(
let db = db::get_db(&env)?; let db = db::get_db(&env)?;
if attachments::attachments_enabled(env.as_ref()) { 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( let keys = attachments::list_attachment_keys_for_cipher_ids_json(
&db, &db,
&body, &body,
@ -445,7 +443,7 @@ pub async fn hard_delete_ciphers_bulk(
Some(&claims.sub), Some(&claims.sub),
) )
.await?; .await?;
attachments::delete_r2_objects(&bucket, &keys).await?; attachments::delete_storage_objects(env.as_ref(), &keys).await?;
} }
query!( query!(
@ -710,9 +708,8 @@ pub async fn purge_vault(
} }
if attachments::attachments_enabled(env.as_ref()) { 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?; 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) // Delete all user's ciphers (both active and soft-deleted)

View file

@ -119,13 +119,11 @@ pub async fn purge_deleted_ciphers(env: &Env) -> Result<u32, worker::Error> {
if count > 0 { if count > 0 {
if attachments::attachments_enabled(env) { 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) let keys = attachments::list_attachment_keys_for_soft_deleted_before(&db, &cutoff_str)
.await .await
.map_err(|e| worker::Error::RustError(e.to_string()))?; .map_err(|e| worker::Error::RustError(e.to_string()))?;
attachments::delete_r2_objects(&bucket, &keys) attachments::delete_storage_objects(env, &keys)
.await .await
.map_err(|e| worker::Error::RustError(e.to_string()))?; .map_err(|e| worker::Error::RustError(e.to_string()))?;
} }

View file

@ -90,12 +90,20 @@ database_name = "vault1"
database_id = "${D1_DATABASE_ID}" database_id = "${D1_DATABASE_ID}"
migrations_dir = "migrations" migrations_dir = "migrations"
# R2 bucket for file attachments (optional) # R2 bucket for file attachments (optional, requires credit card)
# Uncomment and configure this section to enable file attachments # 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]] # [[r2_buckets]]
# binding = "ATTACHMENTS_BUCKET" # binding = "ATTACHMENTS_BUCKET"
# bucket_name = "warden-attachments" # 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] [env.dev]
name = "warden-worker-dev" name = "warden-worker-dev"
keep_vars = true keep_vars = true
@ -125,12 +133,15 @@ database_name = "vault1-dev"
database_id = "${D1_DATABASE_ID_DEV}" database_id = "${D1_DATABASE_ID_DEV}"
migrations_dir = "migrations" migrations_dir = "migrations"
# R2 bucket for file attachments in dev environment (optional) # R2 bucket for file attachments in dev environment (optional, requires credit card)
# Uncomment and configure this section to enable file attachments
# [[env.dev.r2_buckets]] # [[env.dev.r2_buckets]]
# binding = "ATTACHMENTS_BUCKET" # binding = "ATTACHMENTS_BUCKET"
# bucket_name = "warden-attachments-dev" # 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 # logs
[env.dev.observability] [env.dev.observability]
[env.dev.observability.logs] [env.dev.observability.logs]