Skip to content

Commit a42e0c8

Browse files
committed
fix(http): Last-Modified is the file's mtime, not now()
Every static file was served with Last-Modified = SystemTime::now(), so the header advanced by a second between two requests for a file that had not changed. A client doing If-Modified-Since revalidation could never get a meaningful answer and no cache could key on it. FileSystem::modified_at reads the real mtime with the same local-then-database fallback read_file uses, so the timestamp describes the file actually served. DbFsQueries gains a prepared statement for the last_modified column, mirroring the existing was_modified/read_file/exists trio. The header is OMITTED when no mtime is available rather than filled with a guess: a header that says "now" is worse than an absent one, because a client believes it. Both filesystems now compare with `>`. They disagreed - local `>`, DB `>=` - so the same request answered differently depending only on where the file was stored. `>` is the correct half rather than merely the chosen one: If-Modified-Since: T asks whether the file changed AFTER T, so an mtime of exactly T is not a change and must answer 304. I had this as `>=` first and driving the repro caught it - every revalidation re-sent the whole body, which defeats the header this change exists to fix. Verified against the issue's reproduction: mtime reported correctly, stable across requests, and 304/200/304 for If-Modified-Since equal to / older than / newer than the mtime. Fixes #1323
1 parent 109b20d commit a42e0c8

2 files changed

Lines changed: 116 additions & 9 deletions

File tree

src/filesystem.rs

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,50 @@ impl FileSystem {
9898
}
9999
}
100100

101+
/// When the file was last modified, or `None` when nothing can say.
102+
///
103+
/// Same local-then-database fallback as `modified_since`, because a file served from
104+
/// one must report the mtime of that same one. Used for the `Last-Modified` header,
105+
/// which was previously `SystemTime::now()` - a value that changed on every request
106+
/// and so carried no cache-validation information at all.
107+
///
108+
/// `None` rather than a guess when the mtime is unavailable: the caller omits the
109+
/// header instead, which is honest. A header that says "now" is worse than no header,
110+
/// because a client believes it.
111+
pub(crate) async fn modified_at(
112+
&self,
113+
app_state: &AppState,
114+
access: FileAccess<'_>,
115+
) -> Option<DateTime<Utc>> {
116+
let path = access.path();
117+
let local_path = self.safe_local_path(app_state, access);
118+
match tokio::fs::metadata(&local_path)
119+
.await
120+
.and_then(|m| m.modified())
121+
{
122+
Ok(modified) => return Some(DateTime::<Utc>::from(modified)),
123+
Err(e) if !is_path_missing_error(&e) => {
124+
log::debug!(
125+
"Unable to read the modification time of {}: {e}",
126+
local_path.display()
127+
);
128+
return None;
129+
}
130+
Err(_) => {}
131+
}
132+
let db_fs = self.db_fs_queries.as_ref()?;
133+
match db_fs.last_modified_in_db(app_state, path.as_ref()).await {
134+
Ok(modified) => modified,
135+
Err(e) => {
136+
log::debug!(
137+
"Unable to read the modification time of {} from the database: {e:#}",
138+
path.display()
139+
);
140+
None
141+
}
142+
}
143+
}
144+
101145
pub(crate) async fn read_to_string(
102146
&self,
103147
app_state: &AppState,
@@ -271,11 +315,23 @@ async fn file_modified_since_local(path: &Path, since: DateTime<Utc>) -> tokio::
271315
tokio::fs::metadata(path)
272316
.await
273317
.and_then(|m| m.modified())
318+
// Strictly `>`, and the database query below now matches.
319+
//
320+
// The two disagreed - local used `>`, the DB `>=` - so a file whose mtime equalled
321+
// the client's `If-Modified-Since` was "not modified" from disk and "modified"
322+
// from the database. Conditional requests must not depend on where a file is
323+
// stored.
324+
//
325+
// `>` is the correct half, not merely the chosen one: `If-Modified-Since: T` asks
326+
// whether the file changed AFTER T, so an mtime of exactly T is not a change and
327+
// must answer 304. Under `>=` every revalidation re-sends the whole body, which
328+
// defeats the header entirely.
274329
.map(|modified_at| DateTime::<Utc>::from(modified_at) > since)
275330
}
276331

