Skip to content

Commit ac22aee

Browse files
committed
Fix static file Last-Modified headers
1 parent 162f996 commit ac22aee

3 files changed

Lines changed: 127 additions & 15 deletions

File tree

src/filesystem.rs

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,32 @@ impl FileSystem {
3636
}
3737
}
3838

39+
pub async fn last_modified(
40+
&self,
41+
app_state: &AppState,
42+
path: &Path,
43+
priviledged: bool,
44+
) -> anyhow::Result<DateTime<Utc>> {
45+
let local_path = self.safe_local_path(app_state, path, priviledged)?;
46+
let local_result = tokio::fs::metadata(&local_path)
47+
.await
48+
.and_then(|metadata| metadata.modified())
49+
.map(DateTime::<Utc>::from);
50+
match (local_result, &self.db_fs_queries) {
51+
(Ok(last_modified), _) => Ok(last_modified),
52+
(Err(e), Some(db_fs)) if is_path_missing_error(&e) => {
53+
db_fs.last_modified_in_db(app_state, path).await
54+
}
55+
(Err(e), _) => {
56+
let status = io_error_status(&e)
57+
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR);
58+
Err(e).with_status(status).with_context(|| {
59+
format!("Unable to read local file metadata for {}", path.display())
60+
})
61+
}
62+
}
63+
}
64+
3965
pub async fn modified_since(
4066
&self,
4167
app_state: &AppState,
@@ -258,6 +284,7 @@ pub struct DbFsQueries {
258284
was_modified: AnyStatement<'static>,
259285
read_file: AnyStatement<'static>,
260286
exists: AnyStatement<'static>,
287+
last_modified: AnyStatement<'static>,
261288
}
262289

263290
impl DbFsQueries {
@@ -286,6 +313,7 @@ impl DbFsQueries {
286313
was_modified: Self::make_was_modified_query(db).await?,
287314
read_file: Self::make_read_file_query(db).await?,
288315
exists: Self::make_exists_query(db).await?,
316+
last_modified: Self::make_last_modified_query(db).await?,
289317
})
290318
}
291319

@@ -300,7 +328,7 @@ impl DbFsQueries {
300328

301329
async fn make_was_modified_query(db: &Database) -> anyhow::Result<AnyStatement<'static>> {
302330
let was_modified_query = format!(
303-
"SELECT 1 from sqlpage_files WHERE last_modified >= {} AND path = {}",
331+
"SELECT 1 from sqlpage_files WHERE last_modified > {} AND path = {}",
304332
make_placeholder(db.info.kind, 1),
305333
make_placeholder(db.info.kind, 2)
306334
);
@@ -331,6 +359,39 @@ impl DbFsQueries {
331359
db.prepare_with(&exists_query, param_types).await
332360
}
333361

362+
async fn make_last_modified_query(db: &Database) -> anyhow::Result<AnyStatement<'static>> {
363+
let last_modified_query = format!(
364+
"SELECT last_modified from sqlpage_files WHERE path = {}",
365+
make_placeholder(db.info.kind, 1),
366+
);
367+
let param_types: &[AnyTypeInfo; 1] = &[<str as Type<Postgres>>::type_info().into()];
368+
db.prepare_with(&last_modified_query, param_types).await
369+
}
370+
371+
async fn last_modified_in_db(
372+
&self,
373+
app_state: &AppState,
374+
path: &Path,
375+
) -> anyhow::Result<DateTime<Utc>> {
376+
self.last_modified
377+
.query_as::<(DateTime<Utc>,)>()
378+
.bind(path.display().to_string())
379+
.fetch_optional(&app_state.db.connection)
380+
.await
381+
.map_err(anyhow::Error::from)
382+
.and_then(|last_modified| {
383+
last_modified
384+
.map(|(last_modified,)| last_modified)
385+
.ok_or_else(|| {
386+
ErrorWithStatus {
387+
status: actix_web::http::StatusCode::NOT_FOUND,
388+
}
389+
.into()
390+
})
391+
})
392+
.with_context(|| format!("Unable to get the modification time of {}", path.display()))
393+
}
394+
334395
async fn file_modified_since_in_db(
335396
&self,
336397
app_state: &AppState,
@@ -475,5 +536,16 @@ async fn test_sql_file_read_utf8() -> anyhow::Result<()> {
475536
"File should not be modified since one hour in the future"
476537
);
477538

539+
let last_modified = fs
540+
.last_modified(&state, "unit test file.txt".as_ref(), false)
541+
.await?;
542+
let was_modified = fs
543+
.modified_since(&state, "unit test file.txt".as_ref(), last_modified, false)
544+
.await?;
545+
assert!(
546+
!was_modified,
547+
"A file should not be considered modified since its exact modification time"
548+
);
549+
478550
Ok(())
479551
}

