feat(modules): group-files schema + owner-relative path helpers + repo (§11.6 files C1)
Extends the §11.6 shared-collection machinery to the filesystem-backed `files` blob store (after KV/0053 and docs/0054). - 0055_group_files.sql: widen the group_collections kind CHECK to admit 'files'; add a group-keyed `group_files` metadata table mirroring `files` (0018), CASCADE on group delete. - files_repo: generalize the four path/IO free functions (shard_dir_at / final_path_at / write_atomic_at / read_verify_at) to take an owner-relative dir instead of `app_id`, and make them (plus the cursor helpers) pub(crate). The security-sensitive atomic-write + checksum-on-read mechanics now have a single source shared by the app and group repos. App behavior is unchanged (apps shard at `<app_id>/`). - group_files_repo: FsGroupFilesRepo keyed by group_id, blobs at `<root>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>` — a `groups/` infix disjoint from the per-app subtree, so the existing recursive orphan sweeper covers it with zero change. Schema snapshot blessed (55 migrations); app-files fs tests still green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
37
crates/manager-core/migrations/0055_group_files.sql
Normal file
37
crates/manager-core/migrations/0055_group_files.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- §11.6 (cont., v1.2 Hierarchies): group-shared FILES collections.
|
||||||
|
--
|
||||||
|
-- Extends the shared-collection machinery (0052 registry + the per-kind stores
|
||||||
|
-- 0053 group_kv_entries / 0054 group_docs) from KV/docs to the filesystem-backed
|
||||||
|
-- `files` blob store. The registry's `kind` discriminator was built for exactly
|
||||||
|
-- this — widen its CHECK to admit 'files', and add a group-keyed metadata table
|
||||||
|
-- mirroring `files` (0018).
|
||||||
|
|
||||||
|
-- Widen the marker kind allow-list (auto-named `group_collections_kind_check`,
|
||||||
|
-- per 0052's inline column CHECK; 0054 widened it to ('kv','docs')).
|
||||||
|
ALTER TABLE group_collections
|
||||||
|
DROP CONSTRAINT group_collections_kind_check,
|
||||||
|
ADD CONSTRAINT group_collections_kind_check CHECK (kind IN ('kv', 'docs', 'files'));
|
||||||
|
|
||||||
|
-- Group-keyed file metadata. Identity tuple `(group_id, collection, id)` — keyed
|
||||||
|
-- by the OWNING GROUP, not an app (the shared blobs belong to the group). The
|
||||||
|
-- bytes live on disk at
|
||||||
|
-- <PICLOUD_FILES_ROOT>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>
|
||||||
|
-- (a `groups/` infix disjoint from the per-app `files/<app_id>/...` subtree, so
|
||||||
|
-- the orphan sweeper covers both with one walk). CASCADE on group delete (the
|
||||||
|
-- metadata dies with its group; an app delete leaves it), matching 0053/0054.
|
||||||
|
CREATE TABLE group_files (
|
||||||
|
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
collection TEXT NOT NULL,
|
||||||
|
id UUID NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
content_type TEXT NOT NULL,
|
||||||
|
size_bytes BIGINT NOT NULL,
|
||||||
|
checksum_sha256 TEXT NOT NULL, -- hex, 64 chars, lowercase
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (group_id, collection, id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- List + cursor pagination scans by (group_id, collection) — mirrors
|
||||||
|
-- idx_files_app_collection (0018).
|
||||||
|
CREATE INDEX idx_group_files_group_collection ON group_files (group_id, collection);
|
||||||
@@ -211,7 +211,7 @@ impl FsFilesRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn final_path(&self, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
|
fn final_path(&self, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
|
||||||
final_path_at(&self.config.root, app_id, collection, id)
|
final_path_at(&self.config.root, &app_owner_dir(app_id), collection, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_atomic(
|
fn write_atomic(
|
||||||
@@ -221,29 +221,58 @@ impl FsFilesRepo {
|
|||||||
id: Uuid,
|
id: Uuid,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
) -> Result<String, FilesRepoError> {
|
) -> Result<String, FilesRepoError> {
|
||||||
write_atomic_at(&self.config.root, app_id, collection, id, bytes)
|
write_atomic_at(
|
||||||
|
&self.config.root,
|
||||||
|
&app_owner_dir(app_id),
|
||||||
|
collection,
|
||||||
|
id,
|
||||||
|
bytes,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn shard_dir_at(root: &Path, app_id: AppId, collection: &str, id_str: &str) -> PathBuf {
|
/// The owner-relative subdirectory of an **app**'s blobs under `<root>/files/`:
|
||||||
|
/// just `<app_id>`. Group-shared blobs (§11.6) shard at `groups/<group_id>`
|
||||||
|
/// instead (see `group_files_repo::group_owner_dir`) — a disjoint subtree under
|
||||||
|
/// the same `files/` base, so the orphan sweeper covers both with one walk.
|
||||||
|
pub(crate) fn app_owner_dir(app_id: AppId) -> PathBuf {
|
||||||
|
PathBuf::from(app_id.into_inner().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `<root>/files/<owner_rel>/<collection>/<id[0:2]>/`. `owner_rel` is built
|
||||||
|
/// by the caller from a server-generated id (`<app_id>` or `groups/<group_id>`)
|
||||||
|
/// — never user input — and `collection` is path-guarded one layer up, so no
|
||||||
|
/// component can escape the `files/` base.
|
||||||
|
pub(crate) fn shard_dir_at(
|
||||||
|
root: &Path,
|
||||||
|
owner_rel: &Path,
|
||||||
|
collection: &str,
|
||||||
|
id_str: &str,
|
||||||
|
) -> PathBuf {
|
||||||
root.join("files")
|
root.join("files")
|
||||||
.join(app_id.into_inner().to_string())
|
.join(owner_rel)
|
||||||
.join(collection)
|
.join(collection)
|
||||||
.join(&id_str[..2])
|
.join(&id_str[..2])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn final_path_at(root: &Path, app_id: AppId, collection: &str, id: Uuid) -> PathBuf {
|
pub(crate) fn final_path_at(
|
||||||
|
root: &Path,
|
||||||
|
owner_rel: &Path,
|
||||||
|
collection: &str,
|
||||||
|
id: Uuid,
|
||||||
|
) -> PathBuf {
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
shard_dir_at(root, app_id, collection, &id_str).join(&id_str)
|
shard_dir_at(root, owner_rel, collection, &id_str).join(&id_str)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Steps 2–6 of the atomic-write protocol. Returns the lowercase hex
|
/// Steps 2–6 of the atomic-write protocol. Returns the lowercase hex
|
||||||
/// SHA-256 of the bytes (computed in a single pass over the in-memory
|
/// 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
|
/// buffer — the file is never re-read). `pub(crate)` + owner-relative so the
|
||||||
/// mechanics are unit-testable without a Postgres pool.
|
/// app and group-shared (§11.6) repos share this single source for the
|
||||||
fn write_atomic_at(
|
/// security-sensitive write+checksum mechanics, unit-testable without a pool.
|
||||||
|
pub(crate) fn write_atomic_at(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
app_id: AppId,
|
owner_rel: &Path,
|
||||||
collection: &str,
|
collection: &str,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
bytes: &[u8],
|
bytes: &[u8],
|
||||||
@@ -251,7 +280,7 @@ fn write_atomic_at(
|
|||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
|
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let dir = shard_dir_at(root, app_id, collection, &id_str);
|
let dir = shard_dir_at(root, owner_rel, collection, &id_str);
|
||||||
create_dir_all_secure(&dir)?;
|
create_dir_all_secure(&dir)?;
|
||||||
|
|
||||||
// Single-pass checksum over the in-memory buffer.
|
// Single-pass checksum over the in-memory buffer.
|
||||||
@@ -278,15 +307,16 @@ fn write_atomic_at(
|
|||||||
|
|
||||||
/// Read + checksum-verify the bytes at the given path-set. Free
|
/// Read + checksum-verify the bytes at the given path-set. Free
|
||||||
/// function mirror of the `get` read path. Returns `Corrupted` when the
|
/// function mirror of the `get` read path. Returns `Corrupted` when the
|
||||||
/// bytes are missing or don't match `expected_checksum`.
|
/// bytes are missing or don't match `expected_checksum`. `pub(crate)` +
|
||||||
fn read_verify_at(
|
/// owner-relative — shared with the group-files (§11.6) repo.
|
||||||
|
pub(crate) fn read_verify_at(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
app_id: AppId,
|
owner_rel: &Path,
|
||||||
collection: &str,
|
collection: &str,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
expected_checksum: &str,
|
expected_checksum: &str,
|
||||||
) -> Result<Vec<u8>, FilesRepoError> {
|
) -> Result<Vec<u8>, FilesRepoError> {
|
||||||
let path = final_path_at(root, app_id, collection, id);
|
let path = final_path_at(root, owner_rel, collection, id);
|
||||||
let bytes = match std::fs::read(&path) {
|
let bytes = match std::fs::read(&path) {
|
||||||
Ok(b) => b,
|
Ok(b) => b,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -383,7 +413,13 @@ impl FilesRepo for FsFilesRepo {
|
|||||||
let Some((stored_checksum,)) = row else {
|
let Some((stored_checksum,)) = row else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let bytes = read_verify_at(&self.config.root, app_id, collection, id, &stored_checksum)?;
|
let bytes = read_verify_at(
|
||||||
|
&self.config.root,
|
||||||
|
&app_owner_dir(app_id),
|
||||||
|
collection,
|
||||||
|
id,
|
||||||
|
&stored_checksum,
|
||||||
|
)?;
|
||||||
Ok(Some(bytes))
|
Ok(Some(bytes))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,11 +591,11 @@ fn hex_lower(bytes: &[u8]) -> String {
|
|||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode_cursor(last_id: Uuid) -> String {
|
pub(crate) fn encode_cursor(last_id: Uuid) -> String {
|
||||||
URL_SAFE_NO_PAD.encode(last_id.to_string().as_bytes())
|
URL_SAFE_NO_PAD.encode(last_id.to_string().as_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
|
pub(crate) fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
|
||||||
let bytes = URL_SAFE_NO_PAD
|
let bytes = URL_SAFE_NO_PAD
|
||||||
.decode(cursor)
|
.decode(cursor)
|
||||||
.map_err(|_| FilesRepoError::InvalidCursor)?;
|
.map_err(|_| FilesRepoError::InvalidCursor)?;
|
||||||
@@ -672,13 +708,13 @@ mod tests {
|
|||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let bytes = b"hello picloud files".to_vec();
|
let bytes = b"hello picloud files".to_vec();
|
||||||
|
|
||||||
let checksum = write_atomic_at(&root, app, "avatars", id, &bytes).unwrap();
|
let checksum = write_atomic_at(&root, &app_owner_dir(app), "avatars", id, &bytes).unwrap();
|
||||||
// Single-pass checksum matches an independent hash of the bytes.
|
// Single-pass checksum matches an independent hash of the bytes.
|
||||||
let mut h = Sha256::new();
|
let mut h = Sha256::new();
|
||||||
h.update(&bytes);
|
h.update(&bytes);
|
||||||
assert_eq!(checksum, hex_lower(&h.finalize()));
|
assert_eq!(checksum, hex_lower(&h.finalize()));
|
||||||
|
|
||||||
let read = read_verify_at(&root, app, "avatars", id, &checksum).unwrap();
|
let read = read_verify_at(&root, &app_owner_dir(app), "avatars", id, &checksum).unwrap();
|
||||||
assert_eq!(read, bytes);
|
assert_eq!(read, bytes);
|
||||||
|
|
||||||
std::fs::remove_dir_all(&root).ok();
|
std::fs::remove_dir_all(&root).ok();
|
||||||
@@ -689,13 +725,13 @@ mod tests {
|
|||||||
let root = unique_tmp_root();
|
let root = unique_tmp_root();
|
||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let checksum = write_atomic_at(&root, app, "c", id, b"original").unwrap();
|
let checksum = write_atomic_at(&root, &app_owner_dir(app), "c", id, b"original").unwrap();
|
||||||
|
|
||||||
// Mutate the bytes behind the repo's back.
|
// Mutate the bytes behind the repo's back.
|
||||||
let path = final_path_at(&root, app, "c", id);
|
let path = final_path_at(&root, &app_owner_dir(app), "c", id);
|
||||||
std::fs::write(&path, b"tampered").unwrap();
|
std::fs::write(&path, b"tampered").unwrap();
|
||||||
|
|
||||||
let err = read_verify_at(&root, app, "c", id, &checksum).unwrap_err();
|
let err = read_verify_at(&root, &app_owner_dir(app), "c", id, &checksum).unwrap_err();
|
||||||
assert!(matches!(err, FilesRepoError::Corrupted));
|
assert!(matches!(err, FilesRepoError::Corrupted));
|
||||||
|
|
||||||
std::fs::remove_dir_all(&root).ok();
|
std::fs::remove_dir_all(&root).ok();
|
||||||
@@ -707,7 +743,7 @@ mod tests {
|
|||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
// No write — the file never existed.
|
// No write — the file never existed.
|
||||||
let err = read_verify_at(&root, app, "c", id, "deadbeef").unwrap_err();
|
let err = read_verify_at(&root, &app_owner_dir(app), "c", id, "deadbeef").unwrap_err();
|
||||||
assert!(matches!(err, FilesRepoError::Corrupted));
|
assert!(matches!(err, FilesRepoError::Corrupted));
|
||||||
std::fs::remove_dir_all(&root).ok();
|
std::fs::remove_dir_all(&root).ok();
|
||||||
}
|
}
|
||||||
@@ -717,10 +753,10 @@ mod tests {
|
|||||||
let root = unique_tmp_root();
|
let root = unique_tmp_root();
|
||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
write_atomic_at(&root, app, "c", id, b"data").unwrap();
|
write_atomic_at(&root, &app_owner_dir(app), "c", id, b"data").unwrap();
|
||||||
|
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let dir = shard_dir_at(&root, app, "c", &id_str);
|
let dir = shard_dir_at(&root, &app_owner_dir(app), "c", &id_str);
|
||||||
let entries: Vec<_> = std::fs::read_dir(&dir)
|
let entries: Vec<_> = std::fs::read_dir(&dir)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.filter_map(Result::ok)
|
.filter_map(Result::ok)
|
||||||
@@ -739,7 +775,7 @@ mod tests {
|
|||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let path = final_path_at(&root, app, "col", id);
|
let path = final_path_at(&root, &app_owner_dir(app), "col", id);
|
||||||
let shard = &id_str[..2];
|
let shard = &id_str[..2];
|
||||||
assert!(path
|
assert!(path
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
@@ -753,9 +789,9 @@ mod tests {
|
|||||||
let root = unique_tmp_root();
|
let root = unique_tmp_root();
|
||||||
let app = AppId::new();
|
let app = AppId::new();
|
||||||
let id = Uuid::new_v4();
|
let id = Uuid::new_v4();
|
||||||
write_atomic_at(&root, app, "c", id, b"data").unwrap();
|
write_atomic_at(&root, &app_owner_dir(app), "c", id, b"data").unwrap();
|
||||||
let id_str = id.to_string();
|
let id_str = id.to_string();
|
||||||
let dir = shard_dir_at(&root, app, "c", &id_str);
|
let dir = shard_dir_at(&root, &app_owner_dir(app), "c", &id_str);
|
||||||
let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
|
let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
|
||||||
assert_eq!(mode & 0o777, 0o700, "shard dir should be 0o700");
|
assert_eq!(mode & 0o777, 0o700, "shard dir should be 0o700");
|
||||||
std::fs::remove_dir_all(&root).ok();
|
std::fs::remove_dir_all(&root).ok();
|
||||||
|
|||||||
399
crates/manager-core/src/group_files_repo.rs
Normal file
399
crates/manager-core/src/group_files_repo.rs
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
//! Low-level metadata (Postgres `group_files`) + blob bytes (filesystem) storage
|
||||||
|
//! for §11.6 group-shared FILES collections. A near-clone of [`crate::files_repo`]
|
||||||
|
//! keyed by the owning `group_id` instead of `app_id`.
|
||||||
|
//!
|
||||||
|
//! The security-sensitive disk mechanics — the atomic write+checksum protocol and
|
||||||
|
//! checksum-on-read — are **not** duplicated: they come from the owner-relative
|
||||||
|
//! free functions in [`crate::files_repo`] (`write_atomic_at` / `read_verify_at` /
|
||||||
|
//! `final_path_at`), called with this repo's owner sub-path. Group blobs shard at
|
||||||
|
//! `<root>/files/groups/<group_id>/<collection>/<id[0:2]>/<id>` — a `groups/` infix
|
||||||
|
//! disjoint from the per-app `files/<app_id>/...` subtree, so the orphan sweeper
|
||||||
|
//! ([crate::files_sweep]) covers both with one walk. Authorization, group
|
||||||
|
//! resolution, value validation, and content-type sanitization live one layer up
|
||||||
|
//! in `GroupFilesServiceImpl`.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use picloud_shared::{FileMeta, FileUpdate, FilesListPage, GroupId, NewFile};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::files_repo::{
|
||||||
|
decode_cursor, encode_cursor, final_path_at, read_verify_at, write_atomic_at, FileUpdated,
|
||||||
|
FilesRepoError,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILES_LIST_MAX_LIMIT: u32 = 1_000;
|
||||||
|
const FILES_LIST_DEFAULT_LIMIT: u32 = 100;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum GroupFilesRepoError {
|
||||||
|
#[error("database error: {0}")]
|
||||||
|
Db(#[from] sqlx::Error),
|
||||||
|
|
||||||
|
#[error("filesystem error: {0}")]
|
||||||
|
Io(String),
|
||||||
|
|
||||||
|
#[error("invalid collection name: {0}")]
|
||||||
|
InvalidCollection(String),
|
||||||
|
|
||||||
|
/// Bytes on disk no longer match the stored checksum (or are missing).
|
||||||
|
#[error("file content corrupted (checksum mismatch)")]
|
||||||
|
Corrupted,
|
||||||
|
|
||||||
|
#[error("invalid pagination cursor")]
|
||||||
|
InvalidCursor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<FilesRepoError> for GroupFilesRepoError {
|
||||||
|
fn from(e: FilesRepoError) -> Self {
|
||||||
|
match e {
|
||||||
|
FilesRepoError::Db(e) => Self::Db(e),
|
||||||
|
FilesRepoError::Io(s) => Self::Io(s),
|
||||||
|
FilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c),
|
||||||
|
FilesRepoError::Corrupted => Self::Corrupted,
|
||||||
|
FilesRepoError::InvalidCursor => Self::InvalidCursor,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The owner-relative subdirectory of a **group**'s shared blobs under
|
||||||
|
/// `<root>/files/`: `groups/<group_id>`. A UUID app-dir can never equal the
|
||||||
|
/// literal `groups`, so app and group blob trees can't collide.
|
||||||
|
pub(crate) fn group_owner_dir(group_id: GroupId) -> PathBuf {
|
||||||
|
Path::new("groups").join(group_id.into_inner().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait GroupFilesRepo: Send + Sync {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
group_id: GroupId,
|
||||||
|
collection: &str,
|
||||||
|
new: NewFile,
|
||||||
|
) -> Result<FileMeta, GroupFilesRepoError>;
|
||||||
|
|
||||||
|
async fn head(
|
||||||
|
&self,
|
||||||
|
group_id: GroupId,
|
||||||
|
collection: &str,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<Option<FileMeta>, GroupFilesRepoError>;
|
||||||
|
|
||||||
|
/// Reads + checksum-verifies the bytes. `Ok(None)` when no row exists;
|
||||||
|
/// `Err(Corrupted)` when the row exists but the bytes are missing/mismatched.
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
group_id: GroupId,
|
||||||
|
collection: &str,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<Option<Vec<u8>>, GroupFilesRepoError>;
|
||||||
|
|
||||||
|
/// `Ok(None)` when no row exists (the service maps that to `NotFound`).
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
group_id: GroupId,
|
||||||
|
collection: &str,
|
||||||
|
id: Uuid,
|
||||||
|
upd: FileUpdate,
|
||||||
|
) -> Result<Option<FileUpdated>, GroupFilesRepoError>;
|
||||||
|
|
||||||
|
async fn delete(
|
||||||
|
&self,
|
||||||
|
group_id: GroupId,
|
||||||
|
collection: &str,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<Option<FileMeta>, GroupFilesRepoError>;
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
group_id: GroupId,
|
||||||
|
collection: &str,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<FilesListPage, GroupFilesRepoError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filesystem-bytes + Postgres-metadata repo for group-shared files.
|
||||||
|
pub struct FsGroupFilesRepo {
|
||||||
|
pool: PgPool,
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FsGroupFilesRepo {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(pool: PgPool, root: PathBuf) -> Self {
|
||||||
|
Self { pool, root }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Belt-and-suspenders path guard (the service validates at the SDK
|
||||||
|
/// boundary). Mirrors `FsFilesRepo::guard_collection`.
|
||||||
|
fn guard_collection(collection: &str) -> Result<(), GroupFilesRepoError> {
|
||||||
|
if collection.is_empty()
|
||||||
|
|| collection.contains('/')
|
||||||
|
|| collection.contains('\\')
|
||||||
|
|| collection.contains("..")
|
||||||
|
|| collection.contains('\0')
|
||||||
|
{
|
||||||
|
return Err(GroupFilesRepoError::InvalidCollection(
|
||||||
|
collection.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_atomic(
|
||||||
|
&self,
|
||||||
|
group_id: GroupId,
|
||||||
|
collection: &str,
|
||||||
|
id: Uuid,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<String, GroupFilesRepoError> {
|
||||||
|
Ok(write_atomic_at(
|
||||||
|
&self.root,
|
||||||
|
&group_owner_dir(group_id),
|
||||||
|
collection,
|
||||||
|
id,
|
||||||
|
bytes,
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl GroupFilesRepo for FsGroupFilesRepo {
|
||||||
|
async fn create(
|
||||||
|
&self,
|
||||||
|
group_id: GroupId,
|
||||||
|
collection: &str,
|
||||||
|
new: NewFile,
|
||||||
|
) -> Result<FileMeta, GroupFilesRepoError> {
|
||||||
|
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(group_id, collection, id, &new.data)?;
|
||||||
|
|
||||||
|
let row: GroupFileRow = sqlx::query_as(
|
||||||
|
"INSERT INTO group_files \
|
||||||
|
(group_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(group_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,
|
||||||
|
group_id: GroupId,
|
||||||
|
collection: &str,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
||||||
|
Self::guard_collection(collection)?;
|
||||||
|
let row: Option<GroupFileRow> = sqlx::query_as(
|
||||||
|
"SELECT id, collection, name, content_type, size_bytes, \
|
||||||
|
checksum_sha256, created_at, updated_at \
|
||||||
|
FROM group_files WHERE group_id = $1 AND collection = $2 AND id = $3",
|
||||||
|
)
|
||||||
|
.bind(group_id.into_inner())
|
||||||
|
.bind(collection)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.map(GroupFileRow::into_meta))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(
|
||||||
|
&self,
|
||||||
|
group_id: GroupId,
|
||||||
|
collection: &str,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<Option<Vec<u8>>, GroupFilesRepoError> {
|
||||||
|
Self::guard_collection(collection)?;
|
||||||
|
let row: Option<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT checksum_sha256 FROM group_files \
|
||||||
|
WHERE group_id = $1 AND collection = $2 AND id = $3",
|
||||||
|
)
|
||||||
|
.bind(group_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.root,
|
||||||
|
&group_owner_dir(group_id),
|
||||||
|
collection,
|
||||||
|
id,
|
||||||
|
&stored_checksum,
|
||||||
|
)?;
|
||||||
|
Ok(Some(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update(
|
||||||
|
&self,
|
||||||
|
group_id: GroupId,
|
||||||
|
collection: &str,
|
||||||
|
id: Uuid,
|
||||||
|
upd: FileUpdate,
|
||||||
|
) -> Result<Option<FileUpdated>, GroupFilesRepoError> {
|
||||||
|
Self::guard_collection(collection)?;
|
||||||
|
let Some(prev) = self.head(group_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(group_id, collection, id, &upd.data)?;
|
||||||
|
|
||||||
|
let row: GroupFileRow = sqlx::query_as(
|
||||||
|
"UPDATE group_files SET \
|
||||||
|
name = COALESCE($4, name), \
|
||||||
|
content_type = COALESCE($5, content_type), \
|
||||||
|
size_bytes = $6, \
|
||||||
|
checksum_sha256 = $7, \
|
||||||
|
updated_at = NOW() \
|
||||||
|
WHERE group_id = $1 AND collection = $2 AND id = $3 \
|
||||||
|
RETURNING id, collection, name, content_type, size_bytes, \
|
||||||
|
checksum_sha256, created_at, updated_at",
|
||||||
|
)
|
||||||
|
.bind(group_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,
|
||||||
|
group_id: GroupId,
|
||||||
|
collection: &str,
|
||||||
|
id: Uuid,
|
||||||
|
) -> Result<Option<FileMeta>, GroupFilesRepoError> {
|
||||||
|
Self::guard_collection(collection)?;
|
||||||
|
let mut tx = self.pool.begin().await?;
|
||||||
|
let row: Option<GroupFileRow> = sqlx::query_as(
|
||||||
|
"SELECT id, collection, name, content_type, size_bytes, \
|
||||||
|
checksum_sha256, created_at, updated_at \
|
||||||
|
FROM group_files WHERE group_id = $1 AND collection = $2 AND id = $3 \
|
||||||
|
FOR UPDATE",
|
||||||
|
)
|
||||||
|
.bind(group_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 group_files WHERE group_id = $1 AND collection = $2 AND id = $3")
|
||||||
|
.bind(group_id.into_inner())
|
||||||
|
.bind(collection)
|
||||||
|
.bind(id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
// Row gone; unlink the bytes. A failure here leaves an orphan file
|
||||||
|
// (reclaimed by the sweep) — not fatal.
|
||||||
|
let path = final_path_at(&self.root, &group_owner_dir(group_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, "group files: unlink after delete failed (orphan)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Some(row.into_meta()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(
|
||||||
|
&self,
|
||||||
|
group_id: GroupId,
|
||||||
|
collection: &str,
|
||||||
|
cursor: Option<&str>,
|
||||||
|
limit: u32,
|
||||||
|
) -> Result<FilesListPage, GroupFilesRepoError> {
|
||||||
|
Self::guard_collection(collection)?;
|
||||||
|
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<GroupFileRow> = sqlx::query_as(
|
||||||
|
"SELECT id, collection, name, content_type, size_bytes, \
|
||||||
|
checksum_sha256, created_at, updated_at \
|
||||||
|
FROM group_files \
|
||||||
|
WHERE group_id = $1 AND collection = $2 \
|
||||||
|
AND ($3::uuid IS NULL OR id > $3) \
|
||||||
|
ORDER BY id ASC \
|
||||||
|
LIMIT $4",
|
||||||
|
)
|
||||||
|
.bind(group_id.into_inner())
|
||||||
|
.bind(collection)
|
||||||
|
.bind(last_id)
|
||||||
|
.bind(take)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut files: Vec<FileMeta> = rows.into_iter().map(GroupFileRow::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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct GroupFileRow {
|
||||||
|
id: Uuid,
|
||||||
|
collection: String,
|
||||||
|
name: String,
|
||||||
|
content_type: String,
|
||||||
|
size_bytes: i64,
|
||||||
|
checksum_sha256: String,
|
||||||
|
created_at: DateTime<Utc>,
|
||||||
|
updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupFileRow {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ pub mod gc;
|
|||||||
pub mod group_collection_repo;
|
pub mod group_collection_repo;
|
||||||
pub mod group_docs_repo;
|
pub mod group_docs_repo;
|
||||||
pub mod group_docs_service;
|
pub mod group_docs_service;
|
||||||
|
pub mod group_files_repo;
|
||||||
pub mod group_kv_repo;
|
pub mod group_kv_repo;
|
||||||
pub mod group_kv_service;
|
pub mod group_kv_service;
|
||||||
pub mod group_members_repo;
|
pub mod group_members_repo;
|
||||||
@@ -184,6 +185,7 @@ pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc};
|
|||||||
pub use group_collection_repo::{GroupCollectionResolver, PostgresGroupCollectionResolver};
|
pub use group_collection_repo::{GroupCollectionResolver, PostgresGroupCollectionResolver};
|
||||||
pub use group_docs_repo::{GroupDocsRepo, GroupDocsRepoError, PostgresGroupDocsRepo};
|
pub use group_docs_repo::{GroupDocsRepo, GroupDocsRepoError, PostgresGroupDocsRepo};
|
||||||
pub use group_docs_service::GroupDocsServiceImpl;
|
pub use group_docs_service::GroupDocsServiceImpl;
|
||||||
|
pub use group_files_repo::{FsGroupFilesRepo, GroupFilesRepo, GroupFilesRepoError};
|
||||||
pub use group_kv_repo::{GroupKvRepo, GroupKvRepoError, PostgresGroupKvRepo};
|
pub use group_kv_repo::{GroupKvRepo, GroupKvRepoError, PostgresGroupKvRepo};
|
||||||
pub use group_kv_service::GroupKvServiceImpl;
|
pub use group_kv_service::GroupKvServiceImpl;
|
||||||
pub use group_members_repo::{
|
pub use group_members_repo::{
|
||||||
|
|||||||
@@ -239,6 +239,17 @@ table: group_docs
|
|||||||
created_at: timestamp with time zone NOT NULL default=now()
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
updated_at: timestamp with time zone NOT NULL default=now()
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
|
table: group_files
|
||||||
|
group_id: uuid NOT NULL
|
||||||
|
collection: text NOT NULL
|
||||||
|
id: uuid NOT NULL
|
||||||
|
name: text NOT NULL
|
||||||
|
content_type: text NOT NULL
|
||||||
|
size_bytes: bigint NOT NULL
|
||||||
|
checksum_sha256: text NOT NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
table: group_kv_entries
|
table: group_kv_entries
|
||||||
group_id: uuid NOT NULL
|
group_id: uuid NOT NULL
|
||||||
collection: text NOT NULL
|
collection: text NOT NULL
|
||||||
@@ -522,6 +533,10 @@ indexes on group_docs:
|
|||||||
idx_group_docs_data_gin: public.group_docs USING gin (data jsonb_path_ops)
|
idx_group_docs_data_gin: public.group_docs USING gin (data jsonb_path_ops)
|
||||||
idx_group_docs_group_collection: public.group_docs USING btree (group_id, collection)
|
idx_group_docs_group_collection: public.group_docs USING btree (group_id, collection)
|
||||||
|
|
||||||
|
indexes on group_files:
|
||||||
|
group_files_pkey: public.group_files USING btree (group_id, collection, id)
|
||||||
|
idx_group_files_group_collection: public.group_files USING btree (group_id, collection)
|
||||||
|
|
||||||
indexes on group_kv_entries:
|
indexes on group_kv_entries:
|
||||||
group_kv_entries_pkey: public.group_kv_entries USING btree (group_id, collection, key)
|
group_kv_entries_pkey: public.group_kv_entries USING btree (group_id, collection, key)
|
||||||
idx_group_kv_entries_group_collection: public.group_kv_entries USING btree (group_id, collection)
|
idx_group_kv_entries_group_collection: public.group_kv_entries USING btree (group_id, collection)
|
||||||
@@ -727,7 +742,7 @@ constraints on files_trigger_details:
|
|||||||
[PRIMARY KEY] files_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
[PRIMARY KEY] files_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||||
|
|
||||||
constraints on group_collections:
|
constraints on group_collections:
|
||||||
[CHECK] group_collections_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'docs'::text])))
|
[CHECK] group_collections_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'docs'::text, 'files'::text])))
|
||||||
[CHECK] group_collections_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
[CHECK] group_collections_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||||
[FOREIGN KEY] group_collections_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
[FOREIGN KEY] group_collections_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[FOREIGN KEY] group_collections_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
[FOREIGN KEY] group_collections_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||||
@@ -737,6 +752,10 @@ constraints on group_docs:
|
|||||||
[FOREIGN KEY] group_docs_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
[FOREIGN KEY] group_docs_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] group_docs_pkey: PRIMARY KEY (group_id, collection, id)
|
[PRIMARY KEY] group_docs_pkey: PRIMARY KEY (group_id, collection, id)
|
||||||
|
|
||||||
|
constraints on group_files:
|
||||||
|
[FOREIGN KEY] group_files_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] group_files_pkey: PRIMARY KEY (group_id, collection, id)
|
||||||
|
|
||||||
constraints on group_kv_entries:
|
constraints on group_kv_entries:
|
||||||
[FOREIGN KEY] group_kv_entries_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
[FOREIGN KEY] group_kv_entries_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] group_kv_entries_pkey: PRIMARY KEY (group_id, collection, key)
|
[PRIMARY KEY] group_kv_entries_pkey: PRIMARY KEY (group_id, collection, key)
|
||||||
@@ -881,3 +900,4 @@ constraints on vars:
|
|||||||
0052: group collections
|
0052: group collections
|
||||||
0053: group kv entries
|
0053: group kv entries
|
||||||
0054: group docs
|
0054: group docs
|
||||||
|
0055: group files
|
||||||
|
|||||||
Reference in New Issue
Block a user