2026-02-23 00:51:46 +01:00
|
|
|
|
use bytes::{Bytes, BytesMut};
|
|
|
|
|
|
use futures::{Stream, StreamExt};
|
2026-02-26 00:07:10 +01:00
|
|
|
|
use std::pin::Pin;
|
2026-02-14 01:29:34 +01:00
|
|
|
|
use std::sync::Arc;
|
2025-03-19 19:52:12 +01:00
|
|
|
|
|
|
|
|
|
|
use crate::application::dtos::file_dto::FileDto;
|
2026-05-20 22:56:00 +02:00
|
|
|
|
use crate::application::ports::authorization_ports::AuthorizationEngine;
|
2026-06-24 23:52:01 -06:00
|
|
|
|
use crate::application::ports::blob_storage_ports::BlobStream;
|
|
|
|
|
|
use crate::application::ports::external_mount_ports::MountStat;
|
2026-07-16 16:50:07 +00:00
|
|
|
|
use crate::application::ports::file_ports::{
|
|
|
|
|
|
FileRetrievalUseCase, OptimizedFileContent, RangeContent,
|
|
|
|
|
|
};
|
2026-06-26 01:01:44 +02:00
|
|
|
|
use crate::application::ports::resource_access_hook::ResourceAccessHook;
|
2025-03-19 19:52:12 +01:00
|
|
|
|
use crate::application::ports::storage_ports::FileReadPort;
|
2026-06-24 23:52:01 -06:00
|
|
|
|
use crate::application::services::mount_registry::MountConfig;
|
2025-03-19 19:52:12 +01:00
|
|
|
|
use crate::common::errors::DomainError;
|
2026-05-20 22:56:00 +02:00
|
|
|
|
use crate::domain::services::authorization::{Permission, Resource, Subject};
|
2026-06-24 23:52:01 -06:00
|
|
|
|
use crate::domain::services::external_mount_id::NodeId;
|
2026-03-03 15:36:42 +00:00
|
|
|
|
use crate::infrastructure::repositories::pg::file_blob_read_repository::FileBlobReadRepository;
|
|
|
|
|
|
use crate::infrastructure::services::file_content_cache::FileContentCache;
|
2026-03-04 23:55:08 +01:00
|
|
|
|
use crate::infrastructure::services::image_transcode_service::{
|
|
|
|
|
|
ImageTranscodeService, OutputFormat,
|
|
|
|
|
|
};
|
2026-05-20 22:56:00 +02:00
|
|
|
|
use crate::infrastructure::services::pg_acl_engine::PgAclEngine;
|
2026-03-04 23:55:08 +01:00
|
|
|
|
use tracing::{debug, info};
|
2026-03-07 14:59:32 +01:00
|
|
|
|
use uuid::Uuid;
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
|
|
|
|
|
/// Threshold below which files are served from RAM cache (10 MB).
|
|
|
|
|
|
const CACHE_THRESHOLD: u64 = 10 * 1024 * 1024;
|
2025-03-19 19:52:12 +01:00
|
|
|
|
|
2026-02-12 09:41:25 +01:00
|
|
|
|
/// Service for file retrieval operations
|
2026-02-08 13:40:23 +01:00
|
|
|
|
///
|
|
|
|
|
|
/// Implements a multi-tier download strategy:
|
|
|
|
|
|
/// - Tier 0: Write-behind cache (just-uploaded files still in RAM)
|
|
|
|
|
|
/// - Tier 1: Hot cache + optional WebP transcoding (<10 MB)
|
2026-06-22 08:10:54 +00:00
|
|
|
|
/// - Tier 2: Streaming for everything ≥10 MB — CDC chunk reassembly with the
|
|
|
|
|
|
/// backend's read-ahead (`read_prefetch`); no whole-file buffering.
|
2025-03-19 19:52:12 +01:00
|
|
|
|
pub struct FileRetrievalService {
|
2026-03-03 15:36:42 +00:00
|
|
|
|
file_read: Arc<FileBlobReadRepository>,
|
|
|
|
|
|
content_cache: Option<Arc<FileContentCache>>,
|
|
|
|
|
|
transcode: Option<Arc<ImageTranscodeService>>,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
authz: Option<Arc<PgAclEngine>>,
|
2026-06-25 00:51:16 -06:00
|
|
|
|
/// External-mount classifier for path-based resolution (WebDAV/NextCloud).
|
|
|
|
|
|
/// `None` in the simple/test constructor → no mount support.
|
|
|
|
|
|
mount_router: Option<Arc<crate::application::services::external_mount_router::MountRouter>>,
|
2026-06-26 01:01:44 +02:00
|
|
|
|
/// Optional read-event observer. Currently fans out to the Recent-list
|
|
|
|
|
|
/// recorder; future observers (audit trail, "last seen by", …) attach
|
|
|
|
|
|
/// to the same hook so service code only knows the trait, not the impl.
|
|
|
|
|
|
/// `None` for the test/stub path that constructs via [`Self::new`].
|
|
|
|
|
|
resource_access_hook: Option<Arc<dyn ResourceAccessHook>>,
|
2025-03-19 19:52:12 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl FileRetrievalService {
|
2026-05-20 22:56:00 +02:00
|
|
|
|
/// Backward-compatible constructor (simple pass-through). Without the
|
|
|
|
|
|
/// authorization engine, the `*_owned`/`*_with_perms` methods fail closed.
|
|
|
|
|
|
/// Use `new_with_cache` in production.
|
2026-03-03 15:36:42 +00:00
|
|
|
|
pub fn new(file_repository: Arc<FileBlobReadRepository>) -> Self {
|
2026-02-08 13:40:23 +01:00
|
|
|
|
Self {
|
|
|
|
|
|
file_read: file_repository,
|
|
|
|
|
|
content_cache: None,
|
|
|
|
|
|
transcode: None,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
authz: None,
|
2026-06-25 00:51:16 -06:00
|
|
|
|
mount_router: None,
|
2026-06-26 01:01:44 +02:00
|
|
|
|
resource_access_hook: None,
|
2026-02-08 13:40:23 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 22:56:00 +02:00
|
|
|
|
/// Constructor for blob-storage model: read + content cache + transcode +
|
|
|
|
|
|
/// ReBAC authorization.
|
2026-02-14 17:54:25 +01:00
|
|
|
|
pub fn new_with_cache(
|
2026-03-03 15:36:42 +00:00
|
|
|
|
file_read: Arc<FileBlobReadRepository>,
|
|
|
|
|
|
content_cache: Arc<FileContentCache>,
|
|
|
|
|
|
transcode: Arc<ImageTranscodeService>,
|
2026-05-20 22:56:00 +02:00
|
|
|
|
authz: Arc<PgAclEngine>,
|
2026-02-14 17:54:25 +01:00
|
|
|
|
) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
file_read,
|
|
|
|
|
|
content_cache: Some(content_cache),
|
|
|
|
|
|
transcode: Some(transcode),
|
2026-05-20 22:56:00 +02:00
|
|
|
|
authz: Some(authz),
|
2026-06-25 00:51:16 -06:00
|
|
|
|
mount_router: None,
|
2026-06-26 01:01:44 +02:00
|
|
|
|
resource_access_hook: None,
|
2026-02-14 17:54:25 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 00:51:16 -06:00
|
|
|
|
/// Injects the external-mount classifier so path-based lookups
|
|
|
|
|
|
/// (`get_file_by_path`) can resolve mount paths to the provider.
|
|
|
|
|
|
pub fn with_mount_router(
|
|
|
|
|
|
mut self,
|
|
|
|
|
|
router: Arc<crate::application::services::external_mount_router::MountRouter>,
|
|
|
|
|
|
) -> Self {
|
|
|
|
|
|
self.mount_router = Some(router);
|
|
|
|
|
|
self
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 01:01:44 +02:00
|
|
|
|
/// Builder: attach a [`ResourceAccessHook`] that fires after every
|
|
|
|
|
|
/// authorised `_with_perms` read. Without it the service is silent —
|
|
|
|
|
|
/// existing behaviour for stub / test paths.
|
|
|
|
|
|
pub fn with_resource_access_hook(mut self, hook: Arc<dyn ResourceAccessHook>) -> Self {
|
|
|
|
|
|
self.resource_access_hook = Some(hook);
|
|
|
|
|
|
self
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 23:52:01 -06:00
|
|
|
|
/// Test-only constructor: authorization engine without the cache/transcode
|
|
|
|
|
|
/// tiers. The external-mount read methods only consult `authz` + the
|
|
|
|
|
|
/// provider, so this is sufficient to exercise their authorization.
|
|
|
|
|
|
#[cfg(all(test, integration_tests))]
|
|
|
|
|
|
pub(crate) fn new_with_authz_for_test(
|
|
|
|
|
|
file_read: Arc<FileBlobReadRepository>,
|
|
|
|
|
|
authz: Arc<PgAclEngine>,
|
|
|
|
|
|
) -> Self {
|
|
|
|
|
|
Self {
|
|
|
|
|
|
file_read,
|
|
|
|
|
|
content_cache: None,
|
|
|
|
|
|
transcode: None,
|
|
|
|
|
|
authz: Some(authz),
|
2026-06-25 00:51:16 -06:00
|
|
|
|
mount_router: None,
|
2026-07-21 17:09:36 -06:00
|
|
|
|
resource_access_hook: None,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 01:01:44 +02:00
|
|
|
|
/// Fire the access hook if registered. Called from every `_with_perms`
|
|
|
|
|
|
/// read after the authZ + lookup has succeeded (never on failure
|
|
|
|
|
|
/// paths — denied reads must not surface in Recent).
|
|
|
|
|
|
///
|
|
|
|
|
|
/// `pub` because the WebDAV / NextCloud DAV handlers resolve files
|
|
|
|
|
|
/// by path and authorise via that resolver, not via the
|
|
|
|
|
|
/// `*_with_perms` service methods — they then serve content through
|
|
|
|
|
|
/// the no-perms `get_file_stream` / `get_file_range_stream`. Those
|
|
|
|
|
|
/// handlers must call this directly after their own authZ has
|
|
|
|
|
|
/// passed so cross-protocol downloads (NC desktop, davx5, native
|
|
|
|
|
|
/// `/webdav/`) also surface in Recent.
|
|
|
|
|
|
pub fn notify_file_accessed(&self, caller_id: Uuid, file_id: &str) {
|
|
|
|
|
|
if let Some(hook) = &self.resource_access_hook {
|
|
|
|
|
|
hook.on_file_accessed(caller_id, file_id);
|
2026-06-24 23:52:01 -06:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 11:07:04 +02:00
|
|
|
|
// ── private helpers ──────────────────────────────────────────
|
|
|
|
|
|
|
2026-06-20 14:42:10 +02:00
|
|
|
|
/// Read a file's full content through the streaming API into a single
|
|
|
|
|
|
/// `Bytes` buffer. Working memory stays at one chunk while reading; the
|
|
|
|
|
|
/// returned buffer holds the whole (sub-threshold) file.
|
|
|
|
|
|
async fn read_full(
|
|
|
|
|
|
file_read: &FileBlobReadRepository,
|
|
|
|
|
|
id: &str,
|
|
|
|
|
|
capacity: usize,
|
|
|
|
|
|
) -> Result<Bytes, DomainError> {
|
|
|
|
|
|
let stream = file_read.get_file_stream(id).await?;
|
|
|
|
|
|
let mut stream = Pin::from(stream);
|
2026-07-21 10:25:36 +00:00
|
|
|
|
// Most sub-threshold reads arrive as ONE owned contiguous frame from the
|
|
|
|
|
|
// backend (the local ReaderStream emits ≤256 KiB frames, and a
|
|
|
|
|
|
// sub-threshold blob fits in one). Return that frame directly instead of
|
|
|
|
|
|
// copying the whole payload a second time into a fresh BytesMut; only a
|
|
|
|
|
|
// multi-frame read pays the pre-sized concat — byte-identical output
|
|
|
|
|
|
// (benches/ROUND29.md §C).
|
|
|
|
|
|
let Some(first) = stream.next().await else {
|
|
|
|
|
|
return Ok(Bytes::new());
|
|
|
|
|
|
};
|
|
|
|
|
|
let first = first.map_err(|e| {
|
|
|
|
|
|
DomainError::internal_error("File", format!("Stream read error: {}", e))
|
|
|
|
|
|
})?;
|
|
|
|
|
|
let Some(second) = stream.next().await else {
|
|
|
|
|
|
return Ok(first);
|
|
|
|
|
|
};
|
|
|
|
|
|
let mut buf = BytesMut::with_capacity(capacity.max(first.len()));
|
|
|
|
|
|
buf.extend_from_slice(&first);
|
|
|
|
|
|
buf.extend_from_slice(&second.map_err(|e| {
|
|
|
|
|
|
DomainError::internal_error("File", format!("Stream read error: {}", e))
|
|
|
|
|
|
})?);
|
2026-06-20 14:42:10 +02:00
|
|
|
|
while let Some(chunk) = stream.next().await {
|
|
|
|
|
|
buf.extend_from_slice(&chunk.map_err(|e| {
|
|
|
|
|
|
DomainError::internal_error("File", format!("Stream read error: {}", e))
|
|
|
|
|
|
})?);
|
|
|
|
|
|
}
|
|
|
|
|
|
Ok(buf.freeze())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-20 22:56:00 +02:00
|
|
|
|
/// Helper: require the caller has `perm` on the given file id.
|
|
|
|
|
|
/// Fail-closed if no engine was injected (stub/test path).
|
|
|
|
|
|
async fn require_file(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
file_id: &str,
|
|
|
|
|
|
perm: Permission,
|
|
|
|
|
|
caller_id: Uuid,
|
|
|
|
|
|
) -> Result<(), DomainError> {
|
|
|
|
|
|
let authz = self.authz.as_ref().ok_or_else(|| {
|
|
|
|
|
|
DomainError::internal_error("FileRetrieval", "Authorization engine unavailable")
|
|
|
|
|
|
})?;
|
|
|
|
|
|
let uuid = Uuid::parse_str(file_id).map_err(|_| DomainError::not_found("File", file_id))?;
|
|
|
|
|
|
authz
|
|
|
|
|
|
.require(Subject::User(caller_id), perm, Resource::File(uuid))
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 11:07:04 +02:00
|
|
|
|
/// Engine check for a target folder. `None` is allowed (root namespace,
|
|
|
|
|
|
/// implicitly owned by the caller).
|
|
|
|
|
|
async fn require_target_folder_perm(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
folder_id: Option<&str>,
|
|
|
|
|
|
perm: Permission,
|
|
|
|
|
|
caller_id: Uuid,
|
|
|
|
|
|
) -> Result<(), DomainError> {
|
|
|
|
|
|
let Some(target) = folder_id else {
|
|
|
|
|
|
return Ok(());
|
|
|
|
|
|
};
|
|
|
|
|
|
let authz = self.authz.as_ref().ok_or_else(|| {
|
|
|
|
|
|
DomainError::internal_error("FileRetrieval", "Authorization engine unavailable")
|
|
|
|
|
|
})?;
|
|
|
|
|
|
let uuid = Uuid::parse_str(target).map_err(|_| DomainError::not_found("Folder", target))?;
|
|
|
|
|
|
authz
|
|
|
|
|
|
.require(Subject::User(caller_id), perm, Resource::Folder(uuid))
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-06-24 23:52:01 -06:00
|
|
|
|
/// Authorize then `stat` a file inside an external mount. Authorization
|
|
|
|
|
|
/// collapses onto the mount-root folder (a `Read` grant there covers
|
|
|
|
|
|
/// everything in the mount).
|
|
|
|
|
|
pub async fn stat_mount_file_with_perms(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
cfg: &MountConfig,
|
|
|
|
|
|
node_id: &NodeId,
|
|
|
|
|
|
caller_id: Uuid,
|
|
|
|
|
|
) -> Result<MountStat, DomainError> {
|
|
|
|
|
|
let authz = self.authz.as_ref().ok_or_else(|| {
|
|
|
|
|
|
DomainError::internal_error("FileRetrieval", "Authorization engine unavailable")
|
|
|
|
|
|
})?;
|
|
|
|
|
|
authz
|
|
|
|
|
|
.require(
|
|
|
|
|
|
Subject::User(caller_id),
|
|
|
|
|
|
Permission::Read,
|
|
|
|
|
|
Resource::Folder(cfg.mount_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
cfg.provider.stat(node_id).await
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// Authorize then open a (optionally ranged) read stream over a mount file.
|
|
|
|
|
|
/// `range` is `(start, end_inclusive_opt)`.
|
|
|
|
|
|
pub async fn open_mount_file_with_perms(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
cfg: &MountConfig,
|
|
|
|
|
|
node_id: &NodeId,
|
|
|
|
|
|
caller_id: Uuid,
|
|
|
|
|
|
range: Option<(u64, Option<u64>)>,
|
|
|
|
|
|
) -> Result<BlobStream, DomainError> {
|
|
|
|
|
|
let authz = self.authz.as_ref().ok_or_else(|| {
|
|
|
|
|
|
DomainError::internal_error("FileRetrieval", "Authorization engine unavailable")
|
|
|
|
|
|
})?;
|
|
|
|
|
|
authz
|
|
|
|
|
|
.require(
|
|
|
|
|
|
Subject::User(caller_id),
|
|
|
|
|
|
Permission::Read,
|
|
|
|
|
|
Resource::Folder(cfg.mount_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
cfg.provider.open_read_stream(node_id, range).await
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-25 00:51:16 -06:00
|
|
|
|
/// If `id` is an `ext:` mount FILE id, return the mount config + node id.
|
|
|
|
|
|
/// `None` for native ids, mount roots, or when no router is wired.
|
|
|
|
|
|
fn mount_file_node(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
|
|
|
|
|
) -> Option<(
|
|
|
|
|
|
Arc<crate::application::services::mount_registry::MountConfig>,
|
|
|
|
|
|
NodeId,
|
|
|
|
|
|
)> {
|
|
|
|
|
|
use crate::application::services::external_mount_router::ResolvedId;
|
|
|
|
|
|
match self.mount_router.as_ref()?.classify(id) {
|
|
|
|
|
|
ResolvedId::MountChild { cfg, node_id } => Some((cfg, node_id)),
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-08 13:40:23 +01:00
|
|
|
|
/// Try to transcode image content to WebP and return transcoded variant.
|
|
|
|
|
|
async fn try_transcode(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
|
|
|
|
|
content: &Bytes,
|
|
|
|
|
|
mime: &str,
|
|
|
|
|
|
file_size: u64,
|
|
|
|
|
|
accept_webp: bool,
|
2026-03-03 15:55:15 +00:00
|
|
|
|
) -> Option<(Bytes, Arc<str>)> {
|
2026-02-08 13:40:23 +01:00
|
|
|
|
if !accept_webp {
|
|
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let transcode = self.transcode.as_ref()?;
|
2026-03-03 15:36:42 +00:00
|
|
|
|
if !ImageTranscodeService::should_transcode(mime, file_size) {
|
2026-02-08 13:40:23 +01:00
|
|
|
|
return None;
|
|
|
|
|
|
}
|
|
|
|
|
|
let format = OutputFormat::WebP;
|
2026-02-25 10:28:34 +01:00
|
|
|
|
match transcode
|
|
|
|
|
|
.get_transcoded(id, content.clone(), mime, format)
|
|
|
|
|
|
.await
|
|
|
|
|
|
{
|
2026-02-08 13:40:23 +01:00
|
|
|
|
Ok((transcoded, webp_mime, true)) => {
|
|
|
|
|
|
debug!(
|
|
|
|
|
|
"🖼️ WebP transcode: {} -> {} bytes ({:.0}% smaller)",
|
|
|
|
|
|
content.len(),
|
|
|
|
|
|
transcoded.len(),
|
|
|
|
|
|
(1.0 - transcoded.len() as f64 / content.len().max(1) as f64) * 100.0
|
|
|
|
|
|
);
|
2026-03-03 15:55:15 +00:00
|
|
|
|
Some((transcoded, Arc::from(&*webp_mime)))
|
2026-02-08 13:40:23 +01:00
|
|
|
|
}
|
|
|
|
|
|
_ => None,
|
|
|
|
|
|
}
|
2025-03-19 19:52:12 +01:00
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
/// Core multi-tier download logic shared by `get_file_optimized` and
|
|
|
|
|
|
/// `get_file_optimized_preloaded`.
|
|
|
|
|
|
async fn optimized_inner(
|
2026-02-08 13:40:23 +01:00
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
2026-02-15 17:53:25 +01:00
|
|
|
|
dto: FileDto,
|
2026-02-08 13:40:23 +01:00
|
|
|
|
accept_webp: bool,
|
|
|
|
|
|
prefer_original: bool,
|
|
|
|
|
|
) -> Result<(FileDto, OptimizedFileContent), DomainError> {
|
|
|
|
|
|
let mime_type = dto.mime_type.clone();
|
|
|
|
|
|
let file_size = dto.size;
|
2026-06-15 11:43:15 +00:00
|
|
|
|
// The content cache is content-addressed: keyed by the blob hash, not
|
|
|
|
|
|
// the file id. Identical content deduplicated to one blob on disk is
|
|
|
|
|
|
// then cached ONCE in RAM and shared by every file/user that references
|
|
|
|
|
|
// it — the cache benefits from dedup, not just the disk. Immutable by
|
|
|
|
|
|
// construction, so entries never go stale (no invalidation needed). A
|
|
|
|
|
|
// stub DTO without a hash disables caching for that request rather than
|
|
|
|
|
|
// colliding every hash-less file on the key "".
|
2026-07-21 10:25:36 +00:00
|
|
|
|
let cacheable = !dto.content_hash.is_empty();
|
2026-02-08 13:40:23 +01:00
|
|
|
|
let do_transcode = accept_webp && !prefer_original;
|
|
|
|
|
|
|
|
|
|
|
|
// ── Tier 1: Hot cache + transcode (<10 MB) ──────────
|
|
|
|
|
|
if file_size < CACHE_THRESHOLD {
|
2026-07-21 10:25:36 +00:00
|
|
|
|
// Probe the content cache with a BORROW first: a hit serves the blob
|
|
|
|
|
|
// straight from RAM, and only a miss builds the owned load arguments
|
|
|
|
|
|
// (the quoted-etag / key / id Strings) that a hit would otherwise
|
|
|
|
|
|
// allocate and immediately discard (benches/ROUND29.md §B). On a miss
|
|
|
|
|
|
// `load_and_cache` still coalesces concurrent requests for the same
|
|
|
|
|
|
// blob hash into a SINGLE disk read (single-flight) — no thundering
|
|
|
|
|
|
// herd. Hash-less stub DTOs are uncacheable and stream from disk.
|
2026-06-20 14:42:10 +02:00
|
|
|
|
let content_bytes = if cacheable && let Some(cache) = &self.content_cache {
|
2026-07-21 10:25:36 +00:00
|
|
|
|
if let Some((bytes, ..)) = cache.get(&dto.content_hash).await {
|
|
|
|
|
|
bytes
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let etag: Arc<str> = format!("\"{}\"", dto.content_hash).into();
|
|
|
|
|
|
let ct: Arc<str> = mime_type.clone();
|
|
|
|
|
|
let file_read = Arc::clone(&self.file_read);
|
|
|
|
|
|
let id_owned = id.to_string();
|
|
|
|
|
|
let cap = file_size as usize;
|
|
|
|
|
|
let (bytes, ..) = cache
|
|
|
|
|
|
.load_and_cache(dto.content_hash.to_string(), etag, ct, async move {
|
|
|
|
|
|
debug!("💾 TIER 1 Cache MISS: {} – loading from disk", id_owned);
|
|
|
|
|
|
Self::read_full(&file_read, &id_owned, cap).await
|
|
|
|
|
|
})
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
bytes
|
|
|
|
|
|
}
|
2026-06-20 14:42:10 +02:00
|
|
|
|
} else {
|
|
|
|
|
|
debug!(
|
|
|
|
|
|
"💾 TIER 1 (uncacheable): {} – streaming from disk",
|
2026-07-21 10:25:36 +00:00
|
|
|
|
dto.name
|
2026-06-20 14:42:10 +02:00
|
|
|
|
);
|
|
|
|
|
|
Self::read_full(&self.file_read, id, file_size as usize).await?
|
|
|
|
|
|
};
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
2026-02-14 01:26:02 +01:00
|
|
|
|
if do_transcode
|
2026-02-14 01:29:34 +01:00
|
|
|
|
&& let Some((t, m)) = self
|
|
|
|
|
|
.try_transcode(id, &content_bytes, &mime_type, file_size, true)
|
|
|
|
|
|
.await
|
|
|
|
|
|
{
|
|
|
|
|
|
return Ok((
|
|
|
|
|
|
dto,
|
|
|
|
|
|
OptimizedFileContent::Bytes {
|
2026-02-08 13:40:23 +01:00
|
|
|
|
data: t,
|
|
|
|
|
|
mime_type: m,
|
|
|
|
|
|
was_transcoded: true,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
},
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
return Ok((
|
|
|
|
|
|
dto,
|
|
|
|
|
|
OptimizedFileContent::Bytes {
|
|
|
|
|
|
data: content_bytes,
|
|
|
|
|
|
mime_type: mime_type.clone(),
|
|
|
|
|
|
was_transcoded: false,
|
|
|
|
|
|
},
|
|
|
|
|
|
));
|
2026-02-08 13:40:23 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
// ── Tier 2 + 3: Streaming (≥10 MB) ──────────────────
|
2026-02-14 01:29:34 +01:00
|
|
|
|
info!(
|
2026-02-15 17:53:25 +01:00
|
|
|
|
"📡 TIER 2 STREAMING: {} ({} MB)",
|
2026-07-21 10:25:36 +00:00
|
|
|
|
dto.name,
|
2026-02-14 01:29:34 +01:00
|
|
|
|
file_size / (1024 * 1024)
|
|
|
|
|
|
);
|
2026-02-23 00:51:46 +01:00
|
|
|
|
let stream = self.file_read.get_file_stream(id).await?;
|
|
|
|
|
|
Ok((dto, OptimizedFileContent::Stream(Box::into_pin(stream))))
|
2026-02-08 13:40:23 +01:00
|
|
|
|
}
|
2026-06-19 11:27:11 +00:00
|
|
|
|
|
|
|
|
|
|
/// Batch counterpart of [`FileRetrievalUseCase::get_file`]: resolve many
|
|
|
|
|
|
/// file ids in ONE query instead of one per id. Like `get_file` it
|
|
|
|
|
|
/// performs no per-file authorization — both current callers (ACL grant
|
|
|
|
|
|
/// listing, NextCloud favorites REPORT) resolve ids already vetted by the
|
|
|
|
|
|
/// authorization engine or the favorites table. Missing or trashed ids are
|
|
|
|
|
|
/// absent from the result; callers re-associate by `id`.
|
|
|
|
|
|
pub async fn get_files_by_ids(&self, ids: &[String]) -> Result<Vec<FileDto>, DomainError> {
|
|
|
|
|
|
let files = self.file_read.get_files_by_ids(ids).await?;
|
|
|
|
|
|
Ok(files.into_iter().map(FileDto::from).collect())
|
|
|
|
|
|
}
|
2026-07-20 17:12:38 +00:00
|
|
|
|
|
|
|
|
|
|
/// Batched, authorized multi-get for the ZIP-download multi-select — the
|
|
|
|
|
|
/// batch form of [`FileRetrievalUseCase::get_file_with_perms`] over an
|
|
|
|
|
|
/// explicit id list.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Authorizes `Read` on every id in ONE `check_files_read_batch`
|
|
|
|
|
|
/// round-trip (which resolves all drives in a single query AND primes the
|
|
|
|
|
|
/// resource→drive cache, so the per-file re-check the subsequent stream
|
|
|
|
|
|
/// open performs becomes a cache hit), then fetches only the authorized ids
|
|
|
|
|
|
/// in ONE `get_files_by_ids` query. Replaces `download_zip`'s per-file
|
|
|
|
|
|
/// `require_file` + `get_file` loop — 2 round-trips/file → 2 total.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Returns the authorized, existing files; a denied / missing / unparseable
|
|
|
|
|
|
/// id is simply **absent** from the result (the caller re-associates by id
|
|
|
|
|
|
/// and skips the rest, exactly as the per-file loop skipped a denied /
|
|
|
|
|
|
/// missing `get_file_with_perms`). Read-authorization is identical to the
|
|
|
|
|
|
/// per-file path (`check_files_read_batch` is documented and gated as
|
|
|
|
|
|
/// semantically identical to looping `require`). Recents recording is left
|
|
|
|
|
|
/// to the subsequent per-file stream open (`get_file_stream_with_perms`),
|
|
|
|
|
|
/// which records it (throttle-coalesced) — same net effect as the old
|
|
|
|
|
|
/// loop's `notify_file_accessed` + stream double-notify. Fail-closed if no
|
|
|
|
|
|
/// engine was injected, mirroring [`Self::require_file`].
|
|
|
|
|
|
pub async fn get_files_by_ids_with_perms(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
ids: &[String],
|
|
|
|
|
|
caller_id: Uuid,
|
|
|
|
|
|
) -> Result<Vec<FileDto>, DomainError> {
|
|
|
|
|
|
let authz = self.authz.as_ref().ok_or_else(|| {
|
|
|
|
|
|
DomainError::internal_error("FileRetrieval", "Authorization engine unavailable")
|
|
|
|
|
|
})?;
|
|
|
|
|
|
// Unparseable ids can't be authorized (the per-file path 404s on them),
|
|
|
|
|
|
// so drop them here — they stay absent from the authorized set.
|
|
|
|
|
|
let uuids: Vec<Uuid> = ids.iter().filter_map(|s| Uuid::parse_str(s).ok()).collect();
|
|
|
|
|
|
if uuids.is_empty() {
|
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
|
}
|
|
|
|
|
|
let allowed = authz
|
|
|
|
|
|
.check_files_read_batch(Subject::User(caller_id), &uuids)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
if allowed.is_empty() {
|
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
|
}
|
|
|
|
|
|
let allowed_ids: Vec<String> = allowed.iter().map(Uuid::to_string).collect();
|
|
|
|
|
|
let files = self.file_read.get_files_by_ids(&allowed_ids).await?;
|
|
|
|
|
|
Ok(files.into_iter().map(FileDto::from).collect())
|
2026-06-19 11:27:11 +00:00
|
|
|
|
}
|
2026-07-16 16:50:07 +00:00
|
|
|
|
|
|
|
|
|
|
/// Range read for HTTP Range Requests, cache-aware.
|
|
|
|
|
|
///
|
|
|
|
|
|
/// Media players and PDF viewers fetch these files *exclusively* through
|
|
|
|
|
|
/// Range requests (a `bytes=0-` probe, then seeks) — the plain streaming
|
|
|
|
|
|
/// path paid 1 PG round-trip (blob-hash resolve) + a chunk open/seek for
|
|
|
|
|
|
/// EVERY seek, even when the whole blob was already sitting in the moka
|
|
|
|
|
|
/// content cache as one contiguous `Bytes`. For sub-`CACHE_THRESHOLD`
|
|
|
|
|
|
/// files this now answers from the cache: `Bytes::slice` is a refcount
|
|
|
|
|
|
/// bump — zero copy, zero I/O, zero PG (benches/RANGE-CACHE.md). A miss
|
|
|
|
|
|
/// populates the cache via the same single-flight `get_or_load` Tier 1
|
|
|
|
|
|
/// uses, so one probe warms every subsequent seek. `end` is exclusive
|
|
|
|
|
|
/// (callers pass `Some(last_byte + 1)`), matching the streaming variant.
|
|
|
|
|
|
pub async fn get_file_range_preloaded(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
dto: &FileDto,
|
|
|
|
|
|
start: u64,
|
|
|
|
|
|
end: Option<u64>,
|
|
|
|
|
|
) -> Result<RangeContent, DomainError> {
|
|
|
|
|
|
let cacheable = dto.size < CACHE_THRESHOLD && !dto.content_hash.is_empty();
|
|
|
|
|
|
if cacheable && let Some(cache) = &self.content_cache {
|
2026-07-21 10:25:36 +00:00
|
|
|
|
// Probe with a BORROW first: the video-scrub steady state is a cache
|
|
|
|
|
|
// hit, and a hit must not allocate the owned load args (quoted-etag /
|
|
|
|
|
|
// key / id Strings) it would immediately discard — those are built
|
|
|
|
|
|
// only on the miss branch (benches/ROUND29.md §B). A miss still
|
|
|
|
|
|
// populates via the same single-flight coalescing.
|
|
|
|
|
|
let bytes = if let Some((bytes, ..)) = cache.get(&dto.content_hash).await {
|
|
|
|
|
|
bytes
|
|
|
|
|
|
} else {
|
|
|
|
|
|
let etag: Arc<str> = format!("\"{}\"", dto.content_hash).into();
|
|
|
|
|
|
let ct: Arc<str> = dto.mime_type.clone();
|
|
|
|
|
|
let file_read = Arc::clone(&self.file_read);
|
|
|
|
|
|
let id_owned = dto.id.clone();
|
|
|
|
|
|
let cap = dto.size as usize;
|
|
|
|
|
|
let (bytes, ..) = cache
|
|
|
|
|
|
.load_and_cache(dto.content_hash.to_string(), etag, ct, async move {
|
|
|
|
|
|
debug!("💾 Range cache MISS: {} – loading from disk", id_owned);
|
|
|
|
|
|
Self::read_full(&file_read, &id_owned, cap).await
|
|
|
|
|
|
})
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
bytes
|
|
|
|
|
|
};
|
2026-07-16 16:50:07 +00:00
|
|
|
|
let len = bytes.len() as u64;
|
|
|
|
|
|
let s = start.min(len) as usize;
|
|
|
|
|
|
let e = end.unwrap_or(len).min(len) as usize;
|
|
|
|
|
|
if s <= e {
|
|
|
|
|
|
return Ok(RangeContent::Bytes(bytes.slice(s..e)));
|
|
|
|
|
|
}
|
|
|
|
|
|
// Degenerate range the validator should have rejected — fall
|
|
|
|
|
|
// through to the streaming path rather than panic on slice.
|
|
|
|
|
|
}
|
|
|
|
|
|
let stream = self
|
|
|
|
|
|
.file_read
|
|
|
|
|
|
.get_file_range_stream(&dto.id, start, end)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
Ok(RangeContent::Stream(stream))
|
|
|
|
|
|
}
|
2026-02-15 17:53:25 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
impl FileRetrievalUseCase for FileRetrievalService {
|
|
|
|
|
|
async fn get_file(&self, id: &str) -> Result<FileDto, DomainError> {
|
|
|
|
|
|
let file = self.file_read.get_file(id).await?;
|
|
|
|
|
|
Ok(FileDto::from(file))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 11:07:04 +02:00
|
|
|
|
async fn get_file_with_perms(&self, id: &str, caller_id: Uuid) -> Result<FileDto, DomainError> {
|
2026-05-20 22:56:00 +02:00
|
|
|
|
self.require_file(id, Permission::Read, caller_id).await?;
|
|
|
|
|
|
let file = self.file_read.get_file(id).await?;
|
2026-06-26 01:01:44 +02:00
|
|
|
|
// After authZ + lookup succeed: this caller has just inspected the
|
|
|
|
|
|
// file. Recent listing observes via the hook. The throttle in the
|
|
|
|
|
|
// recording impl coalesces repeat metadata fetches against the same
|
|
|
|
|
|
// file (file viewer poll, browse-then-download pattern).
|
|
|
|
|
|
self.notify_file_accessed(caller_id, id);
|
2026-03-04 17:18:39 +01:00
|
|
|
|
Ok(FileDto::from(file))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-22 00:38:44 +02:00
|
|
|
|
async fn get_file_or_trashed_with_perms(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
|
|
|
|
|
caller_id: Uuid,
|
|
|
|
|
|
) -> Result<FileDto, DomainError> {
|
|
|
|
|
|
self.require_file(id, Permission::Read, caller_id).await?;
|
|
|
|
|
|
let file = self.file_read.get_file_or_trashed(id).await?;
|
|
|
|
|
|
Ok(FileDto::from(file))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 11:07:04 +02:00
|
|
|
|
// FIXME no authorisation at all
|
2026-06-19 07:49:33 +02:00
|
|
|
|
async fn get_file_by_path(&self, path: &str, drive_id: Uuid) -> Result<FileDto, DomainError> {
|
2026-02-15 17:53:25 +01:00
|
|
|
|
// Direct SQL lookup — O(folder_depth) queries instead of O(total_files)
|
2026-05-20 22:56:00 +02:00
|
|
|
|
// NOTE: This method does NOT perform any authorization check. Callers
|
|
|
|
|
|
// that surface its result to a user-driven request MUST resolve the
|
|
|
|
|
|
// file via get_file_owned afterwards, or call authz.require directly.
|
|
|
|
|
|
// (Tracked in the audit punch-list under "path-based lookups".)
|
2026-06-19 07:49:33 +02:00
|
|
|
|
// `drive_id` scope axis prevents cross-drive resolution — without
|
|
|
|
|
|
// it, `find_file_by_path` would return a non-deterministic row
|
|
|
|
|
|
// when the same path exists in multiple drives.
|
2026-06-25 00:51:16 -06:00
|
|
|
|
// External mount: a path descending past a mount root resolves on the
|
|
|
|
|
|
// provider (stat). The mount root itself has no file at its path.
|
|
|
|
|
|
if let Some(router) = &self.mount_router
|
|
|
|
|
|
&& let Some((cfg, remainder)) = router.find_path(drive_id, path)
|
|
|
|
|
|
&& !remainder.is_empty()
|
|
|
|
|
|
{
|
|
|
|
|
|
let node = cfg.provider.resolve_path(&remainder);
|
|
|
|
|
|
let stat = cfg.provider.stat(&node).await?;
|
|
|
|
|
|
if stat.is_dir {
|
|
|
|
|
|
return Err(DomainError::not_found("File", path));
|
|
|
|
|
|
}
|
|
|
|
|
|
let parent = crate::application::services::mount_dto::mount_parent_id(
|
|
|
|
|
|
&cfg,
|
|
|
|
|
|
stat.node_id.as_str(),
|
|
|
|
|
|
);
|
|
|
|
|
|
return Ok(crate::application::services::mount_dto::mount_file_dto(
|
|
|
|
|
|
&cfg, &parent, &stat,
|
|
|
|
|
|
));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-19 07:49:33 +02:00
|
|
|
|
if let Some(file) = self.file_read.find_file_by_path(path, drive_id).await? {
|
2026-02-15 17:53:25 +01:00
|
|
|
|
return Ok(FileDto::from(file));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Err(DomainError::not_found(
|
|
|
|
|
|
"File",
|
|
|
|
|
|
format!("not found at path: {}", path),
|
|
|
|
|
|
))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async fn list_files(&self, folder_id: Option<&str>) -> Result<Vec<FileDto>, DomainError> {
|
|
|
|
|
|
let files = self.file_read.list_files(folder_id).await?;
|
|
|
|
|
|
Ok(files.into_iter().map(FileDto::from).collect())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 11:07:04 +02:00
|
|
|
|
async fn list_files_with_perms(
|
2026-03-05 10:30:39 +01:00
|
|
|
|
&self,
|
|
|
|
|
|
folder_id: Option<&str>,
|
2026-03-07 14:59:32 +01:00
|
|
|
|
owner_id: Uuid,
|
2026-03-05 10:30:39 +01:00
|
|
|
|
) -> Result<Vec<FileDto>, DomainError> {
|
2026-07-02 00:29:33 +02:00
|
|
|
|
// Files always have a `folder_id` in the D0+ model — there is no
|
|
|
|
|
|
// longer any concept of "root-level files". A `None` from the
|
|
|
|
|
|
// caller means the query string was missing `folder_id`; reject
|
2026-07-02 01:06:50 +02:00
|
|
|
|
// with a clear error rather than returning an empty set from a
|
|
|
|
|
|
// meaningless root-level query.
|
2026-07-02 00:29:33 +02:00
|
|
|
|
if folder_id.is_none() {
|
|
|
|
|
|
return Err(DomainError::validation_error("folder_id is required"));
|
2026-05-21 11:07:04 +02:00
|
|
|
|
}
|
2026-07-02 00:29:33 +02:00
|
|
|
|
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
self.list_files(folder_id).await
|
2026-03-05 10:30:39 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
async fn get_file_stream(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
|
|
|
|
|
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
2026-06-25 00:51:16 -06:00
|
|
|
|
if let Some((cfg, node)) = self.mount_file_node(id) {
|
|
|
|
|
|
let s = cfg.provider.open_read_stream(&node, None).await?;
|
|
|
|
|
|
// `Pin<Box<dyn Stream>>` is itself a `Stream`, so re-box it.
|
|
|
|
|
|
return Ok(Box::new(s));
|
|
|
|
|
|
}
|
2026-02-15 17:53:25 +01:00
|
|
|
|
self.file_read.get_file_stream(id).await
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 11:07:04 +02:00
|
|
|
|
async fn get_file_stream_with_perms(
|
2026-03-05 10:30:39 +01:00
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
2026-03-07 14:59:32 +01:00
|
|
|
|
caller_id: Uuid,
|
2026-03-05 10:30:39 +01:00
|
|
|
|
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
2026-05-20 22:56:00 +02:00
|
|
|
|
self.require_file(id, Permission::Read, caller_id).await?;
|
2026-06-26 01:01:44 +02:00
|
|
|
|
self.notify_file_accessed(caller_id, id);
|
2026-03-05 10:30:39 +01:00
|
|
|
|
self.file_read.get_file_stream(id).await
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
/// Multi-tier optimized download.
|
|
|
|
|
|
async fn get_file_optimized(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
|
|
|
|
|
accept_webp: bool,
|
|
|
|
|
|
prefer_original: bool,
|
|
|
|
|
|
) -> Result<(FileDto, OptimizedFileContent), DomainError> {
|
|
|
|
|
|
let file = self.file_read.get_file(id).await?;
|
|
|
|
|
|
let dto = FileDto::from(file);
|
|
|
|
|
|
self.optimized_inner(id, dto, accept_webp, prefer_original)
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 11:07:04 +02:00
|
|
|
|
async fn get_file_optimized_with_perms(
|
2026-03-04 17:18:39 +01:00
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
2026-03-07 14:59:32 +01:00
|
|
|
|
caller_id: Uuid,
|
2026-03-04 17:18:39 +01:00
|
|
|
|
accept_webp: bool,
|
|
|
|
|
|
prefer_original: bool,
|
|
|
|
|
|
) -> Result<(FileDto, OptimizedFileContent), DomainError> {
|
2026-05-20 22:56:00 +02:00
|
|
|
|
self.require_file(id, Permission::Read, caller_id).await?;
|
|
|
|
|
|
let file = self.file_read.get_file(id).await?;
|
2026-03-04 17:18:39 +01:00
|
|
|
|
let dto = FileDto::from(file);
|
2026-06-26 01:01:44 +02:00
|
|
|
|
self.notify_file_accessed(caller_id, id);
|
2026-03-04 17:18:39 +01:00
|
|
|
|
self.optimized_inner(id, dto, accept_webp, prefer_original)
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-15 17:53:25 +01:00
|
|
|
|
/// Like `get_file_optimized` but skips the metadata re-fetch.
|
|
|
|
|
|
async fn get_file_optimized_preloaded(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
|
|
|
|
|
file_dto: FileDto,
|
|
|
|
|
|
accept_webp: bool,
|
|
|
|
|
|
prefer_original: bool,
|
|
|
|
|
|
) -> Result<(FileDto, OptimizedFileContent), DomainError> {
|
|
|
|
|
|
self.optimized_inner(id, file_dto, accept_webp, prefer_original)
|
|
|
|
|
|
.await
|
|
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
|
|
|
|
|
|
|
/// Range-based streaming for HTTP Range Requests.
|
|
|
|
|
|
async fn get_file_range_stream(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
|
|
|
|
|
start: u64,
|
|
|
|
|
|
end: Option<u64>,
|
|
|
|
|
|
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
2026-06-25 00:51:16 -06:00
|
|
|
|
if let Some((cfg, node)) = self.mount_file_node(id) {
|
|
|
|
|
|
// The native range convention is exclusive-end; the provider wants
|
|
|
|
|
|
// an inclusive end.
|
|
|
|
|
|
let range = Some((start, end.map(|e| e.saturating_sub(1))));
|
|
|
|
|
|
let s = cfg.provider.open_read_stream(&node, range).await?;
|
|
|
|
|
|
return Ok(Box::new(s));
|
|
|
|
|
|
}
|
2026-02-08 13:40:23 +01:00
|
|
|
|
self.file_read.get_file_range_stream(id, start, end).await
|
2025-03-19 19:52:12 +01:00
|
|
|
|
}
|
2026-02-24 12:18:38 +01:00
|
|
|
|
|
2026-05-21 11:07:04 +02:00
|
|
|
|
async fn get_file_range_stream_with_perms(
|
2026-03-04 17:18:39 +01:00
|
|
|
|
&self,
|
|
|
|
|
|
id: &str,
|
2026-03-07 14:59:32 +01:00
|
|
|
|
caller_id: Uuid,
|
2026-03-04 17:18:39 +01:00
|
|
|
|
start: u64,
|
|
|
|
|
|
end: Option<u64>,
|
|
|
|
|
|
) -> Result<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>, DomainError> {
|
2026-05-20 22:56:00 +02:00
|
|
|
|
self.require_file(id, Permission::Read, caller_id).await?;
|
2026-06-26 01:01:44 +02:00
|
|
|
|
// Range requests are bursty (video seeks, NC chunked downloads) —
|
|
|
|
|
|
// the recording hook's per-(caller, file) throttle absorbs the
|
|
|
|
|
|
// storm so one watched video lands as one Recent row, not 1000.
|
|
|
|
|
|
self.notify_file_accessed(caller_id, id);
|
2026-03-04 17:18:39 +01:00
|
|
|
|
self.file_read.get_file_range_stream(id, start, end).await
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-21 11:07:04 +02:00
|
|
|
|
// TODO: check: no permission check
|
2026-02-26 00:07:10 +01:00
|
|
|
|
async fn stream_files_in_subtree(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
folder_id: &str,
|
|
|
|
|
|
) -> Result<Pin<Box<dyn Stream<Item = Result<FileDto, DomainError>> + Send>>, DomainError> {
|
|
|
|
|
|
let inner = self.file_read.stream_files_in_subtree(folder_id).await?;
|
|
|
|
|
|
let mapped = inner.map(|r| r.map(FileDto::from));
|
|
|
|
|
|
Ok(Box::pin(mapped))
|
2026-02-24 12:18:38 +01:00
|
|
|
|
}
|
2026-02-24 15:11:56 +01:00
|
|
|
|
|
|
|
|
|
|
async fn list_files_batch(
|
|
|
|
|
|
&self,
|
|
|
|
|
|
folder_id: Option<&str>,
|
2026-07-16 14:20:20 +00:00
|
|
|
|
after_name: Option<&str>,
|
2026-02-24 15:11:56 +01:00
|
|
|
|
limit: i64,
|
|
|
|
|
|
) -> Result<Vec<FileDto>, DomainError> {
|
2026-02-25 10:28:34 +01:00
|
|
|
|
let files = self
|
|
|
|
|
|
.file_read
|
2026-07-16 14:20:20 +00:00
|
|
|
|
.list_files_batch(folder_id, after_name, limit)
|
2026-02-25 10:28:34 +01:00
|
|
|
|
.await?;
|
2026-02-24 15:11:56 +01:00
|
|
|
|
Ok(files.into_iter().map(FileDto::from).collect())
|
|
|
|
|
|
}
|
2026-03-05 10:30:39 +01:00
|
|
|
|
|
2026-05-21 11:07:04 +02:00
|
|
|
|
async fn list_files_batch_with_perms(
|
2026-03-05 10:30:39 +01:00
|
|
|
|
&self,
|
|
|
|
|
|
folder_id: Option<&str>,
|
2026-03-07 14:59:32 +01:00
|
|
|
|
owner_id: Uuid,
|
2026-07-16 14:20:20 +00:00
|
|
|
|
after_name: Option<&str>,
|
2026-03-05 10:30:39 +01:00
|
|
|
|
limit: i64,
|
|
|
|
|
|
) -> Result<Vec<FileDto>, DomainError> {
|
2026-06-25 00:51:16 -06:00
|
|
|
|
// External mount: list files from the provider (WebDAV/NextCloud
|
|
|
|
|
|
// PROPFIND Depth:1 file loop). Authz collapses on the mount root.
|
2026-07-21 17:09:36 -06:00
|
|
|
|
// Keyset pagination by name mirrors `paginate_mount_entries` —
|
|
|
|
|
|
// provider order isn't guaranteed, so sort before slicing on
|
|
|
|
|
|
// `after_name`.
|
2026-06-25 00:51:16 -06:00
|
|
|
|
if let Some(fid) = folder_id
|
|
|
|
|
|
&& let Some(router) = &self.mount_router
|
|
|
|
|
|
{
|
|
|
|
|
|
use crate::application::services::external_mount_router::ResolvedId;
|
|
|
|
|
|
let resolved = match router.classify(fid) {
|
|
|
|
|
|
ResolvedId::Regular => None,
|
2026-07-21 17:09:36 -06:00
|
|
|
|
ResolvedId::MountRoot { cfg } => Some((cfg, NodeId::default())),
|
2026-06-25 00:51:16 -06:00
|
|
|
|
ResolvedId::MountChild { cfg, node_id } => Some((cfg, node_id)),
|
|
|
|
|
|
};
|
|
|
|
|
|
if let Some((cfg, node)) = resolved {
|
|
|
|
|
|
if let Some(authz) = &self.authz {
|
|
|
|
|
|
authz
|
|
|
|
|
|
.require(
|
|
|
|
|
|
Subject::User(owner_id),
|
|
|
|
|
|
Permission::Read,
|
|
|
|
|
|
Resource::Folder(cfg.mount_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
.await?;
|
|
|
|
|
|
}
|
2026-07-21 17:09:36 -06:00
|
|
|
|
let mut entries: Vec<_> = cfg
|
|
|
|
|
|
.provider
|
|
|
|
|
|
.list_dir(&node)
|
|
|
|
|
|
.await?
|
|
|
|
|
|
.into_iter()
|
2026-06-25 00:51:16 -06:00
|
|
|
|
.filter(|e| !e.is_dir)
|
2026-07-21 17:09:36 -06:00
|
|
|
|
.collect();
|
|
|
|
|
|
entries.sort_by_key(|e| e.name.to_lowercase());
|
|
|
|
|
|
let start = match after_name {
|
|
|
|
|
|
Some(name) => entries
|
|
|
|
|
|
.iter()
|
|
|
|
|
|
.position(|e| name.eq_ignore_ascii_case(&e.name))
|
|
|
|
|
|
.map(|i| i + 1)
|
|
|
|
|
|
.unwrap_or(0),
|
|
|
|
|
|
None => 0,
|
|
|
|
|
|
};
|
|
|
|
|
|
let files: Vec<FileDto> = entries
|
|
|
|
|
|
.into_iter()
|
|
|
|
|
|
.skip(start)
|
2026-06-25 00:51:16 -06:00
|
|
|
|
.take(limit.max(0) as usize)
|
|
|
|
|
|
.map(|e| {
|
2026-07-21 17:09:36 -06:00
|
|
|
|
crate::application::services::mount_dto::mount_entry_file_dto(&cfg, fid, &e)
|
2026-06-25 00:51:16 -06:00
|
|
|
|
})
|
|
|
|
|
|
.collect();
|
|
|
|
|
|
return Ok(files);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 01:06:50 +02:00
|
|
|
|
// Post-D0: every file lives in a folder — `storage.files.folder_id`
|
|
|
|
|
|
// is NOT NULL. `folder_id = None` means the caller is asking for
|
|
|
|
|
|
// "root-level files", which by design return an empty set: the
|
|
|
|
|
|
// WebDAV synthetic root only lists drive-root folders as
|
|
|
|
|
|
// children. Skip the DB round-trip and the pre-D7 owner-fallback
|
|
|
|
|
|
// query (which used to hit `_for_owner` and would have driven
|
|
|
|
|
|
// the `files.user_id` filter this refactor is retiring).
|
|
|
|
|
|
let Some(_) = folder_id else {
|
|
|
|
|
|
return Ok(Vec::new());
|
|
|
|
|
|
};
|
|
|
|
|
|
self.require_target_folder_perm(folder_id, Permission::Read, owner_id)
|
|
|
|
|
|
.await?;
|
2026-03-05 10:30:39 +01:00
|
|
|
|
let files = self
|
|
|
|
|
|
.file_read
|
2026-07-16 14:20:20 +00:00
|
|
|
|
.list_files_batch(folder_id, after_name, limit)
|
2026-03-05 10:30:39 +01:00
|
|
|
|
.await?;
|
|
|
|
|
|
Ok(files.into_iter().map(FileDto::from).collect())
|
|
|
|
|
|
}
|
2026-02-14 01:29:34 +01:00
|
|
|
|
}
|