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
86 changes: 78 additions & 8 deletions crates/kaiten-client/src/api/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ use crate::models::{CardFile, FileRef};
///
/// SECURITY: Kaiten's classic storage serves uploaded files from a public
/// (unguessable) URL without authentication — never attach secrets. The
/// newer storage serves files through an authenticated API path that
/// redirects to storage; [`Files::download`] sends the API token only to
/// the API origin, never to a file or storage host.
/// newer storage answers its authenticated API path with the file's
/// metadata, whose `url` is a signed storage link valid for seconds;
/// [`Files::download`] sends the API token only to the API origin and
/// fetches the signed link without credentials.
pub struct Files<'a> {
pub(crate) client: &'a KaitenClient,
}
Expand All @@ -24,10 +25,13 @@ impl Files<'_> {
}

/// Download an attachment's content. An absolute `url` (classic storage)
/// is fetched as is; a host-root-relative one (newer storage) is resolved
/// against the API origin. The token goes only to the API origin — never
/// to the public file host, nor to a storage host reached through a
/// redirect. The whole body is held in memory, as with [`Files::attach`].
/// is fetched as is. A host-root-relative one (newer storage) is resolved
/// against the API origin, where the API answers with the file's
/// metadata rather than its bytes: the `url` in it is a signed storage
/// link that expires within seconds, fetched immediately and without
/// credentials. The token goes only to the API origin — never to the
/// public file host nor to storage. The whole body is held in memory, as
/// with [`Files::attach`].
pub async fn download(&self, file: &CardFile) -> Result<Vec<u8>> {
let raw = file.url.as_deref().ok_or_else(|| {
invalid_input(format!(
Expand All @@ -37,7 +41,51 @@ impl Files<'_> {
))
})?;
let url = resolve_file_url(self.client.base_url(), raw)?;
self.client.get_bytes(&url).await
let fetched = self.client.get_bytes(&url).await?;
// Metadata comes only from the API itself: the answer must be JSON and
// must have been served by the very url we asked for. A redirect means
// the API handed us over to storage, and whatever comes back — even a
// JSON attachment — is the file.
let is_metadata = fetched.url == url
&& url.origin() == self.client.base_url().origin()
&& is_json(fetched.content_type.as_deref());
if !is_metadata {
return Ok(fetched.bytes);
}
let text = String::from_utf8(fetched.bytes).map_err(|e| {
invalid_input(format!(
"file metadata for `{}` is not UTF-8: {e}",
file.name
))
})?;
let location: FileLocation = KaitenClient::decode(&text)?;
let signed = location.url.ok_or_else(|| {
invalid_input(format!(
"file metadata for `{}` ({}) has no download url",
file.name,
FileRef::from(file)
))
})?;
let signed = url::Url::parse(&signed)
.map_err(|e| invalid_input(format!("storage url `{signed}` is not valid: {e}")))?;
// The signed link is valid for seconds; it is fetched right away, and
// the API-side 429 retries all happen before it is minted.
match self.client.get_bytes(&signed).await {
Ok(fetched) => Ok(fetched.bytes),
Err(KaitenError::Api {
status,
message,
body,
}) => Err(KaitenError::Api {
status,
message: format!(
"storage refused the signed link for `{}` (it is valid only for seconds): {message}",
file.name
),
body,
}),
Err(other) => Err(other),
}
}

/// [`Files::download`] straight into `path` (created or truncated;
Expand Down Expand Up @@ -79,12 +127,34 @@ impl Files<'_> {
}
}

/// What the newer storage's API path returns for a file: its metadata, of
/// which only the signed storage `url` matters here.
#[derive(serde::Deserialize)]
struct FileLocation {
url: Option<String>,
}

/// Media types are case-insensitive; parameters (`; charset=…`) are ignored.
fn is_json(content_type: Option<&str>) -> bool {
content_type
.and_then(|ct| ct.split(';').next())
.is_some_and(|media| media.trim().eq_ignore_ascii_case("application/json"))
}

/// Absolute URLs pass through; a path is resolved against the API **origin**
/// (`/api/v1/...` lives next to, not under, the `/api/latest` base) and must
/// stay there — WHATWG parsing would otherwise let `/\host/x` wander off to
/// another host, so the invariant is enforced here and not left to the
/// caller's token check.
pub(crate) fn resolve_file_url(base_url: &url::Url, raw: &str) -> Result<url::Url> {
// A fragment is never sent, so the answering url never has one: drop it
// here so "the url that answered is the one we asked for" holds.
let mut resolved = resolve_file_url_raw(base_url, raw)?;
resolved.set_fragment(None);
Ok(resolved)
}

fn resolve_file_url_raw(base_url: &url::Url, raw: &str) -> Result<url::Url> {
match url::Url::parse(raw) {
Ok(url) => Ok(url),
Err(url::ParseError::RelativeUrlWithoutBase) => {
Expand Down
25 changes: 22 additions & 3 deletions crates/kaiten-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,8 @@ impl KaitenClient {
}
}

/// GET `url` and return the body bytes (attachment downloads).
/// GET `url` and return where the answer finally came from (after
/// redirects), its content type and body bytes (attachment downloads).
///
/// The bearer token is sent only when `url` is on the API origin — never
/// to the public file host, nor to a storage host reached through a
Expand All @@ -261,7 +262,7 @@ impl KaitenClient {
/// downgrade would keep it, which only Kaiten itself could trigger).
/// Retry and tracing mirror `send_with_retry`; the body is binary and
/// never traced; errors go through `download_error`.
pub(crate) async fn get_bytes(&self, url: &url::Url) -> Result<Vec<u8>> {
pub(crate) async fn get_bytes(&self, url: &url::Url) -> Result<Fetched> {
let with_auth = url.origin() == self.base_url.origin();
let mut retries = 0u32;
loop {
Expand Down Expand Up @@ -300,7 +301,17 @@ impl KaitenClient {
let text = resp.text().await?;
return Err(download_error(status, text));
}
return Ok(resp.bytes().await?.to_vec());
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let final_url = resp.url().clone();
return Ok(Fetched {
url: final_url,
content_type,
bytes: resp.bytes().await?.to_vec(),
});
}
}

Expand Down Expand Up @@ -336,6 +347,14 @@ impl KaitenClient {
}
}

/// Result of [`KaitenClient::get_bytes`].
pub(crate) struct Fetched {
/// The URL that answered — differs from the requested one after a redirect.
pub(crate) url: url::Url,
pub(crate) content_type: Option<String>,
pub(crate) bytes: Vec<u8>,
}

/// `X-RateLimit-Reset` as sent (missing/garbage → `None`).
fn rate_limit_reset(resp: &reqwest::Response) -> Option<u64> {
resp.headers()
Expand Down
Loading