feat: add file attachment support for ciphers

- Implemented functionality to create, upload, download, and delete attachments associated with ciphers.
- Introduced a new `attachments` module and updated the database schema to include an `attachments` table.
- Enhanced the API routes to handle attachment operations, including legacy support.
- Updated the README and configuration files to reflect the new attachment feature and its usage.
This commit is contained in:
qaz741wsd856 2025-12-07 11:13:06 +00:00
parent 38b978ca6e
commit 73223bfa94
15 changed files with 1167 additions and 25 deletions

46
Cargo.lock generated
View file

@ -61,6 +61,7 @@ dependencies = [
"matchit 0.8.4",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"rustversion",
@ -237,6 +238,15 @@ dependencies = [
"syn",
]
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.2"
@ -398,6 +408,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "iana-time-zone"
version = "0.1.64"
@ -633,6 +649,23 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "multer"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
dependencies = [
"bytes",
"encoding_rs",
"futures-util",
"http",
"httparse",
"memchr",
"mime",
"spin",
"version_check",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@ -976,6 +1009,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@ -1200,6 +1239,12 @@ dependencies = [
"rand 0.9.2",
]
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "warden-worker"
version = "0.2.0"
@ -1207,6 +1252,7 @@ dependencies = [
"axum",
"base32",
"base64 0.21.7",
"bytes",
"chrono",
"console_error_panic_hook",
"console_log",

View file

@ -25,9 +25,10 @@ web-sys = { version = "0.3", features = ["Crypto", "CryptoKey", "SubtleCrypto",
console_error_panic_hook = "0.1.7"
# Axum and Routing
axum = { version = "0.8", default-features = false, features=["json", "macros", "form"] }
axum = { version = "0.8", default-features = false, features=["json", "macros", "form", "multipart", "query"] }
tower-service = "0.3"
tower-http = { version = "0.5", features = ["cors"] }
bytes = "1"
# Data & Serialization
serde = { version = "1.0", features = ["derive"] }

View file

@ -14,6 +14,7 @@ Warden aims to solve this problem by leveraging the Cloudflare Workers ecosystem
## Features
* **Core Vault Functionality:** All your basic vault operations are supported, including creating, reading, updating, and deleting ciphers and folders.
* **File Attachments:** Store files and documents with your passwords using Cloudflare R2 (optional feature).
* **TOTP Support:** Store and generate Time-based One-Time Passwords for your accounts.
* **Bitwarden Compatible:** Works with the official Bitwarden browser extensions and app on both Android and iOS.
* **Free to Host:** Runs on Cloudflare's free tier.
@ -21,6 +22,10 @@ Warden aims to solve this problem by leveraging the Cloudflare Workers ecosystem
* **Secure:** Your data is stored in your own Cloudflare D1 database.
* **Easy to Deploy:** Get up and running in minutes with the Wrangler CLI.
### Attachments Support
Warden now supports file attachments using Cloudflare R2 storage. Attachments are optional and require manual configuration to enable. See the deployment sections below for specific setup instructions. Be aware that R2 may incur additional costs, see [Cloudflare R2 pricing](https://developers.cloudflare.com/r2/pricing/) for details.
## Current Status
**This project is not yet feature-complete**, ~~and it may never be~~. It currently supports the core functionality of a personal vault, including TOTP. However, it does **not** support the following features:
@ -36,8 +41,6 @@ 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)
@ -51,7 +54,11 @@ Attachments are **not yet implemented**. They will be added later using Cloudfla
* A Cloudflare account.
* The [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/get-started/) installed and configured.
### Deployment
You can choose both of the following deployment methods:
- [CLI Deployment](#cli-deployment)
- [CI/CD Deployment with GitHub Actions](#cicd-deployment-with-github-actions)
### CLI Deployment
1. **Clone the repository:**
@ -66,7 +73,20 @@ Attachments are **not yet implemented**. They will be added later using Cloudfla
wrangler d1 create warden-db
```
3. **Configure your Database ID:**
3. **(Optional) Enable R2 Bucket for Attachments:**
If you want to use file attachments:
```bash
# Create the production bucket
wrangler r2 bucket create warden-attachments
```
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.
4. **Configure your Database ID:**
When you create a D1 database, Wrangler will output the `database_id`. To avoid committing this secret to your repository, this project uses an environment variable to configure the database ID.
@ -91,7 +111,7 @@ Attachments are **not yet implemented**. They will be added later using Cloudfla
wrangler deploy
```
4. **Download the frontend (Web Vault):**
5. **Download the frontend (Web Vault):**
```bash
# Get latest version tag
@ -110,7 +130,7 @@ Attachments are **not yet implemented**. They will be added later using Cloudfla
rm bw_web_${LATEST_TAG}.tar.gz
```
5. **Set up database and deploy the worker:**
6. **Set up database and deploy the worker:**
```bash
# Only run once before first deployment
@ -123,13 +143,13 @@ Attachments are **not yet implemented**. They will be added later using Cloudfla
This will deploy the worker and set up the necessary database tables.
6. **Set environment variables** as `Secret`
7. **Set environment variables** as `Secret`
- `ALLOWED_EMAILS` your-email@example.com (supports glob patterns like `*@example.com`)
- `JWT_SECRET` a long random string
- `JWT_REFRESH_SECRET` a long random string
7. **Configure your Bitwarden client:**
8. **Configure your Bitwarden client:**
In your Bitwarden client, go to the self-hosted login screen and enter the URL of your deployed worker (e.g., `https://warden-worker.your-username.workers.dev`).
@ -165,13 +185,29 @@ Add the following secrets to your GitHub repository (`Settings > Secrets and var
2. **Configure the required secrets** in your repository settings
3. **Manually trigger the `Build` Action** from the GitHub Actions tab in your repository
3. **(Optional) Enable R2 bucket for attachments:**
4. **Monitor the deployment** in the Actions tab of your repository
If you want to use file attachments:
5. **Set up tables in database manually** in the Cloudflare console
1. **Create R2 buckets:**
- Log in to [Cloudflare Dashboard](https://dash.cloudflare.com/)
- Go to **Storage & databases****R2** → **Create bucket**
- Create buckets named `warden-attachments`
6. **Set environment variables** as `secret` in the Cloudflare console (following the command line deployment steps):
2. **Add R2 bindings:**
- Go to **Workers & Pages** → Select your `warden-worker`
- Click **Settings** → **Bindings**
- Click **Add binding** → **R2 bucket**
- Variable name: `ATTACHMENTS_BUCKET`
- R2 bucket: `warden-attachments`
4. **Manually trigger the `Build` Action** from the GitHub Actions tab in your repository
5. **Monitor the deployment** in the Actions tab of your repository
6. **Set up tables in database manually** in the Cloudflare console
7. **Set environment variables** as `secret` in the Cloudflare console (following the command line deployment steps):
- `ALLOWED_EMAILS` your-email@example.com (supports glob patterns like `*@example.com`)
- `JWT_SECRET` a long random string
- `JWT_REFRESH_SECRET` a long random string
@ -318,6 +354,20 @@ You can configure the following environment variables in `wrangler.toml` under t
* Set to `true` to DISABLE the time step drift
* **Note:** This setting only affects the TOTP validation. It does NOT affect the actual TOTP generation.
* **`ATTACHMENT_MAX_BYTES`** (Optional, For attachments only, Default: no limit)
Maximum size for individual attachment files in bytes
* Set to a positive value to limit the size of individual attachment files
* Example: `ATTACHMENT_MAX_BYTES = "104857600"` to limit individual attachment files to 100MB
* **`ATTACHMENT_TOTAL_LIMIT_KB`** (Optional, For attachments only, Default: no limit)
Maximum total attachment storage per user in KB
* Set to a positive value to limit the total attachment storage per user
* Example: `ATTACHMENT_TOTAL_LIMIT_KB = "1048576"` to limit the total attachment storage per user to 1GB
### Scheduled Tasks (Cron)
The worker includes a scheduled task that runs automatically to clean up soft-deleted items. By default, this task runs daily at 03:00 UTC.

View file

@ -0,0 +1,15 @@
-- Create attachments table to store metadata for file uploads.
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY NOT NULL,
cipher_id TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
akey TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
organization_id TEXT,
FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id);

View file

@ -37,6 +37,20 @@ CREATE TABLE IF NOT EXISTS ciphers (
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL
);
-- Attachments table for cipher file metadata
CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY NOT NULL,
cipher_id TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER NOT NULL,
akey TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
organization_id TEXT,
FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id);
-- TwoFactor table for two-factor authentication
-- Types: 0=Authenticator(TOTP), 1=Email, 5=Remember, 8=RecoveryCode
CREATE TABLE IF NOT EXISTS twofactor (

865
src/handlers/attachments.rs Normal file
View file

@ -0,0 +1,865 @@
use std::{collections::HashMap, sync::Arc};
use axum::{
body::Body,
extract::{Multipart, Path, Query, State},
http::{header, HeaderValue, StatusCode},
response::Response,
Json,
};
use chrono::Utc;
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use log;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use uuid::Uuid;
use worker::{query, wasm_bindgen::JsValue, Bucket, D1Database, Env, HttpMetadata};
use crate::{
auth::Claims,
db,
error::AppError,
models::{
attachment::{AttachmentDB, AttachmentResponse},
cipher::{Cipher, CipherDBModel},
},
};
const ATTACHMENTS_BUCKET: &str = "ATTACHMENTS_BUCKET";
const SIZE_LEEWAY_BYTES: i64 = 1024 * 1024; // 1 MiB
const DEFAULT_ATTACHMENT_DOWNLOAD_TTL_SECS: i64 = 300; // 5 minutes
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachmentCreateRequest {
pub key: String,
pub file_name: String,
pub file_size: NumberOrString,
#[serde(default)]
#[allow(dead_code)] // We don't support org features and admin requests
pub admin_request: Option<bool>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AttachmentUploadResponse {
pub object: String,
pub attachment_id: String,
pub url: String,
pub file_upload_type: i32,
#[serde(rename = "cipherResponse")]
pub cipher_response: Cipher,
}
#[derive(Deserialize)]
#[serde(untagged)]
pub enum NumberOrString {
Number(i64),
String(String),
}
#[derive(Debug, Serialize, Deserialize)]
struct AttachmentDownloadClaims {
pub sub: String,
pub cipher_id: String,
pub attachment_id: String,
pub exp: usize,
}
#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct AttachmentDownloadQuery {
pub token: Option<String>,
}
impl NumberOrString {
pub fn into_i64(self) -> Result<i64, AppError> {
match self {
NumberOrString::Number(v) => Ok(v),
NumberOrString::String(v) => v
.parse::<i64>()
.map_err(|_| AppError::BadRequest("Invalid attachment size".to_string())),
}
}
}
async fn touch_cipher_updated_at(db: &D1Database, cipher_id: &str) -> Result<(), AppError> {
let now = now_string();
query!(
db,
"UPDATE ciphers SET updated_at = ?1 WHERE id = ?2",
now,
cipher_id
)
.map_err(|_| AppError::Database)?
.run()
.await?;
Ok(())
}
/// POST /api/ciphers/{cipher_id}/attachment/v2
#[worker::send]
pub async fn create_attachment_v2(
claims: Claims,
State(env): State<Arc<Env>>,
Path(cipher_id): Path<String>,
Json(payload): Json<AttachmentCreateRequest>,
) -> Result<Json<AttachmentUploadResponse>, AppError> {
// Require bucket; fail directly if missing
let _bucket = require_bucket(&env)?;
let db = db::get_db(&env)?;
let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?;
let AttachmentCreateRequest {
key,
file_name,
file_size,
admin_request: _,
} = payload;
let declared_size = file_size.into_i64()?;
if declared_size <= 0 {
return Err(AppError::BadRequest(
"Attachment size must be positive".to_string(),
));
}
enforce_limits(
&db,
&env,
&claims.sub,
declared_size,
None, /* exclude_attachment */
)
.await?;
let attachment_id = Uuid::new_v4().to_string();
let now = now_string();
query!(
&db,
"INSERT INTO attachments (id, cipher_id, file_name, file_size, akey, created_at, updated_at, organization_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7)",
attachment_id,
cipher.id,
file_name,
declared_size,
key,
now,
cipher.organization_id,
)
.map_err(|_| AppError::Database)?
.run()
.await?;
// Return upload URL pointing to local upload endpoint
let url = upload_url(&cipher_id, &attachment_id);
let mut cipher_response: Cipher = cipher.into();
hydrate_cipher_attachments(&db, &env, &mut cipher_response, &claims.sub).await?;
touch_cipher_updated_at(&db, &cipher_id).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(AttachmentUploadResponse {
object: "attachment-fileUpload".to_string(),
attachment_id,
url,
file_upload_type: 0, // Direct
cipher_response,
}))
}
/// POST /api/ciphers/{cipher_id}/attachment/{attachment_id}
#[worker::send]
pub async fn upload_attachment_v2_data(
claims: Claims,
State(env): State<Arc<Env>>,
Path((cipher_id, attachment_id)): Path<(String, String)>,
mut multipart: Multipart,
) -> Result<Json<()>, AppError> {
let bucket = require_bucket(&env)?;
let db = db::get_db(&env)?;
let _cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?;
let mut attachment = fetch_attachment(&db, &attachment_id).await?;
if attachment.cipher_id != cipher_id {
return Err(AppError::BadRequest(
"Attachment does not belong to cipher".to_string(),
));
}
let (file_bytes, content_type, key_override, _file_name) =
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(&attachment, actual_size) {
query!(&db, "DELETE FROM attachments WHERE id = ?1", attachment.id)
.map_err(|_| AppError::Database)?
.run()
.await?;
return Err(e);
}
// Validate capacity limits (replace with actual size)
enforce_limits(&db, &env, &claims.sub, actual_size, Some(&attachment.id)).await?;
// Need a key
if attachment.akey.is_none() && key_override.is_none() {
return Err(AppError::BadRequest(
"No attachment key provided".to_string(),
));
}
if let Some(k) = key_override {
attachment.akey = Some(k);
}
// Save to R2
upload_to_r2(
&bucket,
&attachment.r2_key(),
content_type,
file_bytes.to_vec(),
)
.await?;
// Update metadata
let now = now_string();
query!(
&db,
"UPDATE attachments SET file_size = ?1, akey = COALESCE(?2, akey), updated_at = ?3 WHERE id = ?4",
actual_size,
attachment.akey,
now,
attachment.id,
)
.map_err(|_| AppError::Database)?
.run()
.await?;
touch_cipher_updated_at(&db, &cipher_id).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(()))
}
/// POST /api/ciphers/{cipher_id}/attachment
/// Legacy API for creating an attachment associated with a cipher.
#[worker::send]
pub async fn upload_attachment_legacy(
claims: Claims,
State(env): State<Arc<Env>>,
Path(cipher_id): Path<String>,
mut multipart: Multipart,
) -> Result<Json<Cipher>, AppError> {
let bucket = require_bucket(&env)?;
let db = db::get_db(&env)?;
let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?;
let (file_bytes, content_type, key, file_name) = read_multipart(&mut multipart).await?;
let key = key.ok_or_else(|| AppError::BadRequest("No attachment key provided".to_string()))?;
let file_name =
file_name.ok_or_else(|| AppError::BadRequest("No filename provided".to_string()))?;
let actual_size = file_bytes.len() as i64;
if actual_size <= 0 {
return Err(AppError::BadRequest(
"Attachment size must be positive".to_string(),
));
}
// Validate capacity limits
enforce_limits(&db, &env, &claims.sub, actual_size, None).await?;
let attachment_id = Uuid::new_v4().to_string();
let now = now_string();
query!(
&db,
"INSERT INTO attachments (id, cipher_id, file_name, file_size, akey, created_at, updated_at, organization_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7)",
attachment_id,
cipher.id,
file_name,
actual_size,
key,
now,
cipher.organization_id,
)
.map_err(|_| AppError::Database)?
.run()
.await?;
// Save to R2
upload_to_r2(
&bucket,
&format!("{}/{}", cipher_id, attachment_id),
content_type,
file_bytes.to_vec(),
)
.await?;
touch_cipher_updated_at(&db, &cipher_id).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
// 返回最新的 Cipher含附件
let mut cipher_response: Cipher = cipher.into();
hydrate_cipher_attachments(&db, &env, &mut cipher_response, &claims.sub).await?;
Ok(Json(cipher_response))
}
/// GET /api/ciphers/{cipher_id}/attachment/{attachment_id}
#[worker::send]
pub async fn get_attachment(
claims: Claims,
State(env): State<Arc<Env>>,
Path((cipher_id, attachment_id)): Path<(String, String)>,
) -> Result<Json<AttachmentResponse>, AppError> {
let _bucket = require_bucket(&env)?;
let db = db::get_db(&env)?;
let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?;
let attachment = fetch_attachment(&db, &attachment_id).await?;
if attachment.cipher_id != cipher.id {
return Err(AppError::BadRequest(
"Attachment does not belong to cipher".to_string(),
));
}
let url = download_url(&env, &cipher_id, &attachment_id, &claims.sub)?;
Ok(Json(attachment.to_response(url)))
}
/// DELETE /api/ciphers/{cipher_id}/attachment/{attachment_id}
#[worker::send]
pub async fn delete_attachment(
claims: Claims,
State(env): State<Arc<Env>>,
Path((cipher_id, attachment_id)): Path<(String, String)>,
) -> Result<Json<()>, AppError> {
let bucket = require_bucket(&env)?;
let db = db::get_db(&env)?;
let cipher = ensure_cipher_for_user(&db, &cipher_id, &claims.sub).await?;
let attachment = fetch_attachment(&db, &attachment_id).await?;
if attachment.cipher_id != cipher.id {
return Err(AppError::BadRequest(
"Attachment does not belong to cipher".to_string(),
));
}
// Delete R2 object; ignore missing objects
if let Err(err) = bucket.delete(attachment.r2_key()).await {
let msg = err.to_string();
if !(msg.contains("NoSuchKey") || msg.contains("404") || msg.contains("NotFound")) {
return Err(AppError::Worker(err));
}
}
query!(&db, "DELETE FROM attachments WHERE id = ?1", attachment.id)
.map_err(|_| AppError::Database)?
.run()
.await?;
touch_cipher_updated_at(&db, &cipher_id).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(()))
}
/// POST /api/ciphers/{cipher_id}/attachment/{attachment_id}/delete
/// Legacy API for deleting an attachment associated with a cipher.
#[worker::send]
pub async fn delete_attachment_post(
claims: Claims,
State(env): State<Arc<Env>>,
Path((cipher_id, attachment_id)): Path<(String, String)>,
) -> Result<Json<()>, AppError> {
delete_attachment(claims, State(env), Path((cipher_id, attachment_id))).await
}
/// GET /api/ciphers/{cipher_id}/attachment/{attachment_id}/download
#[worker::send]
pub async fn download_attachment(
State(env): State<Arc<Env>>,
Path((cipher_id, attachment_id)): Path<(String, String)>,
Query(query): Query<AttachmentDownloadQuery>,
) -> Result<Response, AppError> {
let bucket = require_bucket(&env)?;
let db = db::get_db(&env)?;
let token = query
.token
.ok_or_else(|| AppError::Unauthorized("Missing download token".to_string()))?;
let user_id = validate_download_token(&env, &token, &cipher_id, &attachment_id)?;
let cipher = ensure_cipher_for_user(&db, &cipher_id, &user_id).await?;
let attachment = fetch_attachment(&db, &attachment_id).await?;
if attachment.cipher_id != cipher.id {
return Err(AppError::BadRequest(
"Attachment does not belong to cipher".to_string(),
));
}
let object = bucket
.get(attachment.r2_key())
.execute()
.await
.map_err(AppError::Worker)?
.ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))?;
let body = object
.body()
.ok_or_else(|| AppError::NotFound("Attachment data not found".to_string()))?
.bytes()
.await
.map_err(AppError::Worker)?;
let mut builder = Response::builder().status(StatusCode::OK);
builder = builder.header(header::CONTENT_TYPE, "application/octet-stream");
if let Ok(value) = HeaderValue::from_str(&body.len().to_string()) {
builder = builder.header(header::CONTENT_LENGTH, value);
}
builder
.body(Body::from(body))
.map_err(|_| AppError::Internal)
}
/// Attach attachment information to Cipher (used by other handlers)
pub async fn hydrate_cipher_attachments(
db: &D1Database,
env: &Env,
cipher: &mut Cipher,
user_id: &str,
) -> Result<(), AppError> {
if !attachments_enabled(env) {
cipher.attachments = None;
return Ok(());
}
let mut map = load_attachment_map(db, env, &[cipher.id.clone()], user_id).await?;
if let Some(list) = map.remove(&cipher.id) {
if !list.is_empty() {
cipher.attachments = Some(list);
}
}
Ok(())
}
/// Batch attach attachments to multiple Ciphers
pub async fn hydrate_ciphers_attachments(
db: &D1Database,
env: &Env,
ciphers: &mut [Cipher],
user_id: &str,
) -> Result<(), AppError> {
if !attachments_enabled(env) {
for cipher in ciphers.iter_mut() {
cipher.attachments = None;
}
return Ok(());
}
let ids: Vec<String> = ciphers.iter().map(|c| c.id.clone()).collect();
let mut map = load_attachment_map(db, env, &ids, user_id).await?;
for cipher in ciphers.iter_mut() {
if let Some(list) = map.remove(&cipher.id) {
if !list.is_empty() {
cipher.attachments = Some(list);
}
}
}
Ok(())
}
fn attachments_enabled(env: &Env) -> bool {
env.bucket(ATTACHMENTS_BUCKET).is_ok()
}
fn require_bucket(env: &Env) -> Result<Bucket, AppError> {
env.bucket(ATTACHMENTS_BUCKET)
.map_err(|_| AppError::BadRequest("Attachments are not enabled".to_string()))
}
fn upload_url(cipher_id: &str, attachment_id: &str) -> String {
format!("/api/ciphers/{cipher_id}/attachment/{attachment_id}")
}
fn download_url(
env: &Env,
cipher_id: &str,
attachment_id: &str,
user_id: &str,
) -> Result<String, AppError> {
let token = build_download_token(env, user_id, cipher_id, attachment_id)?;
Ok(format!(
"/api/ciphers/{cipher_id}/attachment/{attachment_id}/download?token={token}"
))
}
fn now_string() -> String {
Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()
}
async fn ensure_cipher_for_user(
db: &D1Database,
cipher_id: &str,
user_id: &str,
) -> Result<CipherDBModel, AppError> {
let cipher: Option<CipherDBModel> = db
.prepare("SELECT * FROM ciphers WHERE id = ?1 AND user_id = ?2")
.bind(&[cipher_id.into(), user_id.into()])?
.first(None)
.await
.map_err(|_| AppError::Database)?;
let cipher = cipher.ok_or_else(|| AppError::NotFound("Cipher not found".to_string()))?;
if cipher.organization_id.is_some() {
return Err(AppError::BadRequest(
"Organization attachments are not supported".to_string(),
));
}
if cipher.deleted_at.is_some() {
return Err(AppError::BadRequest("Cipher is deleted".to_string()));
}
Ok(cipher)
}
async fn fetch_attachment(db: &D1Database, attachment_id: &str) -> Result<AttachmentDB, AppError> {
db.prepare("SELECT * FROM attachments WHERE id = ?1")
.bind(&[attachment_id.into()])?
.first(None)
.await
.map_err(|_| AppError::Database)?
.ok_or_else(|| AppError::NotFound("Attachment not found".to_string()))
}
async fn load_attachment_map(
db: &D1Database,
env: &Env,
cipher_ids: &[String],
user_id: &str,
) -> Result<HashMap<String, Vec<AttachmentResponse>>, AppError> {
if cipher_ids.is_empty() {
return Ok(HashMap::new());
}
let ids_json = serde_json::to_string(cipher_ids).map_err(|_| AppError::Internal)?;
let attachments: Vec<AttachmentDB> = db
.prepare("SELECT * FROM attachments WHERE cipher_id IN (SELECT value FROM json_each(?1))")
.bind(&[ids_json.into()])?
.all()
.await
.map_err(|_| AppError::Database)?
.results()
.map_err(|_| AppError::Database)?;
let mut map: HashMap<String, Vec<AttachmentResponse>> = HashMap::new();
for attachment in attachments {
let url = download_url(env, &attachment.cipher_id, &attachment.id, user_id)?;
map.entry(attachment.cipher_id.clone())
.or_default()
.push(attachment.to_response(url));
}
Ok(map)
}
async fn upload_to_r2(
bucket: &Bucket,
key: &str,
content_type: Option<String>,
data: Vec<u8>,
) -> Result<(), AppError> {
let mut builder = bucket.put(key, data);
if let Some(ct) = content_type {
builder = builder.http_metadata(HttpMetadata {
content_type: Some(ct),
..Default::default()
});
}
builder.execute().await.map_err(AppError::Worker)?;
Ok(())
}
async fn read_multipart(
multipart: &mut Multipart,
) -> Result<
(
axum::body::Bytes,
Option<String>,
Option<String>,
Option<String>,
),
AppError,
> {
let mut file_bytes: Option<axum::body::Bytes> = None;
let mut content_type: Option<String> = None;
let mut key: Option<String> = None;
let mut file_name: Option<String> = None;
while let Some(field) = multipart
.next_field()
.await
.map_err(|_| AppError::BadRequest("Invalid multipart data".to_string()))?
{
match field.name() {
Some("data") => {
content_type = field.content_type().map(|s| s.to_string());
file_name = field.file_name().map(|s| s.to_string());
file_bytes =
Some(field.bytes().await.map_err(|_| {
AppError::BadRequest("Failed to read file data".to_string())
})?);
}
Some("key") => {
key = Some(
field
.text()
.await
.map_err(|_| AppError::BadRequest("Invalid key field".to_string()))?,
);
}
_ => {}
}
}
let file_bytes = file_bytes
.ok_or_else(|| AppError::BadRequest("No attachment data provided".to_string()))?;
Ok((file_bytes, content_type, key, file_name))
}
fn validate_size_within_declared(
attachment: &AttachmentDB,
actual_size: i64,
) -> Result<(), AppError> {
let max_size = attachment
.file_size
.checked_add(SIZE_LEEWAY_BYTES)
.ok_or_else(|| AppError::BadRequest("Attachment size overflow".to_string()))?;
let min_size = attachment
.file_size
.checked_sub(SIZE_LEEWAY_BYTES)
.ok_or_else(|| AppError::BadRequest("Attachment size overflow".to_string()))?;
if actual_size < min_size || actual_size > max_size {
return Err(AppError::BadRequest(format!(
"Attachment size mismatch (expected within [{min_size}, {max_size}], got {actual_size})"
)));
}
Ok(())
}
fn build_download_token(
env: &Env,
user_id: &str,
cipher_id: &str,
attachment_id: &str,
) -> Result<String, AppError> {
let ttl_secs = download_ttl_secs(env)?;
let now = Utc::now().timestamp();
let exp = now
.checked_add(ttl_secs)
.ok_or_else(|| AppError::Internal)?;
if exp < 0 {
log::error!(
"Computed negative expiration for attachment token: cipher={}, attachment={}",
cipher_id,
attachment_id
);
return Err(AppError::Internal);
}
let claims = AttachmentDownloadClaims {
sub: user_id.to_string(),
cipher_id: cipher_id.to_string(),
attachment_id: attachment_id.to_string(),
exp: exp as usize,
};
let secret = jwt_secret(env)?;
encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(secret.as_bytes()),
)
.map_err(AppError::from)
}
fn validate_download_token(
env: &Env,
token: &str,
cipher_id: &str,
attachment_id: &str,
) -> Result<String, AppError> {
let secret = jwt_secret(env)?;
let data = decode::<AttachmentDownloadClaims>(
token,
&DecodingKey::from_secret(secret.as_bytes()),
&Validation::default(),
)?;
let claims = data.claims;
if claims.cipher_id != cipher_id || claims.attachment_id != attachment_id {
return Err(AppError::Unauthorized("Invalid download token".to_string()));
}
Ok(claims.sub)
}
fn jwt_secret(env: &Env) -> Result<String, AppError> {
Ok(env.secret("JWT_SECRET")?.to_string())
}
fn download_ttl_secs(env: &Env) -> Result<i64, AppError> {
match env.var("ATTACHMENT_DOWNLOAD_TTL_SECS") {
Ok(v) => {
let raw = v.to_string();
let ttl = raw.parse::<i64>().map_err(|err| {
log::error!("Invalid ATTACHMENT_DOWNLOAD_TTL_SECS '{}': {}", raw, err);
AppError::Internal
})?;
if ttl <= 0 {
log::error!("ATTACHMENT_DOWNLOAD_TTL_SECS '{}' must be positive", raw);
return Err(AppError::Internal);
}
Ok(ttl)
}
Err(_) => Ok(DEFAULT_ATTACHMENT_DOWNLOAD_TTL_SECS),
}
}
async fn enforce_limits(
db: &D1Database,
env: &Env,
user_id: &str,
new_size: i64,
exclude_attachment: Option<&str>,
) -> Result<(), AppError> {
if new_size < 0 {
return Err(AppError::BadRequest(
"Attachment size cannot be negative".to_string(),
));
}
let max_bytes = attachment_max_bytes(env)?;
if let Some(max_bytes) = max_bytes {
if new_size as u64 > max_bytes {
return Err(AppError::BadRequest(
"Attachment size exceeds limit".to_string(),
));
}
}
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?;
let limit = limit_bytes as i64;
let new_total = used
.checked_add(new_size)
.ok_or_else(|| AppError::BadRequest("Attachment size overflow".to_string()))?;
if new_total > limit {
return Err(AppError::BadRequest(
"Attachment storage limit reached".to_string(),
));
}
}
Ok(())
}
fn attachment_max_bytes(env: &Env) -> Result<Option<u64>, AppError> {
match env.var("ATTACHMENT_MAX_BYTES") {
Ok(v) => {
let raw = v.to_string();
raw.parse::<u64>().map(Some).map_err(|err| {
log::error!("Invalid ATTACHMENT_MAX_BYTES '{}': {}", raw, err);
AppError::Internal
})
}
Err(_) => Ok(None),
}
}
fn total_limit_bytes(env: &Env) -> Result<Option<u64>, AppError> {
match env.var("ATTACHMENT_TOTAL_LIMIT_KB") {
Ok(v) => {
let raw = v.to_string();
let kb = raw.parse::<u64>().map_err(|err| {
log::error!("Invalid ATTACHMENT_TOTAL_LIMIT_KB '{}': {}", raw, err);
AppError::Internal
})?;
let bytes = kb.checked_mul(1024).ok_or_else(|| {
log::error!(
"ATTACHMENT_TOTAL_LIMIT_KB '{}' overflowed when converting to bytes",
raw
);
AppError::Internal
})?;
Ok(Some(bytes))
}
Err(_) => Ok(None),
}
}
async fn user_attachment_usage(
db: &D1Database,
user_id: &str,
exclude_attachment: Option<&str>,
) -> Result<i64, AppError> {
let query_str = if exclude_attachment.is_some() {
"SELECT COALESCE(SUM(a.file_size), 0) as total
FROM attachments a
JOIN ciphers c ON c.id = a.cipher_id
WHERE c.user_id = ?1 AND a.id != ?2"
} else {
"SELECT COALESCE(SUM(a.file_size), 0) as total
FROM attachments a
JOIN ciphers c ON c.id = a.cipher_id
WHERE c.user_id = ?1"
};
let mut bindings: Vec<JsValue> = vec![JsValue::from_str(user_id)];
if let Some(id) = exclude_attachment {
bindings.push(JsValue::from_str(id));
}
let row: Option<Value> = db
.prepare(query_str)
.bind(&bindings)?
.first(None)
.await
.map_err(|_| AppError::Database)?;
let total = row
.and_then(|v| v.get("total").cloned())
.and_then(|v| v.as_i64())
.unwrap_or(0);
Ok(total)
}

View file

@ -11,6 +11,7 @@ use worker::{query, D1PreparedStatement, Env};
use crate::auth::Claims;
use crate::db;
use crate::error::AppError;
use crate::handlers::attachments;
use crate::models::cipher::{
Cipher, CipherDBModel, CipherData, CipherListResponse, CipherRequestData, CreateCipherRequest,
MoveCipherData, PartialCipherData,
@ -51,7 +52,7 @@ pub async fn create_cipher(
let data_value = serde_json::to_value(&cipher_data).map_err(|_| AppError::Internal)?;
let cipher = Cipher {
let mut cipher = Cipher {
id: Uuid::new_v4().to_string(),
user_id: Some(claims.sub.clone()),
organization_id: cipher_data_req.organization_id.clone(),
@ -71,6 +72,7 @@ pub async fn create_cipher(
} else {
Some(payload.collection_ids)
},
attachments: None,
};
let data = serde_json::to_string(&cipher.data).map_err(|_| AppError::Internal)?;
@ -93,6 +95,7 @@ pub async fn create_cipher(
.run()
.await?;
attachments::hydrate_cipher_attachments(&db, env.as_ref(), &mut cipher, &claims.sub).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(cipher))
@ -167,7 +170,7 @@ pub async fn update_cipher(
let data_value = serde_json::to_value(&cipher_data).map_err(|_| AppError::Internal)?;
let cipher = Cipher {
let mut cipher = Cipher {
id: id.clone(),
user_id: Some(claims.sub.clone()),
organization_id: cipher_data_req.organization_id.clone(),
@ -183,6 +186,7 @@ pub async fn update_cipher(
edit: true,
view_password: true,
collection_ids: None,
attachments: None,
};
let data = serde_json::to_string(&cipher.data).map_err(|_| AppError::Internal)?;
@ -202,6 +206,7 @@ pub async fn update_cipher(
.run()
.await?;
attachments::hydrate_cipher_attachments(&db, env.as_ref(), &mut cipher, &claims.sub).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(cipher))
@ -226,7 +231,9 @@ pub async fn list_ciphers(
.await?
.results()?;
let ciphers: Vec<Cipher> = ciphers_db.into_iter().map(|c| c.into()).collect();
let mut ciphers: Vec<Cipher> = ciphers_db.into_iter().map(|c| c.into()).collect();
attachments::hydrate_ciphers_attachments(&db, env.as_ref(), &mut ciphers, &claims.sub).await?;
Ok(Json(CipherListResponse {
data: ciphers,
@ -244,7 +251,11 @@ pub async fn get_cipher(
) -> 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()))
let mut cipher: Cipher = cipher.into();
attachments::hydrate_cipher_attachments(&db, env.as_ref(), &mut cipher, &claims.sub).await?;
Ok(Json(cipher))
}
/// GET /api/ciphers/{id}/details
@ -304,8 +315,11 @@ pub async fn update_cipher_partial(
db::touch_user_updated_at(&db, user_id).await?;
let cipher = fetch_cipher_for_user(&db, &id, user_id).await?;
let mut cipher: Cipher = cipher.into();
Ok(Json(cipher.into()))
attachments::hydrate_cipher_attachments(&db, env.as_ref(), &mut cipher, &claims.sub).await?;
Ok(Json(cipher))
}
/// Request body for bulk cipher operations
@ -464,9 +478,12 @@ pub async fn restore_cipher(
.await?
.ok_or(AppError::NotFound("Cipher not found".to_string()))?;
let mut cipher: Cipher = cipher_db.into();
attachments::hydrate_cipher_attachments(&db, env.as_ref(), &mut cipher, &claims.sub).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(cipher_db.into()))
Ok(Json(cipher))
}
/// Response for bulk restore operation
@ -517,7 +534,7 @@ pub async fn restore_ciphers_bulk(
// Batch SELECT using json_each() - avoid N+1 query problem
let ids_json = serde_json::to_string(&ids).map_err(|_| AppError::Internal)?;
let restored_ciphers: Vec<Cipher> = db
let mut restored_ciphers: Vec<Cipher> = db
.prepare(
"SELECT * FROM ciphers WHERE user_id = ?1 AND id IN (SELECT value FROM json_each(?2))",
)
@ -529,6 +546,9 @@ pub async fn restore_ciphers_bulk(
.map(|cipher| cipher.into())
.collect();
attachments::hydrate_ciphers_attachments(&db, env.as_ref(), &mut restored_ciphers, &claims.sub)
.await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(BulkRestoreResponse {
@ -559,7 +579,7 @@ pub async fn create_cipher_simple(
let data_value = serde_json::to_value(&cipher_data).map_err(|_| AppError::Internal)?;
let cipher = Cipher {
let mut cipher = Cipher {
id: Uuid::new_v4().to_string(),
user_id: Some(claims.sub.clone()),
organization_id: payload.organization_id.clone(),
@ -575,6 +595,7 @@ pub async fn create_cipher_simple(
edit: true,
view_password: true,
collection_ids: None,
attachments: None,
};
let data = serde_json::to_string(&cipher.data).map_err(|_| AppError::Internal)?;
@ -596,6 +617,7 @@ pub async fn create_cipher_simple(
.run()
.await?;
attachments::hydrate_cipher_attachments(&db, env.as_ref(), &mut cipher, &claims.sub).await?;
db::touch_user_updated_at(&db, &claims.sub).await?;
Ok(Json(cipher))

View file

@ -149,6 +149,7 @@ pub async fn import_data(
edit: true,
view_password: true,
collection_ids: None,
attachments: None,
};
let data = serde_json::to_string(&cipher.data).map_err(|_| AppError::Internal)?;

View file

@ -1,4 +1,5 @@
pub mod accounts;
pub mod attachments;
pub mod ciphers;
pub mod config;
pub mod devices;

View file

@ -7,6 +7,7 @@ use crate::{
auth::Claims,
db,
error::AppError,
handlers::attachments,
models::{
cipher::{Cipher, CipherDBModel},
folder::{Folder, FolderResponse},
@ -63,6 +64,9 @@ pub async fn get_sync_data(
.map(|cipher| cipher.into())
.collect::<Vec<Cipher>>();
let mut ciphers = ciphers;
attachments::hydrate_ciphers_attachments(&db, env.as_ref(), &mut ciphers, &user_id).await?;
let profile = Profile::from_user(user)?;
let response = SyncResponse {

66
src/models/attachment.rs Normal file
View file

@ -0,0 +1,66 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AttachmentDB {
pub id: String,
pub cipher_id: String,
pub file_name: String,
pub file_size: i64,
#[serde(skip_serializing_if = "Option::is_none")]
pub akey: Option<String>,
pub created_at: String,
pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub organization_id: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AttachmentResponse {
pub id: String,
pub url: String,
pub file_name: String,
pub size: String,
pub size_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
pub object: String,
}
impl AttachmentDB {
pub fn r2_key(&self) -> String {
format!("{}/{}", self.cipher_id, self.id)
}
pub fn to_response(&self, url: String) -> AttachmentResponse {
AttachmentResponse {
id: self.id.clone(),
url,
file_name: self.file_name.clone(),
size: self.file_size.to_string(),
size_name: display_size(self.file_size),
key: self.akey.clone(),
object: "attachment".to_string(),
}
}
}
fn display_size(bytes: i64) -> String {
if bytes < 0 {
return "0 B".to_string();
}
const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
let mut size = bytes as f64;
let mut unit = 0;
while size >= 1024.0 && unit < UNITS.len() - 1 {
size /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{} {}", bytes, UNITS[unit])
} else {
format!("{:.1} {}", size, UNITS[unit])
}
}

View file

@ -1,6 +1,8 @@
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use serde_json::{json, Map, Value};
use crate::models::attachment::AttachmentResponse;
// Cipher types:
// Login = 1,
// SecureNote = 2,
@ -123,6 +125,8 @@ pub struct Cipher {
pub view_password: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub collection_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub attachments: Option<Vec<AttachmentResponse>>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
@ -160,6 +164,7 @@ impl Into<Cipher> for CipherDBModel {
edit: true,
view_password: true,
collection_ids: None,
attachments: None,
}
}
}
@ -198,6 +203,7 @@ impl Serialize for Cipher {
response_map.insert("revisionDate".to_string(), json!(self.updated_at));
response_map.insert("creationDate".to_string(), json!(self.created_at));
response_map.insert("deletedDate".to_string(), json!(self.deleted_at));
response_map.insert("attachments".to_string(), json!(self.attachments));
if let Some(data_obj) = self.data.as_object() {
let data_clone = data_obj.clone();

View file

@ -1,3 +1,4 @@
pub mod attachment;
pub mod cipher;
pub mod folder;
pub mod import;

View file

@ -6,8 +6,8 @@ use std::sync::Arc;
use worker::Env;
use crate::handlers::{
accounts, ciphers, config, devices, emergency_access, folders, identity, import, sync,
twofactor, webauth,
accounts, attachments, ciphers, config, devices, emergency_access, folders, identity, import,
sync, twofactor, webauth,
};
pub fn api_router(env: Env) -> Router {
@ -55,6 +55,35 @@ pub fn api_router(env: Env) -> Router {
"/api/ciphers/{id}/details",
get(ciphers::get_cipher_details),
)
// Attachments
.route(
"/api/ciphers/{id}/attachment/v2",
post(attachments::create_attachment_v2),
)
.route(
"/api/ciphers/{id}/attachment",
post(attachments::upload_attachment_legacy),
)
.route(
"/api/ciphers/{id}/attachment/{attachment_id}",
post(attachments::upload_attachment_v2_data),
)
.route(
"/api/ciphers/{id}/attachment/{attachment_id}",
get(attachments::get_attachment),
)
.route(
"/api/ciphers/{id}/attachment/{attachment_id}",
delete(attachments::delete_attachment),
)
.route(
"/api/ciphers/{id}/attachment/{attachment_id}/download",
get(attachments::download_attachment),
)
.route(
"/api/ciphers/{id}/attachment/{attachment_id}/delete",
post(attachments::delete_attachment_post),
)
.route("/api/ciphers/{id}", put(ciphers::update_cipher))
.route("/api/ciphers/{id}", post(ciphers::update_cipher))
// Cipher soft delete (PUT sets deleted_at timestamp)

View file

@ -35,6 +35,15 @@ run_worker_first = ["/api/*", "/identity/*"]
# Defaults to 30 days if not set. Set to 0 to disable auto-purge.
# TRASH_AUTO_DELETE_DAYS = "30"
# Attachment configuration (optional)
# Maximum size for individual attachment files in bytes.
# Defaults to no limit if not set.
# ATTACHMENT_MAX_BYTES = "104857600" # 100MB
# Maximum total attachment storage per user in KB.
# Defaults to no limit if not set.
# ATTACHMENT_TOTAL_LIMIT_KB = "1048576" # 1GB
# Cron triggers for scheduled tasks
# Runs daily at 03:00 UTC to purge soft-deleted ciphers
[triggers]
@ -46,6 +55,12 @@ 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_buckets]]
# binding = "ATTACHMENTS_BUCKET"
# bucket_name = "warden-attachments"
[env.dev]
name = "warden-worker-dev"
keep_vars = true
@ -65,3 +80,9 @@ binding = "vault1"
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
# [[env.dev.r2_buckets]]
# binding = "ATTACHMENTS_BUCKET"
# bucket_name = "warden-attachments-dev"