Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions docs/rfcs/RFC-012-library-browser.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
# RFC-012 — The library browser: folders as a view, filing as metadata

- **Status**: Proposed
- **Implemented by**: not started. The *Status* field above never flips in this
project; this line is the one that will say what is running.
- **Implemented by**: in progress, in the order of "The order of the work"
below: [#298](https://github.com/InstaZDLL/waveflow-server/pull/298) (the
context menu and Properties, with `relative_path` on the track) and the pull
request adding download (`?download=1` on the stream ticket). The *Status*
field above never flips in this project; this line is the one that says what
is running.
- **Date**: 2026-10-21
- **Revised**: 2026-10-21, after review on #297: the **Received** folder's
contract, the depth check on a move, the folder cap that bounds the tree's
Expand Down
161 changes: 157 additions & 4 deletions src/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,22 @@ pub struct StreamQuery {
pub offset_ms: u64,
}

/// `?download=1` on a stream ticket (RFC-012 decision 5).
///
/// Its own extractor rather than a field on [`StreamQuery`], which the
/// Subsonic façade and the bearer route share and which has no business
/// learning a header choice only the ticket route makes.
#[derive(Debug, Default, Deserialize)]
pub struct DownloadQuery {
download: Option<String>,
}

impl DownloadQuery {
fn asked(&self) -> bool {
matches!(self.download.as_deref(), Some("1" | "true"))
}
}

#[derive(Debug)]
pub enum MediaError {
Unauthorized,
Expand Down Expand Up @@ -1190,20 +1206,23 @@ pub async fn create_stream_ticket(
("ticket" = String, Path),
("format" = Option<String>, Query, description = "raw, mp3 or opus"),
("bitrate" = Option<u32>, Query),
("offset_ms" = Option<u64>, Query)
("offset_ms" = Option<u64>, Query),
("download" = Option<String>, Query, description = "1: the original, as an attachment named after the file (RFC-012)")
),
responses(
(status = 200, description = "Original or live transcoded audio"),
(status = 206, description = "Original or completed cached transcode range"),
(status = 404),
(status = 416),
(status = 422, description = "A download asked for together with a transcode or an offset"),
(status = 429)
)
)]
pub async fn stream_with_ticket(
State(state): State<AppState>,
AxumPath(ticket): AxumPath<String>,
Query(query): Query<StreamQuery>,
Query(download): Query<DownloadQuery>,
headers: HeaderMap,
) -> Result<Response, MediaError> {
// An invalid, forged or expired ticket is indistinguishable from an unknown
Expand All @@ -1226,9 +1245,118 @@ pub async fn stream_with_ticket(
let range = headers
.get(header::RANGE)
.and_then(|value| value.to_str().ok());
state.media.serve(user_id, track, query, range).await
if !download.asked() {
return state.media.serve(user_id, track, query, range).await;
}
// A download is the file. A transcode, a bit rate or an offset would make
// it something else under the file's own name, so asking for one with it
// is refused rather than quietly ignored.
if query.format != OutputFormat::Raw || query.bitrate.is_some() || query.offset_ms != 0 {
return Err(MediaError::InvalidRequest);
}
let name = download_name(&state, user_id, &track).await;
let mut response = state.media.serve(user_id, track, query, range).await?;
response
.headers_mut()
.insert(header::CONTENT_DISPOSITION, attachment(&name));
Ok(response)
}

/// The name a downloaded file is saved under.
///
/// A scanned track's own basename, which is what its owner called it. A track
/// received through RFC-008 is named by its hash on disk, so it is saved as
/// `Artist - Title.ext` from its tags instead — the hash is truthful and of no
/// use to anyone who opens their downloads folder.
async fn download_name(state: &AppState, user_id: Uuid, track: &StreamTrack) -> String {
let path = Path::new(&track.relative_path);
let basename = path
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("track")
.to_owned();
let received = track
.relative_path
.split('/')
.next()
.is_some_and(|first| first == crate::services::uploads::MANAGED_DIR);
if !received {
return basename;
}
let extension = path.extension().and_then(OsStr::to_str).unwrap_or("");
let song = state
.services
.songs_by_ids(user_id, &[track.id])
.await
.ok()
.and_then(|songs| songs.into_iter().next());
let Some(song) = song else {
return basename;
};
let stem = match song
.artist
.as_deref()
.map(str::trim)
.filter(|a| !a.is_empty())
{
Some(artist) => format!("{artist} - {}", song.title.trim()),
None => song.title.trim().to_owned(),
};
if stem.is_empty() {
return basename;
}
if extension.is_empty() {
stem
} else {
format!("{stem}.{extension}")
}
}

/// `attachment` with the name in both forms RFC 6266 gives it.
///
/// `filename` is an ASCII fallback, every character a browser could misread
/// in a quoted string — a quote, a backslash, a control, anything outside
/// ASCII — replaced by `_`, and a separator too, so no browser builds a path
/// from it. `filename*` carries the real name, percent-encoded UTF-8, and
/// every current browser prefers it.
fn attachment(name: &str) -> HeaderValue {
let cleaned: String = name
.chars()
.map(|c| match c {
'/' | '\\' => '_',
c if c.is_control() => '_',
c => c,
})
.collect();
let fallback: String = cleaned
.chars()
.map(|c| match c {
' '..='~' if c != '"' && c != '\\' => c,
_ => '_',
})
.collect();
let encoded = percent_encoding::utf8_percent_encode(&cleaned, ATTR_CHAR).to_string();
HeaderValue::from_str(&format!(
"attachment; filename=\"{fallback}\"; filename*=UTF-8''{encoded}"
))
.unwrap_or_else(|_| HeaderValue::from_static("attachment"))
}

/// Everything but RFC 5987's `attr-char` is percent-encoded in `filename*`.
const ATTR_CHAR: &percent_encoding::AsciiSet = &percent_encoding::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'~');

