feat(v1.1.5): files SDK + files:* triggers

Filesystem-backed blob storage as the fifth concrete trigger kind.

- `files::collection(c).{create,head,get,update,delete,list}` Rhai SDK
  (blob in/out; metadata maps; missing-field throws naming the field).
- `FilesService` trait in picloud-shared; `FsFilesRepo` (atomic
  write: temp→fsync→rename→fsync-dir→DB; single-pass SHA-256;
  checksum-verified reads → Corrupted) + `FilesServiceImpl` in
  manager-core. Metadata in Postgres (0018), bytes on disk under
  PICLOUD_FILES_ROOT with 0o700 shard dirs.
- `files:*` trigger kind via the Layout-E pattern (0019: widen both
  CHECKs + files_trigger_details), TriggerEvent::Files (metadata only,
  no bytes), emit_files fan-out, dispatcher arm, admin endpoint
  POST /triggers/files (reuses validate_trigger_target).
- AppFilesRead/AppFilesWrite capabilities → script:read/script:write
  (seven-scope commitment held). AppPubsubPublish reserved for v1.1.6.
- Admin files API (list + delete) + dashboard Files view per app.

Cross-app isolation keyed on cx.app_id at every layer. ~45 new tests
(service in-memory, fs tempdir, bridge integration). No DB required
for the suite. publish_ephemeral and the orphan sweep stay deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-03 21:18:17 +02:00
parent 03d03ea6e7
commit 6e132b6ee0
29 changed files with 3599 additions and 31 deletions

View File