src/webserver/http.rs

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,12 @@ use crate::webserver::routing::RoutingAction::{
3333
use crate::webserver::routing::{AppFileStore, calculate_route};
3434
use actix_web::body::MessageBody;
3535
use anyhow::{Context, bail};
36-
use chrono::{DateTime, Utc};
3736
use futures_util::StreamExt;
3837
use futures_util::stream::Stream;
3938
use std::borrow::Cow;
4039
use std::path::PathBuf;
4140
use std::pin::Pin;
4241
use std::sync::Arc;
43-
use std::time::SystemTime;
4442
use tokio::sync::mpsc;
4543

4644
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -441,17 +439,19 @@ async fn serve_file(
441439
if_modified_since: Option<IfModifiedSince>,
442440
) -> actix_web::Result<HttpResponse> {
443441
let path = strip_site_prefix(path, state);
444-
if let Some(IfModifiedSince(date)) = if_modified_since {
445-
let since = DateTime::<Utc>::from(SystemTime::from(date));
446-
let modified = state
447-
.file_system
448-
.modified_since(state, path.as_ref(), since, false)
449-
.await
450-
.with_context(|| format!("Unable to get modification time of file {path:?}"))
451-
.map_err(|e| anyhow_err_to_actix(e, state))?;
452-
if !modified {
453-
return Ok(HttpResponse::NotModified().finish());
454-
}
442+
let last_modified = state
443+
.file_system
444+
.last_modified(state, path.as_ref(), false)
445+
.await
446+
.with_context(|| format!("Unable to get modification time of file {path:?}"))
447+
.map_err(|e| anyhow_err_to_actix(e, state))?;
448+
let last_modified = HttpDate::from(last_modified.into());
449+
if let Some(IfModifiedSince(date)) = if_modified_since
450+
&& last_modified <= date
451+
{
452+
return Ok(HttpResponse::NotModified()
453+
.insert_header(LastModified(last_modified))
454+
.finish());
455455
}
456456
state
457457
.file_system
@@ -466,7 +466,7 @@ async fn serve_file(
466466
.first()
467467
.map_or_else(ContentType::octet_stream, ContentType),
468468
)
469-
.insert_header(LastModified(HttpDate::from(SystemTime::now())))
469+
.insert_header(LastModified(last_modified))
470470
.body(b)
471471
})
472472
}

tests/requests/mod.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,3 +248,43 @@ async fn test_missing_multipart_content_disposition_returns_bad_request() -> act
248248
}
249249

250250
mod webhook_hmac;
251+
252+
#[actix_web::test]
253+
async fn static_file_uses_source_last_modified_for_revalidation() -> anyhow::Result<()> {
254+
use actix_web::http::header;
255+
use actix_web::http::header::HttpDate;
256+
use std::time::{SystemTime, UNIX_EPOCH};
257+
258+
let unique = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
259+
let web_root = std::env::temp_dir().join(format!("sqlpage-last-modified-{unique}"));
260+
std::fs::create_dir_all(&web_root)?;
261+
let asset_path = web_root.join("asset.txt");
262+
std::fs::write(&asset_path, "hello static world\n")?;
263+
264+
let expected = HttpDate::from(std::fs::metadata(&asset_path)?.modified()?);
265+
let mut config = crate::common::test_config();
266+
config.web_root = web_root.clone();
267+
let app_data = crate::common::make_app_data_from_config(config).await;
268+
269+
let req = crate::common::get_request_to_with_data("/asset.txt", app_data.clone())
270+
.await?
271+
.to_srv_request();
272+
let response = main_handler(req).await?;
273+
assert_eq!(response.status(), StatusCode::OK);
274+
let last_modified = response
275+
.headers()
276+
.get(header::LAST_MODIFIED)
277+
.expect("static files should have a Last-Modified header")
278+
.to_str()?;
279+
assert_eq!(last_modified, expected.to_string());
280+
281+
let req = crate::common::get_request_to_with_data("/asset.txt", app_data)
282+
.await?
283+
.insert_header((header::IF_MODIFIED_SINCE, last_modified))
284+
.to_srv_request();
285+
let response = main_handler(req).await?;
286+
assert_eq!(response.status(), StatusCode::NOT_MODIFIED);
287+
288+
std::fs::remove_dir_all(web_root)?;
289+
Ok(())
290+
}

0 commit comments

Comments
 (0)