#[utoipa::path(
get,
path = "/api/v2/tracks/{track_id}/stream",
Expand Down Expand Up @@ -1661,10 +1789,35 @@ mod tests {
use tokio::sync::{Mutex, Semaphore};

use super::{
cache_key, parse_range, prune_cache, secure_track_path, starts_at_the_first_byte,
MediaInner, MediaService, OutputFormat,
attachment, cache_key, parse_range, prune_cache, secure_track_path,
starts_at_the_first_byte, MediaInner, MediaService, OutputFormat,
};

/// A download's name in both forms, whatever it holds.
///
/// The fallback is what an old browser builds a path from, so a quote, a
/// backslash, a separator or anything outside ASCII must not survive in
/// it; `filename*` must carry the real name, accents and all.
#[test]
fn a_download_name_is_safe_in_both_forms() {
let header = attachment("Björk - Hyperballad \"live\" 1/2\\3.flac");
let header = header.to_str().unwrap();
assert_eq!(
header,
"attachment; filename=\"Bj_rk - Hyperballad _live_ 1_2_3.flac\"; \
filename*=UTF-8''Bj%C3%B6rk%20-%20Hyperballad%20%22live%22%201_2_3.flac"
);
// A control character is no part of a name.
let header = attachment("a\nb.flac");
assert!(
header
.to_str()
.unwrap()
.starts_with("attachment; filename=\"a_b.flac\""),
"{header:?}"
);
}

#[test]
fn ranges_are_bounded_and_validated() {
assert_eq!(parse_range("bytes=2-5", 10), Some((2, 5)));
Expand Down
2 changes: 1 addition & 1 deletion src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1374,7 +1374,7 @@ mod shares;
mod songs;
mod sync;
mod track_metadata;
mod uploads;
pub(crate) mod uploads;

/// Crate-visible only: the adapter builds the browser-less journey's address
/// from it, and nothing outside this binary has a use for it.
Expand Down
53 changes: 53 additions & 0 deletions tests/media.rs
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,59 @@ async fn stream_tickets_authorise_browser_playback_without_a_bearer() {
.unwrap();
assert_eq!(ranged.status(), StatusCode::PARTIAL_CONTENT);

// Playing sends no attachment header; that is a download's alone.
assert!(played.headers().get("content-disposition").is_none());
let played_length = played.headers()["content-length"].clone();

// `?download=1` is the same file, saved under the file's own name
// (RFC-012 decision 5) — no other route, no other read.
let basename = tracks[0]["relative_path"]
.as_str()
.unwrap()
.rsplit('/')
.next()
.unwrap()
.to_owned();
let downloaded = router
.clone()
.oneshot(
Request::get(format!("{url}?download=1"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(downloaded.status(), StatusCode::OK);
assert_eq!(downloaded.headers()["content-length"], played_length);
let disposition = downloaded.headers()["content-disposition"]
.to_str()
.unwrap()
.to_owned();
assert!(
disposition.starts_with("attachment; filename=\"")
&& disposition.contains(&format!("filename*=UTF-8''{basename}")),
"{disposition}"
);

// A download is the file: asked for with a transcode, an offset or a bit
// rate, it is refused rather than handed over under the file's name.
for refused in [
format!("{url}?download=1&format=mp3"),
format!("{url}?download=1&offset_ms=1000"),
format!("{url}?download=1&bitrate=128"),
] {
let response = router
.clone()
.oneshot(Request::get(&refused).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::UNPROCESSABLE_ENTITY,
"{refused}"
);
}

// An expired ticket is refused even though it is otherwise well formed.
let expired = waveflow_server::stream_ticket::mint(
&secret_box,
Expand Down
Loading
Loading