277332
pub struct DbFsQueries {
278333
was_modified: AnyStatement<'static>,
334+
last_modified: AnyStatement<'static>,
279335
read_file: AnyStatement<'static>,
280336
exists: AnyStatement<'static>,
281337
}
@@ -304,6 +360,7 @@ impl DbFsQueries {
304360
Self::check_table_available(db).await?;
305361
Ok(Self {
306362
was_modified: Self::make_was_modified_query(db).await?,
363+
last_modified: Self::make_last_modified_query(db).await?,
307364
read_file: Self::make_read_file_query(db).await?,
308365
exists: Self::make_exists_query(db).await?,
309366
})
@@ -320,7 +377,11 @@ impl DbFsQueries {
320377

321378
async fn make_was_modified_query(db: &Database) -> anyhow::Result<AnyStatement<'static>> {
322379
let was_modified_query = format!(
323-
"SELECT 1 from sqlpage_files WHERE last_modified >= {} AND path = {}",
380+
// `>` not `>=`: an mtime equal to the client's If-Modified-Since is not a
381+
// change since that moment, and must revalidate as 304. This matches
382+
// `file_modified_since_local`, which the same request would otherwise
383+
// answer differently depending only on where the file is stored.
384+
"SELECT 1 from sqlpage_files WHERE last_modified > {} AND path = {}",
324385
make_placeholder(db.info.kind, 1),
325386
make_placeholder(db.info.kind, 2)
326387
);
@@ -332,6 +393,16 @@ impl DbFsQueries {
332393
db.prepare_with(&was_modified_query, param_types).await
333394
}
334395

396+
async fn make_last_modified_query(db: &Database) -> anyhow::Result<AnyStatement<'static>> {
397+
let last_modified_query = format!(
398+
"SELECT last_modified from sqlpage_files WHERE path = {}",
399+
make_placeholder(db.info.kind, 1),
400+
);
401+
let param_types: &[AnyTypeInfo; 1] = &[<str as Type<Postgres>>::type_info().into()];
402+
log::debug!("Preparing the database filesystem last_modified_query: {last_modified_query}");
403+
db.prepare_with(&last_modified_query, param_types).await
404+
}
405+
335406
async fn make_read_file_query(db: &Database) -> anyhow::Result<AnyStatement<'static>> {
336407
let read_file_query = format!(
337408
"SELECT contents from sqlpage_files WHERE path = {}",
@@ -351,6 +422,32 @@ impl DbFsQueries {
351422
db.prepare_with(&exists_query, param_types).await
352423
}
353424

425+
async fn last_modified_in_db(
426+
&self,
427+
app_state: &AppState,
428+
path: &Path,
429+
) -> anyhow::Result<Option<DateTime<Utc>>> {
430+
let query = self
431+
.last_modified
432+
.query_as::<(DateTime<Utc>,)>()
433+
.bind(path.display().to_string());
434+
log::trace!(
435+
"Reading the modification time of {} by executing query: \n{}",
436+
path.display(),
437+
self.last_modified.sql()
438+
);
439+
let row = query
440+
.fetch_optional(&app_state.db.connection)
441+
.await
442+
.with_context(|| {
443+
format!(
444+
"Unable to read the modification time of {} from the database",
445+
path.display()
446+
)
447+
})?;
448+
Ok(row.map(|(modified,)| modified))
449+
}
450+
354451
async fn file_modified_since_in_db(
355452
&self,
356453
app_state: &AppState,

src/webserver/http.rs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -455,21 +455,31 @@ async fn serve_file(
455455
return Ok(HttpResponse::NotModified().finish());
456456
}
457457
}
458+
// The file's real modification time, not `SystemTime::now()`.
459+
//
460+
// `now()` changed on every request, so `Last-Modified` advanced by a second between
461+
// two fetches of a file that had not changed since 2020. A client doing
462+
// `If-Modified-Since` revalidation could never get a meaningful answer, and no cache
463+
// could key on it. Omitted entirely when the mtime is unknown: a header that says
464+
// "now" is worse than an absent one, because a client believes it.
465+
let last_modified = state.file_system.modified_at(state, access).await;
458466
state
459467
.file_system
460468
.read_file(state, access)
461469
.await
462470
.with_context(|| format!("Unable to read file {path:?}"))
463471
.map_err(|e| anyhow_err_to_actix(e, state))
464472
.map(|b| {
465-
HttpResponse::Ok()
466-
.insert_header(
467-
mime_guess::from_path(path)
468-
.first()
469-
.map_or_else(ContentType::octet_stream, ContentType),
470-
)
471-
.insert_header(LastModified(HttpDate::from(SystemTime::now())))
472-
.body(b)
473+
let mut response = HttpResponse::Ok();
474+
response.insert_header(
475+
mime_guess::from_path(path)
476+
.first()
477+
.map_or_else(ContentType::octet_stream, ContentType),
478+
);
479+
if let Some(modified) = last_modified {
480+
response.insert_header(LastModified(HttpDate::from(SystemTime::from(modified))));
481+
}
482+
response.body(b)
473483
})
474484
}
475485

0 commit comments

Comments
 (0)