feat(share): public folder browsing API + range support + zip

Five new public endpoints under /api/s/{token}/...:
  GET /contents
  GET /contents/{folder_id}
  GET /file/{file_id}
  GET /zip
  GET /zip/{folder_id}

All honour the unlock cookie from /verify, so password-protected
folder shares work end-to-end.

Folder/file IDs are validated against the share subtree via a single
ltree containment query (O(log N) on the existing GiST index).
Out-of-scope IDs return 404.

download_shared_file refactored to a Range/304/206/416-aware
serve_share_file helper, shared with the new /file/{file_id}
endpoint. content_disposition extracted from FileHandler so RFC 5987
formatting is identical across auth and share download paths.
This commit is contained in:
abnvle
2026-05-05 22:41:55 +02:00
parent 8527765bf2
commit d15d7b8f8e
8 changed files with 725 additions and 85 deletions
+48 -46
View File
@@ -967,56 +967,15 @@ impl FileHandler {
/// Build a Content-Disposition header value.
///
/// Uses RFC 5987 `filename*=UTF-8''<percent-encoded>` to safely handle
/// filenames with quotes, non-ASCII characters, or other special chars.
/// A sanitised ASCII `filename=` fallback is included for legacy clients.
/// Build a `Content-Disposition` header value for an authenticated download,
/// honouring the `?inline=true|1` query param. Delegates to the shared
/// `build_content_disposition` so the share-link path produces identical
/// header values for the same `(name, mime)` pair.
fn content_disposition(name: &str, mime: &str, params: &HashMap<String, String>) -> String {
let force_inline = params
.get("inline")
.is_some_and(|v| v == "true" || v == "1");
let disposition = if force_inline
|| mime.starts_with("image/")
|| mime == "application/pdf"
|| mime.starts_with("video/")
|| mime.starts_with("audio/")
{
"inline"
} else {
"attachment"
};
// RFC 5987 percent-encode for filename* (attr-char safe set)
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
// Characters that DON'T need encoding per RFC 5987 attr-char:
// ALPHA / DIGIT / "!" / "#" / "$" / "&" / "+" / "-" / "." /
// "^" / "_" / "`" / "|" / "~"
const RFC5987_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'!')
.remove(b'#')
.remove(b'$')
.remove(b'&')
.remove(b'+')
.remove(b'-')
.remove(b'.')
.remove(b'^')
.remove(b'_')
.remove(b'`')
.remove(b'|')
.remove(b'~');
let encoded = utf8_percent_encode(name, RFC5987_SET).to_string();
// ASCII fallback: strip anything outside printable ASCII and
// replace '"' and '\\' to prevent header injection.
let ascii_safe: String = name
.chars()
.filter(|c| c.is_ascii_graphic() || *c == ' ')
.map(|c| match c {
'"' | '\\' => '_',
_ => c,
})
.collect();
format!("{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}")
build_content_disposition(name, mime, force_inline)
}
/// Build a 201 Created JSON response.
@@ -1073,6 +1032,49 @@ pub struct MoveFilePayload {
pub folder_id: Option<String>,
}
/// RFC 5987-compliant `Content-Disposition` with both ASCII fallback and
/// `filename*=UTF-8''...` for non-ASCII filenames.
pub(super) fn build_content_disposition(name: &str, mime: &str, force_inline: bool) -> String {
let disposition = if force_inline
|| mime.starts_with("image/")
|| mime == "application/pdf"
|| mime.starts_with("video/")
|| mime.starts_with("audio/")
{
"inline"
} else {
"attachment"
};
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
// RFC 5987 attr-char safe set (no encoding needed for these).
const RFC5987_SET: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'!')
.remove(b'#')
.remove(b'$')
.remove(b'&')
.remove(b'+')
.remove(b'-')
.remove(b'.')
.remove(b'^')
.remove(b'_')
.remove(b'`')
.remove(b'|')
.remove(b'~');
let encoded = utf8_percent_encode(name, RFC5987_SET).to_string();
let ascii_safe: String = name
.chars()
.filter(|c| c.is_ascii_graphic() || *c == ' ')
.map(|c| match c {
'"' | '\\' => '_',
_ => c,
})
.collect();
format!("{disposition}; filename=\"{ascii_safe}\"; filename*=UTF-8''{encoded}")
}
// ── Route handlers (free functions) ──────────────────────────────────────────
//
// All annotated route functions live here rather than as methods on FileHandler