Merge pull request 'fix(files): save purchased files atomically with rootless ownership' (#162) from fix/filebrowser-purchase-filing into main
This commit was merged in pull request #162.
This commit is contained in:
@@ -73,13 +73,9 @@ fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json
|
||||
})
|
||||
}
|
||||
|
||||
/// FileBrowser owns its files through a rootless UID mapping. Use its authenticated
|
||||
/// API rather than writing host paths with the backend's unrelated UID. Its
|
||||
/// override=false upload atomically refuses existing names, including races.
|
||||
/// File purchases through an atomic no-clobber write in Files' own namespace.
|
||||
async fn file_purchase_in_files(
|
||||
client: &reqwest::Client,
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
data_dir: &std::path::Path,
|
||||
filename: &str,
|
||||
mime: &str,
|
||||
bytes: &[u8],
|
||||
@@ -91,59 +87,24 @@ async fn file_purchase_in_files(
|
||||
} else {
|
||||
"Documents"
|
||||
};
|
||||
let mut folder_url = reqwest::Url::parse(base_url)?;
|
||||
folder_url
|
||||
.path_segments_mut()
|
||||
.map_err(|_| anyhow::anyhow!("Invalid Files URL"))?
|
||||
.extend(["api", "resources", folder, ""]);
|
||||
let response = client
|
||||
.get(folder_url.clone())
|
||||
.header("X-Auth", token)
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
let response = client
|
||||
.post(folder_url.clone())
|
||||
.header("X-Auth", token)
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() != reqwest::StatusCode::CONFLICT {
|
||||
response.error_for_status()?;
|
||||
}
|
||||
} else {
|
||||
response.error_for_status()?;
|
||||
}
|
||||
let base = std::path::Path::new(filename)
|
||||
let root = data_dir.join("filebrowser");
|
||||
anyhow::ensure!(
|
||||
tokio::fs::metadata(&root).await?.is_dir(),
|
||||
"Files storage is unavailable"
|
||||
);
|
||||
let name = std::path::Path::new(filename)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.filter(|n| !n.is_empty())
|
||||
.unwrap_or("download");
|
||||
let (stem, extension) = match base.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.is_empty() => (stem, format!(".{ext}")),
|
||||
_ => (base, String::new()),
|
||||
};
|
||||
for attempt in 1..=100 {
|
||||
let name = if attempt == 1 {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{stem} ({attempt}){extension}")
|
||||
};
|
||||
let mut url = folder_url.clone();
|
||||
url.path_segments_mut().unwrap().pop_if_empty().push(&name);
|
||||
url.query_pairs_mut().append_pair("override", "false");
|
||||
let response = client
|
||||
.post(url)
|
||||
.header("X-Auth", token)
|
||||
.body(bytes.to_vec())
|
||||
.send()
|
||||
.await?;
|
||||
if response.status() == reqwest::StatusCode::CONFLICT {
|
||||
continue;
|
||||
}
|
||||
response.error_for_status()?;
|
||||
return Ok(format!("{folder}/{name}"));
|
||||
}
|
||||
anyhow::bail!("Too many existing copies; purchased file remains in the purchase cache")
|
||||
let path =
|
||||
crate::container::filebrowser::save_new_file(&root.join(folder), name, bytes).await?;
|
||||
Ok(format!(
|
||||
"{folder}/{}",
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.context("Invalid Files name")?
|
||||
))
|
||||
}
|
||||
|
||||
impl RpcHandler {
|
||||
@@ -755,28 +716,8 @@ impl RpcHandler {
|
||||
|
||||
// The durable purchased-content cache above is primary. A Files copy
|
||||
// remains optional: a stopped FileBrowser must not undo a paid download.
|
||||
let filed = async {
|
||||
let auth = self.handle_filebrowser_token().await?;
|
||||
let token = auth
|
||||
.get("token")
|
||||
.and_then(|v| v.as_str())
|
||||
.context("FileBrowser omitted its authentication token")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
file_purchase_in_files(
|
||||
&client,
|
||||
"http://127.0.0.1:8083",
|
||||
token,
|
||||
&filename,
|
||||
&mime_type,
|
||||
&bytes,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
let filed =
|
||||
file_purchase_in_files(&self.config.data_dir, &filename, &mime_type, &bytes).await;
|
||||
match filed {
|
||||
Ok(path) => tracing::info!("paid download: filed into Files/{path}"),
|
||||
Err(error) => tracing::warn!(
|
||||
|
||||
@@ -1,69 +1,4 @@
|
||||
use super::*;
|
||||
use hyper::{
|
||||
service::{make_service_fn, service_fn},
|
||||
Body, Response, Server,
|
||||
};
|
||||
use std::{
|
||||
collections::VecDeque,
|
||||
convert::Infallible,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
struct FilesApi {
|
||||
url: String,
|
||||
seen: Arc<Mutex<Vec<(String, String, Vec<u8>)>>>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
impl Drop for FilesApi {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
fn files_api(statuses: Vec<u16>) -> FilesApi {
|
||||
let statuses = Arc::new(Mutex::new(VecDeque::from(statuses)));
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
let history = seen.clone();
|
||||
let server = Server::bind(&([127, 0, 0, 1], 0).into());
|
||||
let address = server.local_addr();
|
||||
let service = make_service_fn(move |_| {
|
||||
let statuses = statuses.clone();
|
||||
let seen = history.clone();
|
||||
async move {
|
||||
Ok::<_, Infallible>(service_fn(move |request: hyper::Request<Body>| {
|
||||
let statuses = statuses.clone();
|
||||
let seen = seen.clone();
|
||||
async move {
|
||||
assert_eq!(request.headers().get("X-Auth").unwrap(), "test-session");
|
||||
let method = request.method().to_string();
|
||||
let uri = request.uri().to_string();
|
||||
let body = hyper::body::to_bytes(request.into_body())
|
||||
.await
|
||||
.unwrap()
|
||||
.to_vec();
|
||||
seen.lock().unwrap().push((method, uri, body));
|
||||
let status = statuses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.expect("unexpected extra Files request");
|
||||
Ok::<_, Infallible>(
|
||||
Response::builder()
|
||||
.status(status)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
FilesApi {
|
||||
url: format!("http://{address}"),
|
||||
seen,
|
||||
task: tokio::spawn(async move {
|
||||
server.serve(service).await.unwrap();
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||
@@ -85,82 +20,39 @@ fn first_and_cached_paid_downloads_have_the_same_client_payload_contract() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_uses_authenticated_api_and_preserves_existing_names() {
|
||||
let api = files_api(vec![200, 409, 200]);
|
||||
let client = reqwest::Client::new();
|
||||
let path = file_purchase_in_files(
|
||||
&client,
|
||||
&api.url,
|
||||
"test-session",
|
||||
"../my #file?.txt",
|
||||
"text/plain",
|
||||
b"paid bytes",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(path, "Documents/my #file? (2).txt");
|
||||
let seen = api.seen.lock().unwrap();
|
||||
assert_eq!(seen[0].0, "GET");
|
||||
assert_eq!(seen[0].1, "/api/resources/Documents/");
|
||||
assert_eq!(seen.len(), 3);
|
||||
for (_, uri, body) in &seen[1..] {
|
||||
assert!(uri.contains("override=false"));
|
||||
assert!(uri.contains("%23file%3F"));
|
||||
assert!(!uri.contains("../"));
|
||||
assert_eq!(body, b"paid bytes");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_creates_missing_media_folder() {
|
||||
async fn files_copy_routes_media_and_sanitizes_the_filename() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
tokio::fs::create_dir(dir.path().join("filebrowser"))
|
||||
.await
|
||||
.unwrap();
|
||||
for (mime, folder) in [
|
||||
("image/png", "Photos"),
|
||||
("video/mp4", "Photos"),
|
||||
("audio/ogg", "Music"),
|
||||
("audio/mpeg", "Music"),
|
||||
("text/plain", "Documents"),
|
||||
] {
|
||||
let api = files_api(vec![404, 200, 200]);
|
||||
let path = file_purchase_in_files(
|
||||
&reqwest::Client::new(),
|
||||
&api.url,
|
||||
"test-session",
|
||||
"file",
|
||||
mime,
|
||||
b"bytes",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(path, format!("{folder}/file"));
|
||||
let seen = api.seen.lock().unwrap();
|
||||
assert_eq!(seen[1].0, "POST");
|
||||
assert!(seen[1].1.ends_with('/'));
|
||||
assert!(seen[1].2.is_empty());
|
||||
assert_eq!(seen[2].2, b"bytes");
|
||||
let relative = file_purchase_in_files(dir.path(), "../name #?.bin", mime, b"paid")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(relative.starts_with(&format!("{folder}/name #?")));
|
||||
assert_eq!(
|
||||
tokio::fs::read(dir.path().join("filebrowser").join(relative))
|
||||
.await
|
||||
.unwrap(),
|
||||
b"paid"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn files_copy_fails_without_overwriting_or_claiming_success_on_errors() {
|
||||
for statuses in [
|
||||
vec![401],
|
||||
vec![503],
|
||||
vec![404, 500],
|
||||
vec![200, 507],
|
||||
vec![200, 403],
|
||||
] {
|
||||
let expected = statuses.len();
|
||||
let api = files_api(statuses);
|
||||
assert!(file_purchase_in_files(
|
||||
&reqwest::Client::new(),
|
||||
&api.url,
|
||||
"test-session",
|
||||
"file.txt",
|
||||
"text/plain",
|
||||
b"bytes"
|
||||
)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(api.seen.lock().unwrap().len(), expected);
|
||||
}
|
||||
async fn unavailable_files_storage_is_reported_without_creating_a_fake_installation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(
|
||||
file_purchase_in_files(dir.path(), "name", "text/plain", b"bytes")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(!dir.path().join("filebrowser").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user