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: 8 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
What's changed in loggerhead?
=============================

UNRELEASED
----------

- Send ``Last-Modified`` based on the revision being served for file
downloads and tarballs, rather than the branch tip. The tip-based
middleware no longer overwrites a header already set by the
handler. (Jelmer Vernooij, #503144)

3.0.0 [22Apr2026]
-----------------

Expand Down
6 changes: 2 additions & 4 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ use axum::http::{header, HeaderValue, StatusCode};
use axum::middleware::Next;
use axum::response::{IntoResponse, Redirect, Response};
use axum::{routing::get, Router};
use chrono::{DateTime, Utc};
use moka::sync::Cache;
use tower_http::services::ServeDir;
use tower_http::trace::TraceLayer;
Expand Down Expand Up @@ -225,10 +224,9 @@ async fn last_modified_layer(
}

let mut resp = next.run(req).await;
if resp.status().is_success() {
if resp.status().is_success() && !resp.headers().contains_key(header::LAST_MODIFIED) {
if let Some(ts) = last_ts {
if let Some(dt) = DateTime::<Utc>::from_timestamp(ts as i64, 0) {
let rfc = dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
if let Some(rfc) = crate::util::fmt::http_date(ts) {
if let Ok(v) = HeaderValue::from_str(&rfc) {
resp.headers_mut().insert(header::LAST_MODIFIED, v);
}
Expand Down
36 changes: 22 additions & 14 deletions src/controllers/download.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use crate::app::AppState;
use crate::breezy::open_branch;
use crate::history::History;
use crate::util::errors::{AppError, AppResult};
use crate::util::fmt::http_date;

/// GET /download (no args) — permanent redirect to `/changes`. Matches
/// Python's DownloadUI, which redirects when fewer than two args are given.
Expand All @@ -41,7 +42,7 @@ pub async fn show_file(
.unwrap_or_else(|| path.clone());

let path_for_task = path.clone();
let content = tokio::task::spawn_blocking(move || -> AppResult<Vec<u8>> {
let (content, timestamp) = tokio::task::spawn_blocking(move || -> AppResult<(Vec<u8>, f64)> {
let branch = open_branch(&state.root)?;
let _lock = branch.lock_read()?;
let whole = state.load_whole_history(&branch)?;
Expand All @@ -52,24 +53,29 @@ pub async fn show_file(
let repo = branch.repository();
let tree = repo.revision_tree(&revid)?;
let p = PathBuf::from(&path_for_task);
Ok(tree.get_file_text(&p)?)
let bytes = tree.get_file_text(&p)?;
// `Last-Modified` for the download: timestamp of the revision
// being served, not the branch tip. See Launchpad bug #503144.
let timestamp = repo.get_revision(&revid)?.timestamp;
Ok((bytes, timestamp))
})
.await??;

let mime = mime_guess::from_path(&filename)
.first_or_octet_stream()
.to_string();
let encoded = utf8_percent_encode(&filename, NON_ALPHANUMERIC).to_string();
Ok(Response::builder()
let mut builder = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, mime)
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename*=utf-8''{encoded}"),
)
.body(Body::from(content))
.unwrap()
.into_response())
);
if let Some(d) = http_date(timestamp) {
builder = builder.header(header::LAST_MODIFIED, d);
}
Ok(builder.body(Body::from(content)).unwrap().into_response())
}

/// GET /tarball/:revid — stream a tgz of the tree at the given
Expand All @@ -89,7 +95,7 @@ pub async fn tarball(
// iterator across the async boundary while still holding the GIL is
// awkward; for the sizes most loggerhead deployments see this is a fair
// tradeoff, and we can revisit with a bounded mpsc channel if needed.
let (bytes, filename) = tokio::task::spawn_blocking(move || -> AppResult<_> {
let (bytes, filename, timestamp) = tokio::task::spawn_blocking(move || -> AppResult<_> {
let branch = open_branch(&state.root)?;
let _lock = branch.lock_read()?;
let whole = state.load_whole_history(&branch)?;
Expand All @@ -108,23 +114,25 @@ pub async fn tarball(
// be a dotted revno.
let revno_part = history.whole.get_revno(&revid);
let filename = format!("{nick}-r{revno_part}.tgz");
let timestamp = repo.get_revision(&revid)?.timestamp;
let mut out = Vec::new();
for chunk in archive(&tree, ArchiveFormat::Tgz, &filename, None, Some(&nick))? {
out.extend_from_slice(&chunk?);
}
Ok::<_, AppError>((out, filename))
Ok::<_, AppError>((out, filename, timestamp))
})
.await??;

let encoded = utf8_percent_encode(&filename, NON_ALPHANUMERIC).to_string();
Ok(Response::builder()
let mut builder = Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/octet-stream")
.header(
header::CONTENT_DISPOSITION,
format!("attachment; filename*=utf-8''{encoded}"),
)
.body(Body::from(bytes))
.unwrap()
.into_response())
);
if let Some(d) = http_date(timestamp) {
builder = builder.header(header::LAST_MODIFIED, d);
}
Ok(builder.body(Body::from(bytes)).unwrap().into_response())
}
17 changes: 17 additions & 0 deletions src/util/fmt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ pub fn hide_email(author: &str) -> String {
}
}

/// Render a Breezy (unix) timestamp as an RFC 7231 HTTP-date, suitable
/// for the `Last-Modified` header. Returns `None` if the timestamp is
/// outside the representable range.
pub fn http_date(timestamp: f64) -> Option<String> {
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.format("%a, %d %b %Y %H:%M:%S GMT").to_string())
}

/// Render a Breezy timestamp+timezone as the "2026-04-21 17:09:44 UTC" form
/// Python loggerhead uses as the `<span title>` tooltip.
pub fn utc_iso(timestamp: f64, timezone: i32) -> String {
Expand Down Expand Up @@ -80,4 +88,13 @@ mod tests {
let t = (Utc::now() - chrono::Duration::hours(3)).timestamp() as f64;
assert_eq!(approximate_date(t), "3 hours ago");
}

#[test]
fn http_date_formats_rfc7231() {
// 2024-01-02T03:04:05Z
assert_eq!(
http_date(1_704_164_645.0).as_deref(),
Some("Tue, 02 Jan 2024 03:04:05 GMT"),
);
}
}
Loading