@@ -78,6 +78,17 @@ pub enum Capability {
/// so the conservative write mapping is correct. Splitting
/// read/write is a v1.2+ refinement. Granted to `editor`+.
AppHttpRequest(AppId),
/// Read blobs from this app's files store (v1.1.5). Same trust
/// shape as KV/docs read — granted to `viewer`+, maps to
/// `script:read` on API keys. Honors the seven-scope commitment.
AppFilesRead(AppId),
/// Write blobs to this app's files store (v1.1.5). Granted to
/// `editor`+, maps to `script:write` on API keys.
AppFilesWrite(AppId),
/// Publish a durable pub/sub message from a script in this app
/// (v1.1.5). Maps to `script:write` on API keys (a publish is a
/// write that fans out to subscribers). Granted to `editor`+.
AppPubsubPublish(AppId),
/// Create / list / delete triggers for this app (v1.1.1). Maps to
/// `app:admin` on API keys — triggers are app-configuration acts
/// rather than data-plane access. Granted to `app_admin`+.
@@ -108,6 +119,9 @@ impl Capability {
| Self::AppDocsRead(id)
| Self::AppDocsWrite(id)
| Self::AppHttpRequest(id)
| Self::AppFilesRead(id)
| Self::AppFilesWrite(id)
| Self::AppPubsubPublish(id)
| Self::AppManageTriggers(id)
| Self::AppDeadLetterManage(id) => Some(id),
}
@@ -124,11 +138,16 @@ impl Capability {
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
Scope::InstanceAdmin
}
Self::AppRead(_) | Self::AppKvRead(_) | Self::AppDocsRead(_) => Scope::ScriptRead,
Self::AppRead(_)
| Self::AppKvRead(_)
| Self::AppDocsRead(_)
| Self::AppFilesRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_)
| Self::AppKvWrite(_)
| Self::AppDocsWrite(_)
| Self::AppHttpRequest(_) => Scope::ScriptWrite,
| Self::AppHttpRequest(_)
| Self::AppFilesWrite(_)
| Self::AppPubsubPublish(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
Self::AppAdmin(_) | Self::AppManageTriggers(_) | Self::AppDeadLetterManage(_) => {
@@ -277,6 +296,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppLogRead(_)
| Capability::AppKvRead(_)
| Capability::AppDocsRead(_)
| Capability::AppFilesRead(_)
);
let in_editor = in_viewer
|| matches!(
@@ -286,6 +306,8 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
| Capability::AppKvWrite(_)
| Capability::AppDocsWrite(_)
| Capability::AppHttpRequest(_)
| Capability::AppFilesWrite(_)
| Capability::AppPubsubPublish(_)
);
let in_app_admin = in_editor
|| matches!(

View File

@@ -166,7 +166,8 @@ impl Dispatcher {
OutboxSourceKind::Kv
| OutboxSourceKind::Docs
| OutboxSourceKind::DeadLetter
| OutboxSourceKind::Cron => {
| OutboxSourceKind::Cron
| OutboxSourceKind::Files => {
let resolved = self.resolve_trigger(&row).await?;
let req = match self.build_exec_request(&row, &resolved).await {
Ok(req) => req,

View File

@@ -0,0 +1,215 @@
//! `/api/v1/admin/apps/{id}/files*` — minimal files admin endpoints
//! backing the dashboard's files view (v1.1.5).
//!
//! Two operations only, both operator-facing:
//! * `GET /apps/{id}/files?collection=<c>&cursor=&limit=` — list file
//! metadata for a collection (cursor-paginated).
//! * `DELETE /apps/{id}/files/{collection}/{file_id}` — remove a file.
//!
//! These talk to the `FilesRepo` directly (like `triggers_api` talks to
//! `TriggerRepo`), guarded by the same capability model as the SDK
//! (`AppFilesRead` / `AppFilesWrite`). **Admin deletes do NOT emit a
//! `files:delete` trigger event** — they're operator cleanup actions,
//! not script mutations (see HANDBACK §7). The capability binds to the
//! resource's `app_id` after the app is loaded.
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get};
use axum::{Extension, Router};
use picloud_shared::{AppId, Principal};
use serde::{Deserialize, Serialize};
use serde_json::json;
use uuid::Uuid;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::files_repo::{FilesRepo, FilesRepoError};
#[derive(Clone)]
pub struct FilesAdminState {
pub files: Arc<dyn FilesRepo>,
pub apps: Arc<dyn AppRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn files_admin_router(state: FilesAdminState) -> Router {
Router::new()
.route("/apps/{app_id}/files", get(list_files))
.route(
"/apps/{app_id}/files/{collection}/{file_id}",
delete(delete_file),
)
.with_state(state)
}
#[derive(Debug, Deserialize)]
pub struct ListFilesQuery {
pub collection: String,
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub limit: Option<u32>,
}
#[derive(Debug, Serialize)]
struct FileMetaDto {
id: String,
collection: String,
name: String,
content_type: String,
size: u64,
checksum: String,
created_at: String,
updated_at: String,
}
#[derive(Debug, Serialize)]
struct ListFilesResponse {
files: Vec<FileMetaDto>,
next_cursor: Option<String>,
}
async fn list_files(
State(s): State<FilesAdminState>,
Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>,
Query(q): Query<ListFilesQuery>,
) -> Result<Json<ListFilesResponse>, FilesApiError> {
ensure_app_exists(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppFilesRead(app_id),
)
.await?;
if q.collection.trim().is_empty() {
return Err(FilesApiError::Invalid(
"collection must not be empty".into(),
));
}
let page = s
.files
.list(
app_id,
&q.collection,
q.cursor.as_deref(),
q.limit.unwrap_or(0),
)
.await?;
let files = page
.files
.into_iter()
.map(|m| FileMetaDto {
id: m.id.to_string(),
collection: m.collection,
name: m.name,
content_type: m.content_type,
size: m.size,
checksum: m.checksum,
created_at: m.created_at.to_rfc3339(),
updated_at: m.updated_at.to_rfc3339(),
})
.collect();
Ok(Json(ListFilesResponse {
files,
next_cursor: page.next_cursor,
}))
}
async fn delete_file(
State(s): State<FilesAdminState>,
Extension(principal): Extension<Principal>,
Path((app_id, collection, file_id)): Path<(AppId, String, String)>,
) -> Result<StatusCode, FilesApiError> {
ensure_app_exists(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppFilesWrite(app_id),
)
.await?;
let id = Uuid::parse_str(&file_id).map_err(|_| FilesApiError::NotFound)?;
if s.files.delete(app_id, &collection, id).await?.is_none() {
return Err(FilesApiError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
async fn ensure_app_exists(apps: &dyn AppRepository, app_id: AppId) -> Result<(), FilesApiError> {
apps.get_by_id(app_id)
.await
.map_err(|e| FilesApiError::Backend(e.to_string()))?
.ok_or(FilesApiError::AppNotFound)?;
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum FilesApiError {
#[error("app not found")]
AppNotFound,
#[error("file not found")]
NotFound,
#[error("invalid request: {0}")]
Invalid(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("files backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for FilesApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for FilesApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl From<FilesRepoError> for FilesApiError {
fn from(e: FilesRepoError) -> Self {
Self::Backend(e.to_string())
}
}
impl IntoResponse for FilesApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound | Self::NotFound => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::Invalid(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "files admin authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "files admin backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -0,0 +1,759 @@
//! `FilesRepo` — the metadata row (Postgres) + blob bytes (filesystem)
//! storage layer for the v1.1.5 `files::*` SDK.
//!
//! Unlike KV/docs, this repo owns BOTH halves of a file: the `files`
//! row (metadata + SHA-256 checksum) and the bytes on disk at
//! `<root>/files/<app_id>/<collection>/<id[0:2]>/<id>`.
//! It owns both because the write must be atomic across them — a crash
//! mid-write must never leave a readable half-written file.
//!
//! ## Atomic write protocol (`create` / `update`)
//! 1. Validate (collection path-safety; caps live one layer up).
//! 2. `create_dir_all` the shard dir with `0o700`.
//! 3. SHA-256 the in-memory bytes (single pass) while writing to
//! `<final>.tmp.<unique>`.
//! 4. `fsync` the temp file.
//! 5. `rename` temp → final (atomic on POSIX).
//! 6. `fsync` the parent dir (so the rename is durable).
//! 7. INSERT / UPDATE the DB row.
//!
//! A crash between 15 leaves an orphan `*.tmp.*` (never read). A crash
//! between 57 leaves a file with no row — never reachable via the SDK
//! (reads start from the row). Both are reclaimed by a future orphan
//! sweep (deferred to v1.1.6+; see HANDBACK §7).
//!
//! ## Atomic delete protocol
//! 1. SELECT + DELETE the row inside one transaction; commit.
//! 2. `unlink` the file (outside the tx). A failure here leaves an
//! orphan; a failure before the commit changes nothing.
//!
//! ## Checksum-on-read
//! `get` reads the file, hashes it, and compares against the stored
//! checksum — returning `FilesError::Corrupted` (and logging the path
//! at error level) on a mismatch. It never auto-deletes; the operator
//! decides what to do with a metadata-vs-bytes divergence.
use std::env;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use picloud_shared::{AppId, FileMeta, FileUpdate, FilesListPage, NewFile};
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use uuid::Uuid;
/// 100 MB default per-file cap.
pub const DEFAULT_MAX_FILE_SIZE_BYTES: usize = 100 * 1024 * 1024;
/// Default filesystem root (relative to the process CWD).
pub const DEFAULT_FILES_ROOT: &str = "./data";
const FILES_LIST_MAX_LIMIT: u32 = 1_000;
const FILES_LIST_DEFAULT_LIMIT: u32 = 100;
/// Monotonic counter feeding unique temp-file suffixes (combined with
/// the pid). Avoids `rand` in the storage layer per the brief.
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, thiserror::Error)]
pub enum FilesRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("filesystem error: {0}")]
Io(String),
#[error("invalid collection name: {0}")]
InvalidCollection(String),
/// The bytes on disk no longer match the stored checksum (or are
/// missing entirely while the row persists).
#[error("file content corrupted (checksum mismatch)")]
Corrupted,
#[error("invalid pagination cursor")]
InvalidCursor,
}
/// Outbound-files tunables. Env-overridable following the same pattern
/// as `HttpConfig::from_env`.
#[derive(Debug, Clone)]
pub struct FilesConfig {
pub root: PathBuf,
pub max_file_size_bytes: usize,
}
impl FilesConfig {
#[must_use]
pub fn conservative() -> Self {
Self {
root: PathBuf::from(DEFAULT_FILES_ROOT),
max_file_size_bytes: DEFAULT_MAX_FILE_SIZE_BYTES,
}
}
#[must_use]
pub fn from_env() -> Self {
let mut c = Self::conservative();
if let Ok(v) = env::var("PICLOUD_FILES_ROOT") {
if !v.trim().is_empty() {
c.root = PathBuf::from(v);
}
}
if let Ok(v) = env::var("PICLOUD_FILES_MAX_FILE_SIZE_BYTES") {
match v.parse::<usize>() {
Ok(n) => c.max_file_size_bytes = n,
Err(e) => {
tracing::warn!(error = %e, "ignoring invalid PICLOUD_FILES_MAX_FILE_SIZE_BYTES");
}
}
}
c
}
}
impl Default for FilesConfig {
fn default() -> Self {
Self::conservative()
}
}
/// The new+prior metadata returned from a successful `update`, so the
/// service can emit a `ServiceEvent` with the change-data-capture
/// surface (`old_payload`).
#[derive(Debug, Clone)]
pub struct FileUpdated {
pub new: FileMeta,
pub prev: FileMeta,
}
#[async_trait]
pub trait FilesRepo: Send + Sync {
async fn create(
&self,
app_id: AppId,
collection: &str,
new: NewFile,
) -> Result<FileMeta, FilesRepoError>;
async fn head(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError>;
/// Reads + checksum-verifies the bytes. `Ok(None)` when no row
/// exists; `Err(Corrupted)` when the row exists but the bytes are
/// missing or mismatched.
async fn get(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<Vec<u8>>, FilesRepoError>;
/// `Ok(None)` when no row exists (the SDK turns this into
/// `FilesError::NotFound`).
async fn update(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
upd: FileUpdate,
) -> Result<Option<FileUpdated>, FilesRepoError>;
/// Returns the deleted row's metadata if present, `None` otherwise.
async fn delete(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError>;
async fn list(
&self,
app_id: AppId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, FilesRepoError>;
}
/// Filesystem-bytes + Postgres-metadata repo.
pub struct FsFilesRepo {
pool: PgPool,
config: FilesConfig,
}
impl FsFilesRepo {
#[must_use]
pub fn new(pool: PgPool, config: FilesConfig) -> Self {
Self { pool, config }
}
/// Defensive path-component guard. The service already validates the
/// collection at the SDK boundary; this is belt-and-suspenders so a
/// future caller can't smuggle a traversal sequence onto disk.
fn guard_collection(collection: &str) -> Result<(), FilesRepoError> {
if collection.is_empty()
|| collection.contains('/')
|| collection.contains('\\')
|| collection.contains("..")
|| collection.contains('\0')
{
return Err(FilesRepoError::InvalidCollection(collection.to_string()));
}
Ok(())
}
fn final_path(&self, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
final_path_at(&self.config.root, app_id, collection, id)
}
fn write_atomic(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
bytes: &[u8],
) -> Result<String, FilesRepoError> {
write_atomic_at(&self.config.root, app_id, collection, id, bytes)
}
}
fn shard_dir_at(root: &Path, app_id: AppId, collection: &str, id_str: &str) -> PathBuf {
root.join("files")
.join(app_id.into_inner().to_string())
.join(collection)
.join(&id_str[..2])
}
fn final_path_at(root: &Path, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
let id_str = id.to_string();
shard_dir_at(root, app_id, collection, &id_str).join(&id_str)
}
/// Steps 26 of the atomic-write protocol. Returns the lowercase hex
/// SHA-256 of the bytes (computed in a single pass over the in-memory
/// buffer — the file is never re-read). Free function so the fs
/// mechanics are unit-testable without a Postgres pool.
fn write_atomic_at(
root: &Path,
app_id: AppId,
collection: &str,
id: Uuid,
bytes: &[u8],
) -> Result<String, FilesRepoError> {
use std::io::Write as _;
let id_str = id.to_string();
let dir = shard_dir_at(root, app_id, collection, &id_str);
create_dir_all_secure(&dir)?;
// Single-pass checksum over the in-memory buffer.
let mut hasher = Sha256::new();
hasher.update(bytes);
let checksum = hex_lower(&hasher.finalize());
let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp = dir.join(format!("{id_str}.tmp.{}-{seq}", std::process::id()));
let final_path = dir.join(&id_str);
{
let mut f = std::fs::File::create(&tmp).map_err(io_err)?;
f.write_all(bytes).map_err(io_err)?;
f.sync_all().map_err(io_err)?; // fsync temp
}
std::fs::rename(&tmp, &final_path).map_err(io_err)?; // atomic
// fsync the parent dir so the rename is durable.
if let Ok(dirf) = std::fs::File::open(&dir) {
let _ = dirf.sync_all();
}
Ok(checksum)
}
/// Read + checksum-verify the bytes at the given path-set. Free
/// function mirror of the `get` read path. Returns `Corrupted` when the
/// bytes are missing or don't match `expected_checksum`.
fn read_verify_at(
root: &Path,
app_id: AppId,
collection: &str,
id: Uuid,
expected_checksum: &str,
) -> Result<Vec<u8>, FilesRepoError> {
let path = final_path_at(root, app_id, collection, id);
let bytes = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
tracing::error!(
path = %path.display(), error = %e,
"files: row exists but bytes are unreadable — treating as corrupted"
);
return Err(FilesRepoError::Corrupted);
}
};
let mut hasher = Sha256::new();
hasher.update(&bytes);
let actual = hex_lower(&hasher.finalize());
if actual != expected_checksum {
tracing::error!(
path = %path.display(), expected = %expected_checksum, actual = %actual,
"files: checksum mismatch on read — content corrupted"
);
return Err(FilesRepoError::Corrupted);
}
Ok(bytes)
}
#[async_trait]
impl FilesRepo for FsFilesRepo {
async fn create(
&self,
app_id: AppId,
collection: &str,
new: NewFile,
) -> Result<FileMeta, FilesRepoError> {
Self::guard_collection(collection)?;
let id = Uuid::new_v4();
let size = i64::try_from(new.data.len()).unwrap_or(i64::MAX);
let checksum = self.write_atomic(app_id, collection, id, &new.data)?;
let row: FileRow = sqlx::query_as(
"INSERT INTO files \
(app_id, collection, id, name, content_type, size_bytes, checksum_sha256) \
VALUES ($1, $2, $3, $4, $5, $6, $7) \
RETURNING id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(collection)
.bind(id)
.bind(&new.name)
.bind(&new.content_type)
.bind(size)
.bind(&checksum)
.fetch_one(&self.pool)
.await?;
Ok(row.into_meta())
}
async fn head(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError> {
let row: Option<FileRow> = sqlx::query_as(
"SELECT id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at \
FROM files WHERE app_id = $1 AND collection = $2 AND id = $3",
)
.bind(app_id.into_inner())
.bind(collection)
.bind(id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(FileRow::into_meta))
}
async fn get(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<Vec<u8>>, FilesRepoError> {
let row: Option<(String,)> = sqlx::query_as(
"SELECT checksum_sha256 FROM files \
WHERE app_id = $1 AND collection = $2 AND id = $3",
)
.bind(app_id.into_inner())
.bind(collection)
.bind(id)
.fetch_optional(&self.pool)
.await?;
let Some((stored_checksum,)) = row else {
return Ok(None);
};
let bytes = read_verify_at(&self.config.root, app_id, collection, id, &stored_checksum)?;
Ok(Some(bytes))
}
async fn update(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
upd: FileUpdate,
) -> Result<Option<FileUpdated>, FilesRepoError> {
Self::guard_collection(collection)?;
// Read the prior row first (existence check + CDC surface).
let Some(prev) = self.head(app_id, collection, id).await? else {
return Ok(None);
};
let size = i64::try_from(upd.data.len()).unwrap_or(i64::MAX);
let checksum = self.write_atomic(app_id, collection, id, &upd.data)?;
let row: FileRow = sqlx::query_as(
"UPDATE files SET \
name = COALESCE($4, name), \
content_type = COALESCE($5, content_type), \
size_bytes = $6, \
checksum_sha256 = $7, \
updated_at = NOW() \
WHERE app_id = $1 AND collection = $2 AND id = $3 \
RETURNING id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(collection)
.bind(id)
.bind(upd.name.as_deref())
.bind(upd.content_type.as_deref())
.bind(size)
.bind(&checksum)
.fetch_one(&self.pool)
.await?;
Ok(Some(FileUpdated {
new: row.into_meta(),
prev,
}))
}
async fn delete(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError> {
// SELECT + DELETE in one tx; unlink afterwards (outside the tx).
let mut tx = self.pool.begin().await?;
let row: Option<FileRow> = sqlx::query_as(
"SELECT id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at \
FROM files WHERE app_id = $1 AND collection = $2 AND id = $3 \
FOR UPDATE",
)
.bind(app_id.into_inner())
.bind(collection)
.bind(id)
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
tx.rollback().await?;
return Ok(None);
};
sqlx::query("DELETE FROM files WHERE app_id = $1 AND collection = $2 AND id = $3")
.bind(app_id.into_inner())
.bind(collection)
.bind(id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
// Row is gone; unlink the bytes. A failure here leaves an orphan
// file (reclaimed by a future sweep) — not fatal.
let path = self.final_path(app_id, collection, id);
if let Err(e) = std::fs::remove_file(&path) {
if e.kind() != std::io::ErrorKind::NotFound {
tracing::warn!(path = %path.display(), error = %e, "files: unlink after delete failed (orphan)");
}
}
Ok(Some(row.into_meta()))
}
async fn list(
&self,
app_id: AppId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, FilesRepoError> {
let limit = if limit == 0 {
FILES_LIST_DEFAULT_LIMIT
} else {
limit.min(FILES_LIST_MAX_LIMIT)
};
let last_id = match cursor {
Some(c) => Some(decode_cursor(c)?),
None => None,
};
let take = i64::from(limit) + 1;
let rows: Vec<FileRow> = sqlx::query_as(
"SELECT id, collection, name, content_type, size_bytes, \
checksum_sha256, created_at, updated_at \
FROM files \
WHERE app_id = $1 AND collection = $2 \
AND ($3::uuid IS NULL OR id > $3) \
ORDER BY id ASC \
LIMIT $4",
)
.bind(app_id.into_inner())
.bind(collection)
.bind(last_id)
.bind(take)
.fetch_all(&self.pool)
.await?;
let mut files: Vec<FileMeta> = rows.into_iter().map(FileRow::into_meta).collect();
let next_cursor = if files.len() > limit as usize {
files.truncate(limit as usize);
files.last().map(|m| encode_cursor(m.id))
} else {
None
};
Ok(FilesListPage { files, next_cursor })
}
}
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
fn io_err(e: std::io::Error) -> FilesRepoError {
FilesRepoError::Io(e.to_string())
}
/// `create_dir_all` with `0o700` on the created tree (Unix). On other
/// platforms it falls back to the default permissions.
fn create_dir_all_secure(dir: &Path) -> Result<(), FilesRepoError> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)
.map_err(io_err)
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(dir).map_err(io_err)
}
}
fn hex_lower(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
}
s
}
fn encode_cursor(last_id: Uuid) -> String {
URL_SAFE_NO_PAD.encode(last_id.to_string().as_bytes())
}
fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
let bytes = URL_SAFE_NO_PAD
.decode(cursor)
.map_err(|_| FilesRepoError::InvalidCursor)?;
let s = String::from_utf8(bytes).map_err(|_| FilesRepoError::InvalidCursor)?;
Uuid::parse_str(&s).map_err(|_| FilesRepoError::InvalidCursor)
}
#[derive(sqlx::FromRow)]
struct FileRow {
id: Uuid,
collection: String,
name: String,
content_type: String,
size_bytes: i64,
checksum_sha256: String,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
}
impl FileRow {
fn into_meta(self) -> FileMeta {
FileMeta {
id: self.id,
collection: self.collection,
name: self.name,
content_type: self.content_type,
size: u64::try_from(self.size_bytes).unwrap_or(0),
checksum: self.checksum_sha256,
created_at: self.created_at,
updated_at: self.updated_at,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_lower_matches_known_sha256_vector() {
// SHA-256("abc") — NIST known-answer vector.
let mut h = Sha256::new();
h.update(b"abc");
assert_eq!(
hex_lower(&h.finalize()),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn hex_lower_of_empty_is_known_vector() {
let mut h = Sha256::new();
h.update(b"");
assert_eq!(
hex_lower(&h.finalize()),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
}
#[test]
fn cursor_round_trips() {
let id = Uuid::new_v4();
let enc = encode_cursor(id);
assert_eq!(decode_cursor(&enc).unwrap(), id);
assert!(matches!(
decode_cursor("!!not-base64!!"),
Err(FilesRepoError::InvalidCursor)
));
}
#[test]
fn guard_collection_rejects_traversal() {
assert!(FsFilesRepo::guard_collection("avatars").is_ok());
assert!(FsFilesRepo::guard_collection("a/b").is_err());
assert!(FsFilesRepo::guard_collection("..").is_err());
assert!(FsFilesRepo::guard_collection("a..b").is_err());
assert!(FsFilesRepo::guard_collection("").is_err());
assert!(FsFilesRepo::guard_collection("a\0b").is_err());
}
#[test]
fn config_from_env_defaults_are_conservative() {
let c = FilesConfig::conservative();
assert_eq!(c.max_file_size_bytes, DEFAULT_MAX_FILE_SIZE_BYTES);
assert_eq!(c.root, PathBuf::from(DEFAULT_FILES_ROOT));
}
// ------------------------------------------------------------------
// Tempdir-backed filesystem mechanics — exercise the atomic write,
// single-pass checksum, and checksum-on-read tamper detection
// without needing a Postgres pool.
// ------------------------------------------------------------------
use picloud_shared::AppId;
/// Process-unique scratch dir under the system temp dir. Cleaned up
/// by each test via `remove_dir_all`.
fn unique_tmp_root() -> PathBuf {
let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("picloud-files-test-{}-{seq}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn write_atomic_then_read_verify_round_trips() {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
let bytes = b"hello picloud files".to_vec();
let checksum = write_atomic_at(&root, app, "avatars", id, &bytes).unwrap();
// Single-pass checksum matches an independent hash of the bytes.
let mut h = Sha256::new();
h.update(&bytes);
assert_eq!(checksum, hex_lower(&h.finalize()));
let read = read_verify_at(&root, app, "avatars", id, &checksum).unwrap();
assert_eq!(read, bytes);
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn read_verify_detects_tampering_as_corrupted() {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
let checksum = write_atomic_at(&root, app, "c", id, b"original").unwrap();
// Mutate the bytes behind the repo's back.
let path = final_path_at(&root, app, "c", id);
std::fs::write(&path, b"tampered").unwrap();
let err = read_verify_at(&root, app, "c", id, &checksum).unwrap_err();
assert!(matches!(err, FilesRepoError::Corrupted));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn read_verify_missing_bytes_is_corrupted() {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
// No write — the file never existed.
let err = read_verify_at(&root, app, "c", id, "deadbeef").unwrap_err();
assert!(matches!(err, FilesRepoError::Corrupted));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn atomic_write_leaves_no_tmp_file_after_success() {
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
write_atomic_at(&root, app, "c", id, b"data").unwrap();
let id_str = id.to_string();
let dir = shard_dir_at(&root, app, "c", &id_str);
let entries: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
// Exactly the final file is visible — no `*.tmp.*` orphan.
assert_eq!(entries, vec![id_str]);
assert!(!entries.iter().any(|n| n.contains(".tmp.")));
std::fs::remove_dir_all(&root).ok();
}
#[test]
fn id_shard_uses_first_two_chars() {
let root = PathBuf::from("/tmp/x");
let app = AppId::new();
let id = Uuid::new_v4();
let id_str = id.to_string();
let path = final_path_at(&root, app, "col", id);
let shard = &id_str[..2];
assert!(path
.to_string_lossy()
.contains(&format!("/col/{shard}/{id_str}")));
}
#[cfg(unix)]
#[test]
fn shard_tree_created_with_0700() {
use std::os::unix::fs::PermissionsExt as _;
let root = unique_tmp_root();
let app = AppId::new();
let id = Uuid::new_v4();
write_atomic_at(&root, app, "c", id, b"data").unwrap();
let id_str = id.to_string();
let dir = shard_dir_at(&root, app, "c", &id_str);
let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700, "shard dir should be 0o700");
std::fs::remove_dir_all(&root).ok();
}
}

View File

@@ -0,0 +1,817 @@
//! `FilesServiceImpl` — wires the `FilesRepo` underneath the
//! `picloud_shared::FilesService` trait scripts see via the Rhai
//! bridge.
//!
//! Layers added here (vs the raw repo), mirroring `KvServiceImpl`:
//! 1. Collection validation (empty + path-traversal) and field /
//! size-cap validation at the SDK boundary.
//! 2. **Script-as-gate authz**: when `cx.principal.is_some()` we run
//! `authz::require(...)`; when it's `None` (public HTTP) we skip.
//! Cross-app isolation is unaffected — every repo call is keyed by
//! `cx.app_id`, never an argument.
//! 3. `ServiceEvent` emission after each mutation (`create` /
//! `update` / `delete`). The payload is the file **metadata**, not
//! the blob bytes (files are too big for trigger payloads).
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
validate_files_collection, FileMeta, FileUpdate, FilesError, FilesListPage, FilesService,
NewFile, SdkCallCx, ServiceEvent, ServiceEventEmitter,
};
use uuid::Uuid;
use crate::authz::{self, AuthzRepo, Capability};
use crate::files_repo::{FileUpdated, FilesRepo, FilesRepoError};
pub struct FilesServiceImpl {
repo: Arc<dyn FilesRepo>,
authz: Arc<dyn AuthzRepo>,
events: Arc<dyn ServiceEventEmitter>,
max_file_size_bytes: usize,
}
impl FilesServiceImpl {
#[must_use]
pub fn new(
repo: Arc<dyn FilesRepo>,
authz: Arc<dyn AuthzRepo>,
events: Arc<dyn ServiceEventEmitter>,
max_file_size_bytes: usize,
) -> Self {
Self {
repo,
authz,
events,
max_file_size_bytes,
}
}
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
if let Some(ref principal) = cx.principal {
authz::require(&*self.authz, principal, Capability::AppFilesRead(cx.app_id))
.await
.map_err(|_| FilesError::Forbidden)?;
}
Ok(())
}
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), FilesError> {
if let Some(ref principal) = cx.principal {
authz::require(
&*self.authz,
principal,
Capability::AppFilesWrite(cx.app_id),
)
.await
.map_err(|_| FilesError::Forbidden)?;
}
Ok(())
}
/// Best-effort `ServiceEvent` emission. A failed emit is logged but
/// never rolls back the (already-durable) file write.
async fn emit(
&self,
cx: &SdkCallCx,
op: &'static str,
collection: &str,
meta: &FileMeta,
old: Option<&FileMeta>,
) {
let payload = serde_json::to_value(meta).ok();
let old_payload = old.and_then(|m| serde_json::to_value(m).ok());
if let Err(e) = self
.events
.emit(
cx,
ServiceEvent {
source: "files",
op,
collection: Some(collection.to_string()),
key: Some(meta.id.to_string()),
payload,
old_payload,
},
)
.await
{
tracing::warn!(error = %e, source = "files", op, "event emit failed");
}
}
}
/// Parse a script-supplied id. Invalid UUIDs aren't an error shape the
/// SDK exposes — for reads/deletes they simply mean "no such file".
fn parse_id(id: &str) -> Option<Uuid> {
Uuid::parse_str(id).ok()
}
impl From<FilesRepoError> for FilesError {
fn from(e: FilesRepoError) -> Self {
match e {
FilesRepoError::Corrupted => Self::Corrupted,
FilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
other => Self::Backend(other.to_string()),
}
}
}
#[async_trait]
impl FilesService for FilesServiceImpl {
async fn create(
&self,
cx: &SdkCallCx,
collection: &str,
new: NewFile,
) -> Result<Uuid, FilesError> {
validate_files_collection(collection)?;
self.check_write(cx).await?;
new.validate(self.max_file_size_bytes)?;
let meta = self.repo.create(cx.app_id, collection, new).await?;
self.emit(cx, "create", collection, &meta, None).await;
Ok(meta.id)
}
async fn head(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<FileMeta>, FilesError> {
validate_files_collection(collection)?;
self.check_read(cx).await?;
let Some(uuid) = parse_id(id) else {
return Ok(None);
};
Ok(self.repo.head(cx.app_id, collection, uuid).await?)
}
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<Vec<u8>>, FilesError> {
validate_files_collection(collection)?;
self.check_read(cx).await?;
let Some(uuid) = parse_id(id) else {
return Ok(None);
};
Ok(self.repo.get(cx.app_id, collection, uuid).await?)
}
async fn update(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
upd: FileUpdate,
) -> Result<(), FilesError> {
validate_files_collection(collection)?;
self.check_write(cx).await?;
upd.validate(self.max_file_size_bytes)?;
let Some(uuid) = parse_id(id) else {
return Err(FilesError::NotFound);
};
match self.repo.update(cx.app_id, collection, uuid, upd).await? {
Some(FileUpdated { new, prev }) => {
self.emit(cx, "update", collection, &new, Some(&prev)).await;
Ok(())
}
None => Err(FilesError::NotFound),
}
}
async fn delete(&self, cx: &SdkCallCx, collection: &str, id: &str) -> Result<bool, FilesError> {
validate_files_collection(collection)?;
self.check_write(cx).await?;
let Some(uuid) = parse_id(id) else {
return Ok(false);
};
match self.repo.delete(cx.app_id, collection, uuid).await? {
Some(meta) => {
// On delete, the top-level metadata AND `prev` both carry
// the deleted row (per docs/v1.1.x design + the brief).
self.emit(cx, "delete", collection, &meta, Some(&meta))
.await;
Ok(true)
}
None => Ok(false),
}
}
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, FilesError> {
validate_files_collection(collection)?;
self.check_read(cx).await?;
Ok(self.repo.list(cx.app_id, collection, cursor, limit).await?)
}
}
// ----------------------------------------------------------------------------
// Tests — in-memory FilesRepo so unit tests need neither Postgres nor a
// filesystem. The on-disk atomic-write / checksum mechanics are covered
// by the tempdir tests in `files_repo.rs`.
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::authz::{AuthzError, AuthzRepo};
use async_trait::async_trait;
use chrono::Utc;
use picloud_shared::{
AdminUserId, AppId, AppRole, EmitError, ExecutionId, InstanceRole, Principal, RequestId,
ScriptId, ServiceEvent, UserId,
};
use std::collections::BTreeMap;
use std::sync::Mutex as StdMutex;
use tokio::sync::Mutex;
/// In-memory FilesRepo keyed by (app, collection, id). Stores the
/// metadata + bytes together so cross-app isolation and round-trips
/// can be checked without disk.
#[derive(Default)]
struct InMemoryFilesRepo {
#[allow(clippy::type_complexity)]
data: Mutex<BTreeMap<(AppId, String, Uuid), (FileMeta, Vec<u8>)>>,
}
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
let mut h = Sha256::new();
h.update(bytes);
let out = h.finalize();
let mut s = String::new();
for b in out {
use std::fmt::Write as _;
let _ = write!(s, "{b:02x}");
}
s
}
#[async_trait]
impl FilesRepo for InMemoryFilesRepo {
async fn create(
&self,
app_id: AppId,
collection: &str,
new: NewFile,
) -> Result<FileMeta, FilesRepoError> {
let id = Uuid::new_v4();
let now = Utc::now();
let meta = FileMeta {
id,
collection: collection.to_string(),
name: new.name.clone(),
content_type: new.content_type.clone(),
size: new.data.len() as u64,
checksum: sha256_hex(&new.data),
created_at: now,
updated_at: now,
};
self.data.lock().await.insert(
(app_id, collection.to_string(), id),
(meta.clone(), new.data),
);
Ok(meta)
}
async fn head(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError> {
Ok(self
.data
.lock()
.await
.get(&(app_id, collection.to_string(), id))
.map(|(m, _)| m.clone()))
}
async fn get(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<Vec<u8>>, FilesRepoError> {
Ok(self
.data
.lock()
.await
.get(&(app_id, collection.to_string(), id))
.map(|(_, b)| b.clone()))
}
async fn update(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
upd: FileUpdate,
) -> Result<Option<FileUpdated>, FilesRepoError> {
let mut data = self.data.lock().await;
let key = (app_id, collection.to_string(), id);
let Some((prev_meta, _)) = data.get(&key).cloned() else {
return Ok(None);
};
let now = Utc::now();
let new_meta = FileMeta {
id,
collection: collection.to_string(),
name: upd.name.clone().unwrap_or_else(|| prev_meta.name.clone()),
content_type: upd
.content_type
.clone()
.unwrap_or_else(|| prev_meta.content_type.clone()),
size: upd.data.len() as u64,
checksum: sha256_hex(&upd.data),
created_at: prev_meta.created_at,
updated_at: now,
};
data.insert(key, (new_meta.clone(), upd.data));
Ok(Some(FileUpdated {
new: new_meta,
prev: prev_meta,
}))
}
async fn delete(
&self,
app_id: AppId,
collection: &str,
id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError> {
Ok(self
.data
.lock()
.await
.remove(&(app_id, collection.to_string(), id))
.map(|(m, _)| m))
}
async fn list(
&self,
app_id: AppId,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, FilesRepoError> {
let data = self.data.lock().await;
let after = cursor.and_then(|c| Uuid::parse_str(c).ok());
let mut metas: Vec<FileMeta> = data
.iter()
.filter(|((a, c, _), _)| *a == app_id && c == collection)
.map(|(_, (m, _))| m.clone())
.filter(|m| after.is_none_or(|a| m.id > a))
.collect();
metas.sort_by_key(|m| m.id);
let take = (limit.max(1)) as usize;
let next_cursor = if metas.len() > take {
metas.truncate(take);
metas.last().map(|m| m.id.to_string())
} else {
None
};
Ok(FilesListPage {
files: metas,
next_cursor,
})
}
}
/// Captures emitted events so tests can assert on fan-out shape.
#[derive(Default)]
struct CapturingEmitter {
events: StdMutex<Vec<ServiceEvent>>,
}
#[async_trait]
impl ServiceEventEmitter for CapturingEmitter {
async fn emit(&self, _cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> {
self.events.lock().unwrap().push(event);
Ok(())
}
}
#[derive(Default)]
struct DenyingAuthzRepo;
#[async_trait]
impl AuthzRepo for DenyingAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(None)
}
}
#[derive(Default)]
struct EditorAuthzRepo;
#[async_trait]
impl AuthzRepo for EditorAuthzRepo {
async fn membership(
&self,
_user_id: UserId,
_app_id: AppId,
) -> Result<Option<AppRole>, AuthzError> {
Ok(Some(AppRole::Editor))
}
}
fn anon_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx {
app_id,
script_id: ScriptId::new(),
principal: None,
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
fn member_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx {
principal: Some(Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Member,
scopes: None,
app_binding: None,
}),
..anon_cx(app_id)
}
}
fn svc_with(authz: Arc<dyn AuthzRepo>, emitter: Arc<CapturingEmitter>) -> FilesServiceImpl {
FilesServiceImpl::new(
Arc::new(InMemoryFilesRepo::default()),
authz,
emitter,
10 * 1024 * 1024,
)
}
fn svc() -> FilesServiceImpl {
svc_with(
Arc::new(DenyingAuthzRepo),
Arc::new(CapturingEmitter::default()),
)
}
fn new_file(name: &str, data: &[u8]) -> NewFile {
NewFile {
name: name.to_string(),
content_type: "application/octet-stream".to_string(),
data: data.to_vec(),
}
}
#[tokio::test]
async fn create_then_get_head_round_trips() {
let files = svc();
let cx = anon_cx(AppId::new());
let id = files
.create(&cx, "avatars", new_file("a.bin", b"hello"))
.await
.unwrap();
let bytes = files.get(&cx, "avatars", &id.to_string()).await.unwrap();
assert_eq!(bytes, Some(b"hello".to_vec()));
let meta = files
.head(&cx, "avatars", &id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(meta.name, "a.bin");
assert_eq!(meta.size, 5);
assert_eq!(meta.checksum, sha256_hex(b"hello"));
}
#[tokio::test]
async fn get_and_head_missing_return_none() {
let files = svc();
let cx = anon_cx(AppId::new());
let missing = Uuid::new_v4().to_string();
assert_eq!(files.get(&cx, "c", &missing).await.unwrap(), None);
assert!(files.head(&cx, "c", &missing).await.unwrap().is_none());
// Non-UUID id is also "missing", not an error.
assert_eq!(files.get(&cx, "c", "not-a-uuid").await.unwrap(), None);
}
#[tokio::test]
async fn update_replaces_content_and_keeps_metadata_when_omitted() {
let files = svc();
let cx = anon_cx(AppId::new());
let id = files
.create(&cx, "c", new_file("v1.txt", b"one"))
.await
.unwrap();
files
.update(
&cx,
"c",
&id.to_string(),
FileUpdate {
data: b"two!!".to_vec(),
name: None,
content_type: None,
},
)
.await
.unwrap();
let meta = files
.head(&cx, "c", &id.to_string())
.await
.unwrap()
.unwrap();
assert_eq!(meta.name, "v1.txt"); // kept
assert_eq!(meta.size, 5);
assert_eq!(
files.get(&cx, "c", &id.to_string()).await.unwrap(),
Some(b"two!!".to_vec())
);
}
#[tokio::test]
async fn update_missing_throws_not_found() {
let files = svc();
let cx = anon_cx(AppId::new());
let err = files
.update(
&cx,
"c",
&Uuid::new_v4().to_string(),
FileUpdate {
data: b"x".to_vec(),
name: None,
content_type: None,
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::NotFound));
}
#[tokio::test]
async fn delete_returns_was_present() {
let files = svc();
let cx = anon_cx(AppId::new());
let id = files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
assert!(files.delete(&cx, "c", &id.to_string()).await.unwrap());
assert!(!files.delete(&cx, "c", &id.to_string()).await.unwrap());
assert!(!files.delete(&cx, "c", "not-a-uuid").await.unwrap());
}
#[tokio::test]
async fn empty_collection_rejected() {
let files = svc();
let cx = anon_cx(AppId::new());
let err = files
.create(&cx, "", new_file("f", b"x"))
.await
.unwrap_err();
assert!(matches!(err, FilesError::InvalidCollection(_)));
}
#[tokio::test]
async fn traversal_collection_rejected() {
let files = svc();
let cx = anon_cx(AppId::new());
for bad in ["../etc", "a/b", "a..b", "x\0y"] {
let err = files
.create(&cx, bad, new_file("f", b"x"))
.await
.unwrap_err();
assert!(
matches!(err, FilesError::InvalidCollection(_)),
"expected reject for {bad:?}"
);
}
}
#[tokio::test]
async fn missing_required_fields_have_field_specific_messages() {
let files = svc();
let cx = anon_cx(AppId::new());
// name
let err = files
.create(
&cx,
"c",
NewFile {
name: " ".into(),
content_type: "text/plain".into(),
data: b"x".to_vec(),
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::MissingField("name")));
// content_type
let err = files
.create(
&cx,
"c",
NewFile {
name: "f".into(),
content_type: String::new(),
data: b"x".to_vec(),
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::MissingField("content_type")));
// data
let err = files
.create(
&cx,
"c",
NewFile {
name: "f".into(),
content_type: "text/plain".into(),
data: vec![],
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::MissingField("data")));
}
#[tokio::test]
async fn name_and_content_type_length_caps_enforced() {
let files = svc();
let cx = anon_cx(AppId::new());
let long_name = "x".repeat(256);
let err = files
.create(&cx, "c", new_file(&long_name, b"x"))
.await
.unwrap_err();
assert!(matches!(err, FilesError::NameTooLong(256)));
let err = files
.create(
&cx,
"c",
NewFile {
name: "f".into(),
content_type: "x".repeat(128),
data: b"x".to_vec(),
},
)
.await
.unwrap_err();
assert!(matches!(err, FilesError::ContentTypeTooLong(128)));
}
#[tokio::test]
async fn per_file_size_cap_enforced() {
let files = FilesServiceImpl::new(
Arc::new(InMemoryFilesRepo::default()),
Arc::new(DenyingAuthzRepo),
Arc::new(CapturingEmitter::default()),
8, // tiny cap
);
let cx = anon_cx(AppId::new());
let err = files
.create(&cx, "c", new_file("big", b"123456789"))
.await
.unwrap_err();
assert!(matches!(err, FilesError::TooLarge { limit: 8, .. }));
}
#[tokio::test]
async fn cross_app_isolation() {
let files = svc();
let app_a = AppId::new();
let app_b = AppId::new();
let cx_a = anon_cx(app_a);
let cx_b = anon_cx(app_b);
let id = files
.create(&cx_a, "shared", new_file("f", b"from-a"))
.await
.unwrap();
// app B cannot see app A's file by id.
assert_eq!(
files.get(&cx_b, "shared", &id.to_string()).await.unwrap(),
None
);
assert!(files
.head(&cx_b, "shared", &id.to_string())
.await
.unwrap()
.is_none());
let page_b = files.list(&cx_b, "shared", None, 100).await.unwrap();
assert!(page_b.files.is_empty());
// app A still sees it.
assert!(files
.get(&cx_a, "shared", &id.to_string())
.await
.unwrap()
.is_some());
}
#[tokio::test]
async fn anonymous_cx_skips_authz() {
let files = svc(); // DenyingAuthzRepo
let cx = anon_cx(AppId::new());
// No principal → no authz check, even with a denying repo.
files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
}
#[tokio::test]
async fn member_without_role_is_forbidden() {
let files = svc(); // DenyingAuthzRepo
let cx = member_cx(AppId::new());
let err = files
.create(&cx, "c", new_file("f", b"x"))
.await
.unwrap_err();
assert!(matches!(err, FilesError::Forbidden));
}
#[tokio::test]
async fn member_with_editor_role_allowed() {
let files = svc_with(
Arc::new(EditorAuthzRepo),
Arc::new(CapturingEmitter::default()),
);
let cx = member_cx(AppId::new());
files.create(&cx, "c", new_file("f", b"x")).await.unwrap();
}
#[tokio::test]
async fn mutations_emit_events_with_correct_prev() {
let emitter = Arc::new(CapturingEmitter::default());
let files = svc_with(Arc::new(DenyingAuthzRepo), emitter.clone());
let cx = anon_cx(AppId::new());
let id = files.create(&cx, "c", new_file("f", b"one")).await.unwrap();
files
.update(
&cx,
"c",
&id.to_string(),
FileUpdate {
data: b"two".to_vec(),
name: None,
content_type: None,
},
)
.await
.unwrap();
files.delete(&cx, "c", &id.to_string()).await.unwrap();
let events = emitter.events.lock().unwrap();
assert_eq!(events.len(), 3);
// create: prev is None
assert_eq!(events[0].op, "create");
assert_eq!(events[0].source, "files");
assert!(events[0].old_payload.is_none());
assert!(events[0].payload.is_some());
// update: prev is the prior metadata
assert_eq!(events[1].op, "update");
assert!(events[1].old_payload.is_some());
// delete: prev is the deleted metadata (payload == old_payload)
assert_eq!(events[2].op, "delete");
assert_eq!(events[2].payload, events[2].old_payload);
assert!(events[2].payload.is_some());
}
#[tokio::test]
async fn list_cursor_paginates() {
let files = svc();
let cx = anon_cx(AppId::new());
for i in 0..5 {
files
.create(&cx, "c", new_file(&format!("f{i}"), b"x"))
.await
.unwrap();
}
let p1 = files.list(&cx, "c", None, 2).await.unwrap();
assert_eq!(p1.files.len(), 2);
assert!(p1.next_cursor.is_some());
let p2 = files
.list(&cx, "c", p1.next_cursor.as_deref(), 2)
.await
.unwrap();
assert_eq!(p2.files.len(), 2);
let p3 = files
.list(&cx, "c", p2.next_cursor.as_deref(), 2)
.await
.unwrap();
assert_eq!(p3.files.len(), 1);
assert!(p3.next_cursor.is_none());
}
}

View File

@@ -30,6 +30,9 @@ pub mod dispatcher;
pub mod docs_filter;
pub mod docs_repo;
pub mod docs_service;
pub mod files_api;
pub mod files_repo;
pub mod files_service;
pub mod gc;
pub mod http_service;
pub mod kv_repo;
@@ -96,6 +99,9 @@ pub use dead_letters_api::{dead_letters_router, DeadLettersApiError, DeadLetters
pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError};
pub use docs_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo};
pub use docs_service::DocsServiceImpl;
pub use files_api::{files_admin_router, FilesAdminState};
pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
pub use files_service::FilesServiceImpl;
pub use gc::{spawn_abandoned_gc, spawn_dead_letter_gc};
pub use http_service::{HttpConfig, HttpServiceImpl};
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
@@ -116,8 +122,9 @@ pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
pub use sandbox::{CeilingError, SandboxCeiling};
pub use trigger_config::{BackoffShape, TriggerConfig};
pub use trigger_repo::{
collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateKvTrigger,
DeadLetterTriggerMatch, DocsTriggerMatch, KvTriggerMatch, PostgresTriggerRepo, Trigger,
TriggerDetails, TriggerDispatchMode, TriggerKind, TriggerRepo, TriggerRepoError,
collection_matches, CreateDeadLetterTrigger, CreateDocsTrigger, CreateFilesTrigger,
CreateKvTrigger, DeadLetterTriggerMatch, DocsTriggerMatch, FilesTriggerMatch, KvTriggerMatch,
PostgresTriggerRepo, Trigger, TriggerDetails, TriggerDispatchMode, TriggerKind, TriggerRepo,
TriggerRepoError,
};
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};

View File

@@ -19,7 +19,8 @@ use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{
DocsEventOp, EmitError, KvEventOp, SdkCallCx, ServiceEvent, ServiceEventEmitter, TriggerEvent,
DocsEventOp, EmitError, FileMeta, FilesEventOp, KvEventOp, SdkCallCx, ServiceEvent,
ServiceEventEmitter, TriggerEvent,
};
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
@@ -43,6 +44,7 @@ impl ServiceEventEmitter for OutboxEventEmitter {
match event.source {
"kv" => self.emit_kv(cx, event).await,
"docs" => self.emit_docs(cx, event).await,
"files" => self.emit_files(cx, event).await,
// Future sources land here. For now, silently drop — the
// SDK calls `events.emit(...)` unconditionally for forward
// compat, so swallowing without an error is correct.
@@ -154,4 +156,68 @@ impl OutboxEventEmitter {
}
Ok(())
}
/// v1.1.5. Fan out a files mutation across matching files triggers.
/// The `ServiceEvent.payload` is the file **metadata** (never the
/// blob bytes); `old_payload` is the prior metadata (the deleted
/// row's metadata on delete). The `TriggerEvent::Files` carries the
/// metadata fields explicitly + `prev` for the change-data-capture
/// surface.
async fn emit_files(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> {
let Some(op) = FilesEventOp::from_wire(event.op) else {
return Ok(());
};
let Some(collection) = event.collection.clone() else {
return Ok(());
};
// The payload is the FileMeta JSON the FilesServiceImpl emitted.
let Some(meta) = event
.payload
.clone()
.and_then(|v| serde_json::from_value::<FileMeta>(v).ok())
else {
return Ok(());
};
let matches = self
.triggers
.list_matching_files(cx.app_id, &collection, op)
.await
.map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?;
if matches.is_empty() {
return Ok(());
}
let trigger_event = TriggerEvent::Files {
op,
collection,
id: meta.id.to_string(),
name: meta.name,
content_type: meta.content_type,
size: meta.size,
checksum: meta.checksum,
prev: event.old_payload.clone(),
};
let payload = serde_json::to_value(&trigger_event)
.map_err(|e| EmitError::Rejected(format!("event serialize: {e}")))?;
for m in matches {
self.outbox
.insert(NewOutboxRow {
app_id: cx.app_id,
source_kind: OutboxSourceKind::Files,
trigger_id: Some(m.trigger_id),
script_id: Some(m.script_id),
reply_to: None,
payload: payload.clone(),
origin_principal: cx.principal.as_ref().map(|p| p.user_id),
trigger_depth: cx.trigger_depth.saturating_add(1),
root_execution_id: Some(cx.root_execution_id),
})
.await
.map_err(|e| EmitError::Unavailable(format!("outbox insert: {e}")))?;
}
Ok(())
}
}

View File

@@ -27,6 +27,8 @@ pub enum OutboxSourceKind {
DeadLetter,
/// v1.1.4.
Cron,
/// v1.1.5.
Files,
}
impl OutboxSourceKind {
@@ -38,6 +40,7 @@ impl OutboxSourceKind {
Self::Docs => "docs",
Self::DeadLetter => "dead_letter",
Self::Cron => "cron",
Self::Files => "files",
}
}
@@ -49,6 +52,7 @@ impl OutboxSourceKind {
"docs" => Some(Self::Docs),
"dead_letter" => Some(Self::DeadLetter),
"cron" => Some(Self::Cron),
"files" => Some(Self::Files),
_ => None,
}
}

View File

@@ -5,7 +5,9 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AdminUserId, AppId, DocsEventOp, KvEventOp, ScriptId, TriggerId};
use picloud_shared::{
AdminUserId, AppId, DocsEventOp, FilesEventOp, KvEventOp, ScriptId, TriggerId,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
@@ -51,6 +53,8 @@ pub enum TriggerKind {
DeadLetter,
/// v1.1.4.
Cron,
/// v1.1.5.
Files,
}
impl TriggerKind {
@@ -61,6 +65,7 @@ impl TriggerKind {
Self::Docs => "docs",
Self::DeadLetter => "dead_letter",
Self::Cron => "cron",
Self::Files => "files",
}
}
@@ -71,6 +76,7 @@ impl TriggerKind {
"docs" => Some(Self::Docs),
"dead_letter" => Some(Self::DeadLetter),
"cron" => Some(Self::Cron),
"files" => Some(Self::Files),
_ => None,
}
}
@@ -120,6 +126,11 @@ pub enum TriggerDetails {
#[serde(default, skip_serializing_if = "Option::is_none")]
last_fired_at: Option<DateTime<Utc>>,
},
/// v1.1.5. Same shape as KV/docs: a collection glob + op subset.
Files {
collection_glob: String,
ops: Vec<FilesEventOp>,
},
}
/// Create payload for a KV trigger. Defaults applied at the admin
@@ -175,6 +186,33 @@ pub struct CreateCronTrigger {
pub registered_by_principal: AdminUserId,
}
/// Create payload for a files trigger (v1.1.5). Same shape as KV with
/// `FilesEventOp` ops.
#[derive(Debug, Clone)]
pub struct CreateFilesTrigger {
pub script_id: ScriptId,
pub collection_glob: String,
pub ops: Vec<FilesEventOp>,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
pub registered_by_principal: AdminUserId,
}
/// One match for the dispatcher's files trigger fan-out lookup
/// (v1.1.5). Same shape as `KvTriggerMatch`.
#[derive(Debug, Clone)]
pub struct FilesTriggerMatch {
pub trigger_id: TriggerId,
pub script_id: ScriptId,
pub dispatch_mode: TriggerDispatchMode,
pub retry_max_attempts: u32,
pub retry_backoff: BackoffShape,
pub retry_base_ms: u32,
pub registered_by_principal: AdminUserId,
}
/// One match for the dispatcher's "which KV triggers fire on this
/// event" lookup. Carries everything the dispatcher needs to construct
/// the outbox row.
@@ -242,6 +280,13 @@ pub trait TriggerRepo: Send + Sync {
req: CreateCronTrigger,
) -> Result<Trigger, TriggerRepoError>;
/// v1.1.5.
async fn create_files_trigger(
&self,
app_id: AppId,
req: CreateFilesTrigger,
) -> Result<Trigger, TriggerRepoError>;
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError>;
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError>;
@@ -269,6 +314,16 @@ pub trait TriggerRepo: Send + Sync {
op: DocsEventOp,
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError>;
/// Dispatcher hot path for files fan-out (v1.1.5). Mirrors the KV
/// fan-out logic: pull every enabled files trigger, filter glob +
/// ops in Rust (empty ops array means "any op").
async fn list_matching_files(
&self,
app_id: AppId,
collection: &str,
op: FilesEventOp,
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError>;
/// Dispatcher hot path for dead-letter fan-out. Filters: source
/// (or any-source), originating trigger_id (or any), originating
/// script_id (or any). Each filter is "match OR is_null".
@@ -555,6 +610,71 @@ impl TriggerRepo for PostgresTriggerRepo {
})
}
async fn create_files_trigger(
&self,
app_id: AppId,
req: CreateFilesTrigger,
) -> Result<Trigger, TriggerRepoError> {
if req.collection_glob.is_empty() {
return Err(TriggerRepoError::Invalid(
"collection_glob must not be empty".into(),
));
}
let mut tx = self.pool.begin().await?;
let parent: TriggerRow = sqlx::query_as(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal \
) VALUES ($1, $2, 'files', TRUE, $3, $4, $5, $6, $7) \
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, created_at, updated_at",
)
.bind(app_id.into_inner())
.bind(req.script_id.into_inner())
.bind(req.dispatch_mode.as_str())
.bind(i32::try_from(req.retry_max_attempts).unwrap_or(3))
.bind(req.retry_backoff.as_str())
.bind(i32::try_from(req.retry_base_ms).unwrap_or(1000))
.bind(req.registered_by_principal.into_inner())
.fetch_one(&mut *tx)
.await?;
let ops_str: Vec<String> = req.ops.iter().map(|o| o.as_str().to_string()).collect();
sqlx::query(
"INSERT INTO files_trigger_details (trigger_id, collection_glob, ops) \
VALUES ($1, $2, $3)",
)
.bind(parent.id)
.bind(&req.collection_glob)
.bind(&ops_str)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Trigger {
id: parent.id.into(),
app_id: parent.app_id.into(),
script_id: parent.script_id.into(),
kind: TriggerKind::Files,
enabled: parent.enabled,
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000),
registered_by_principal: parent.registered_by_principal.into(),
created_at: parent.created_at,
updated_at: parent.updated_at,
details: TriggerDetails::Files {
collection_glob: req.collection_glob,
ops: req.ops,
},
})
}
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
let parents: Vec<TriggerRow> = sqlx::query_as(
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
@@ -693,6 +813,51 @@ impl TriggerRepo for PostgresTriggerRepo {
Ok(out)
}
async fn list_matching_files(
&self,
app_id: AppId,
collection: &str,
op: FilesEventOp,
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
// Mirrors list_matching_kv: pull every enabled files trigger,
// filter glob + ops in Rust (empty ops array means "any op").
let rows: Vec<KvMatchRow> = sqlx::query_as(
"SELECT t.id, t.script_id, t.dispatch_mode, \
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
t.registered_by_principal, \
d.collection_glob, d.ops \
FROM triggers t \
JOIN files_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'files' AND t.enabled = TRUE",
)
.bind(app_id.into_inner())
.fetch_all(&self.pool)
.await?;
let op_str = op.as_str();
let mut out = Vec::new();
for r in rows {
if !collection_matches(&r.collection_glob, collection) {
continue;
}
let any_op = r.ops.is_empty();
if !any_op && !r.ops.iter().any(|o| o == op_str) {
continue;
}
out.push(FilesTriggerMatch {
trigger_id: r.id.into(),
script_id: r.script_id.into(),
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
retry_max_attempts: u32::try_from(r.retry_max_attempts).unwrap_or(3),
retry_backoff: BackoffShape::from_wire(&r.retry_backoff)
.unwrap_or(BackoffShape::Exponential),
retry_base_ms: u32::try_from(r.retry_base_ms).unwrap_or(1000),
registered_by_principal: r.registered_by_principal.into(),
});
}
Ok(out)
}
async fn list_matching_dead_letter(
&self,
app_id: AppId,
@@ -729,6 +894,7 @@ impl TriggerRepo for PostgresTriggerRepo {
}
}
#[allow(clippy::too_many_lines)]
async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, TriggerRepoError> {
let kind = TriggerKind::from_wire(&parent.kind).ok_or_else(|| {
TriggerRepoError::Invalid(format!("unknown trigger kind {}", parent.kind))
@@ -797,6 +963,23 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
last_fired_at: row.last_fired_at,
}
}
TriggerKind::Files => {
let row: KvDetailRow = sqlx::query_as(
"SELECT collection_glob, ops FROM files_trigger_details WHERE trigger_id = $1",
)
.bind(parent.id)
.fetch_one(pool)
.await?;
let ops = row
.ops
.iter()
.filter_map(|s| FilesEventOp::from_wire(s))
.collect();
TriggerDetails::Files {
collection_glob: row.collection_glob,
ops,
}
}
};
Ok(Trigger {

View File

@@ -16,7 +16,9 @@ use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post};
use axum::{Extension, Router};
use picloud_shared::{AppId, DocsEventOp, KvEventOp, Principal, ScriptId, ScriptKind, TriggerId};
use picloud_shared::{
AppId, DocsEventOp, FilesEventOp, KvEventOp, Principal, ScriptId, ScriptKind, TriggerId,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -25,8 +27,8 @@ use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::repo::{ScriptRepository, ScriptRepositoryError};
use crate::trigger_config::{BackoffShape, TriggerConfig};
use crate::trigger_repo::{
CreateCronTrigger, CreateDeadLetterTrigger, CreateDocsTrigger, CreateKvTrigger, Trigger,
TriggerDispatchMode, TriggerRepo, TriggerRepoError,
CreateCronTrigger, CreateDeadLetterTrigger, CreateDocsTrigger, CreateFilesTrigger,
CreateKvTrigger, Trigger, TriggerDispatchMode, TriggerRepo, TriggerRepoError,
};
#[derive(Clone)]
@@ -54,6 +56,7 @@ pub fn triggers_router(state: TriggersState) -> Router {
.route("/apps/{app_id}/triggers/kv", post(create_kv_trigger))
.route("/apps/{app_id}/triggers/docs", post(create_docs_trigger))
.route("/apps/{app_id}/triggers/cron", post(create_cron_trigger))
.route("/apps/{app_id}/triggers/files", post(create_files_trigger))
.route(
"/apps/{app_id}/triggers/dead_letter",
post(create_dl_trigger),
@@ -139,6 +142,24 @@ fn default_timezone() -> String {
"UTC".to_string()
}
/// v1.1.5 files trigger. Mirrors `CreateKvTriggerRequest`; `ops` uses
/// `FilesEventOp` (`create` / `update` / `delete`).
#[derive(Debug, Deserialize)]
pub struct CreateFilesTriggerRequest {
pub script_id: ScriptId,
pub collection_glob: String,
#[serde(default)]
pub ops: Vec<FilesEventOp>,
#[serde(default = "default_dispatch")]
pub dispatch_mode: TriggerDispatchMode,
#[serde(default)]
pub retry_max_attempts: Option<u32>,
#[serde(default)]
pub retry_backoff: Option<BackoffShape>,
#[serde(default)]
pub retry_base_ms: Option<u32>,
}
#[derive(Debug, Deserialize)]
pub struct CreateDeadLetterTriggerRequest {
pub script_id: ScriptId,
@@ -328,6 +349,43 @@ async fn create_cron_trigger(
Ok((StatusCode::CREATED, Json(created)))
}
async fn create_files_trigger(
State(s): State<TriggersState>,
Extension(principal): Extension<Principal>,
Path(app_id): Path<AppId>,
Json(input): Json<CreateFilesTriggerRequest>,
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
ensure_app_exists(&*s.apps, app_id).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppManageTriggers(app_id),
)
.await?;
if input.collection_glob.trim().is_empty() {
return Err(TriggersApiError::Invalid(
"collection_glob must not be empty".into(),
));
}
validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;
let req = CreateFilesTrigger {
script_id: input.script_id,
collection_glob: input.collection_glob,
ops: input.ops,
dispatch_mode: input.dispatch_mode,
retry_max_attempts: input
.retry_max_attempts
.unwrap_or(s.config.retry_max_attempts),
retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff),
retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms),
registered_by_principal: principal.user_id,
};
let created = s.triggers.create_files_trigger(app_id, req).await?;
Ok((StatusCode::CREATED, Json(created)))
}
async fn create_dl_trigger(
State(s): State<TriggersState>,
Extension(principal): Extension<Principal>,
@@ -484,13 +542,14 @@ mod tests {
use super::*;
use crate::app_repo::{AppLookup, AppRepository};
use crate::trigger_repo::{
CreateCronTrigger, DeadLetterTriggerMatch, DocsTriggerMatch, KvTriggerMatch, Trigger,
TriggerDetails, TriggerRepo, TriggerRepoError,
CreateCronTrigger, CreateFilesTrigger, DeadLetterTriggerMatch, DocsTriggerMatch,
FilesTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails, TriggerRepo, TriggerRepoError,
};
use async_trait::async_trait;
use chrono::Utc;
use picloud_shared::{
AdminUserId, App, AppRole, DocsEventOp, KvEventOp, ScriptId, TriggerId, UserId,
AdminUserId, App, AppRole, DocsEventOp, FilesEventOp, KvEventOp, ScriptId, TriggerId,
UserId,
};
use std::collections::HashMap;
use tokio::sync::Mutex;
@@ -616,6 +675,34 @@ mod tests {
self.inner.lock().await.insert(id, trigger.clone());
Ok(trigger)
}
async fn create_files_trigger(
&self,
app_id: AppId,
req: CreateFilesTrigger,
) -> Result<Trigger, TriggerRepoError> {
let now = Utc::now();
let id = TriggerId::new();
let trigger = Trigger {
id,
app_id,
script_id: req.script_id,
kind: crate::trigger_repo::TriggerKind::Files,
enabled: true,
dispatch_mode: req.dispatch_mode,
retry_max_attempts: req.retry_max_attempts,
retry_backoff: req.retry_backoff,
retry_base_ms: req.retry_base_ms,
registered_by_principal: req.registered_by_principal,
created_at: now,
updated_at: now,
details: TriggerDetails::Files {
collection_glob: req.collection_glob,
ops: req.ops,
},
};
self.inner.lock().await.insert(id, trigger.clone());
Ok(trigger)
}
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
Ok(self
.inner
@@ -648,6 +735,14 @@ mod tests {
) -> Result<Vec<DocsTriggerMatch>, TriggerRepoError> {
Ok(vec![])
}
async fn list_matching_files(
&self,
_app_id: AppId,
_collection: &str,
_op: FilesEventOp,
) -> Result<Vec<FilesTriggerMatch>, TriggerRepoError> {
Ok(vec![])
}
async fn list_matching_dead_letter(
&self,
_app_id: AppId,