From e1c0707ad78654a4cb8f9370e3c28c12b4f3bcca Mon Sep 17 00:00:00 2001 From: Liam Date: Thu, 27 Aug 2026 18:37:05 -0400 Subject: [PATCH] Add `pdf` media type --- README.md | 8 +-- src/document.rs | 87 ++++++++++++++++++++++++ src/document_type.rs | 58 ++++++++++++++++ src/lib.rs | 4 ++ src/media.rs | 15 ++++- src/metadata.rs | 3 + src/resource_type.rs | 17 +++-- src/subcommand/serve.rs | 7 +- src/subcommand/serve/route.rs | 28 ++++++++ src/subcommand/serve/tests.rs | 78 +++++++++++++++++++++ src/templates.rs | 5 +- src/templates/document.rs | 123 ++++++++++++++++++++++++++++++++++ src/templates/package.rs | 54 +++++++++++++++ src/yaml.rs | 4 +- src/yaml/document.rs | 7 ++ src/yaml/media.rs | 11 +++ src/yaml/metadata.rs | 32 +++++++++ static/document.css | 10 +++ templates/document.html | 2 + templates/package.html | 19 ++++++ tests/metadata.rs | 39 +++++++++++ 21 files changed, 596 insertions(+), 15 deletions(-) create mode 100644 src/document.rs create mode 100644 src/document_type.rs create mode 100644 src/templates/document.rs create mode 100644 src/yaml/document.rs create mode 100644 static/document.css create mode 100644 templates/document.html diff --git a/README.md b/README.md index 0773b363..cd8059a7 100644 --- a/README.md +++ b/README.md @@ -347,11 +347,11 @@ Top-level fields: Fields of `media` containing media-specific metadata: -- `type: {audio,image,video,web}`: The media type. +- `type: {audio,document,image,video,web}`: The media type. -If the media type is `audio`, `image`, or `video`, the media object contains a -field named `items`, which is a list of objects containing metadata for -individual items in the package. +If the media type is `audio`, `document`, `image`, or `video`, the media object +contains a field named `items`, which is a list of objects containing metadata +for individual items in the package. When authoring metadata YAML, each item is an object with a `path` field containing the path to the item. For example, for an `audio` package: diff --git a/src/document.rs b/src/document.rs new file mode 100644 index 00000000..cec818ec --- /dev/null +++ b/src/document.rs @@ -0,0 +1,87 @@ +use super::*; + +#[derive(Clone, Debug, Decode, Encode, PartialEq, Serialize)] +pub(crate) struct Document { + #[n(0)] + pub(crate) path: RelativePath, + #[n(1)] + #[serde(rename = "type")] + pub(crate) ty: DocumentType, +} + +impl Content for Document { + const LABEL: &'static str = "Document"; + + type Type = DocumentType; + + fn info(&self, builder: InfoBuilder) -> InfoBuilder { + builder.value("type", self.ty) + } + + fn load(_root: &Utf8Path, path: RelativePath) -> Result> { + let ty = DocumentType::from_path(&path).context(error::Path { path: &path })?; + + Ok(Item { + content: Self { path, ty }, + title: None, + }) + } + + fn path(&self) -> &RelativePath { + &self.path + } + + #[cfg(test)] + fn test(path: &str) -> Self { + let path = path.parse::().unwrap(); + let ty = DocumentType::from_path(&path).unwrap(); + Self { path, ty } + } + + fn ty(&self) -> Self::Type { + self.ty + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn load() { + let (_tempdir, root) = tempdir(); + + std::fs::write(root.join("foo.pdf"), b"%PDF-1.7\n").unwrap(); + + assert_eq!( + Document::load(&root, "foo.pdf".parse().unwrap()).unwrap(), + Item { + content: Document { + path: "foo.pdf".parse().unwrap(), + ty: DocumentType::Pdf, + }, + title: None, + }, + ); + } + + #[test] + fn load_rejects_invalid_extension() { + let (_tempdir, root) = tempdir(); + + assert_eq!( + Document::load(&root, "foo.txt".parse().unwrap()) + .unwrap_err() + .to_string(), + "invalid path `foo.txt`", + ); + } + + #[test] + fn serialize() { + assert_eq!( + serde_json::to_string(&Document::test("foo.pdf")).unwrap(), + r#"{"path":"foo.pdf","type":"pdf"}"#, + ); + } +} diff --git a/src/document_type.rs b/src/document_type.rs new file mode 100644 index 00000000..90ec25be --- /dev/null +++ b/src/document_type.rs @@ -0,0 +1,58 @@ +use super::*; + +#[derive(Clone, Copy, Debug, Decode, Display, Encode, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +#[strum(serialize_all = "UPPERCASE")] +pub(crate) enum DocumentType { + #[n(0)] + Pdf, +} + +impl ContentType for DocumentType { + const EXTENSIONS: &[&str] = &["pdf"]; + + fn from_extension(extension: &str) -> Option { + match extension { + "pdf" => Some(Self::Pdf), + _ => None, + } + } + + fn resource_type(self) -> ResourceType { + match self { + Self::Pdf => ResourceType::Pdf, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + assert_eq!(DocumentType::Pdf.to_string(), "PDF"); + } + + #[test] + fn from_path() { + #[track_caller] + fn case(path: &str, expected: Result) { + assert_eq!(DocumentType::from_path(&path.parse().unwrap()), expected); + } + + case("foo.pdf", Ok(DocumentType::Pdf)); + case( + "foo.txt", + Err(PathError::Extension { + extensions: &["pdf"], + }), + ); + case( + "foo", + Err(PathError::Extension { + extensions: &["pdf"], + }), + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index e27d02d7..89c61429 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,6 +64,8 @@ use { display_path::DisplayPath, display_sample_rate::DisplaySampleRate, display_secret::DisplaySecret, + document::Document, + document_type::DocumentType, entries::Entries, envelope::Envelope, exif_decoder::ExifDecoder, @@ -329,6 +331,8 @@ mod display_millis; mod display_path; mod display_sample_rate; mod display_secret; +mod document; +mod document_type; mod encode; mod encoder; mod entries; diff --git a/src/media.rs b/src/media.rs index 92b29b33..e5f31277 100644 --- a/src/media.rs +++ b/src/media.rs @@ -16,16 +16,21 @@ pub(crate) enum Media { items: Vec>, }, #[n(1)] + Document { + #[n(0)] + items: Vec>, + }, + #[n(2)] Image { #[n(0)] items: Vec>, }, - #[n(2)] + #[n(3)] Video { #[n(0)] items: Vec>, }, - #[n(3)] + #[n(4)] Web, } @@ -33,6 +38,7 @@ impl Media { pub(crate) fn item(&self, i: usize) -> Option<&dyn MediaItem> { match self { Self::Audio { items } => items.get(i).map(|item| item as &dyn MediaItem), + Self::Document { items } => items.get(i).map(|item| item as &dyn MediaItem), Self::Image { items } => items.get(i).map(|item| item as &dyn MediaItem), Self::Video { items } => items.get(i).map(|item| item as &dyn MediaItem), Self::Web => unreachable!(), @@ -42,6 +48,7 @@ impl Media { pub(crate) fn item_count(&self) -> usize { match self { Self::Audio { items } => items.len(), + Self::Document { items } => items.len(), Self::Image { items } => items.len(), Self::Video { items } => items.len(), Self::Web => unreachable!(), @@ -55,6 +62,7 @@ impl Media { pub(crate) fn items<'a>(&'a self) -> Box + 'a> { match self { Self::Audio { items } => Box::new(items.iter().map(|item| item as &dyn MediaItem)), + Self::Document { items } => Box::new(items.iter().map(|item| item as &dyn MediaItem)), Self::Image { items } => Box::new(items.iter().map(|item| item as &dyn MediaItem)), Self::Video { items } => Box::new(items.iter().map(|item| item as &dyn MediaItem)), Self::Web => unreachable!(), @@ -81,7 +89,7 @@ impl Media { impl MediaType { pub(crate) fn has_items(self) -> bool { match self { - Self::Audio | Self::Image | Self::Video => true, + Self::Audio | Self::Document | Self::Image | Self::Video => true, Self::Web => false, } } @@ -89,6 +97,7 @@ impl MediaType { pub(crate) fn item_noun(self) -> &'static str { match self { Self::Audio => "track", + Self::Document => "document", Self::Image => "image", Self::Video => "video", Self::Web => unreachable!(), diff --git a/src/metadata.rs b/src/metadata.rs index 38cfacc7..b06aa49a 100644 --- a/src/metadata.rs +++ b/src/metadata.rs @@ -137,6 +137,9 @@ impl Metadata { Media::Audio { items } => { files.extend(items.iter().map(|audio| audio.path().into())); } + Media::Document { items } => { + files.extend(items.iter().map(|document| document.path().into())); + } Media::Image { items } => { files.extend(items.iter().map(|image| image.path().into())); } diff --git a/src/resource_type.rs b/src/resource_type.rs index d8acd7ee..1dcfd1e0 100644 --- a/src/resource_type.rs +++ b/src/resource_type.rs @@ -8,6 +8,7 @@ pub(crate) enum ResourceType { Markdown, Mp3, Mp4, + Pdf, Png, Webm, } @@ -16,9 +17,14 @@ impl ResourceType { pub(crate) fn content_disposition(self) -> Option { match self { Self::Binary => Some(HeaderValue::from_static("attachment")), - Self::Flac | Self::Jpeg | Self::Markdown | Self::Mp3 | Self::Mp4 | Self::Png | Self::Webm => { - None - } + Self::Flac + | Self::Jpeg + | Self::Markdown + | Self::Mp3 + | Self::Mp4 + | Self::Pdf + | Self::Png + | Self::Webm => None, } } @@ -30,6 +36,7 @@ impl ResourceType { Self::Markdown => mime::TEXT_PLAIN_UTF_8, Self::Mp3 => "audio/mpeg".parse().unwrap(), Self::Mp4 => "video/mp4".parse().unwrap(), + Self::Pdf => mime::APPLICATION_PDF, Self::Png => mime::IMAGE_PNG, Self::Webm => "video/webm".parse().unwrap(), } @@ -42,6 +49,7 @@ impl ResourceType { "md" => Some(Self::Markdown), "mp3" => Some(Self::Mp3), "mp4" => Some(Self::Mp4), + "pdf" => Some(Self::Pdf), "png" => Some(Self::Png), "webm" => Some(Self::Webm), _ => None, @@ -50,7 +58,7 @@ impl ResourceType { pub(crate) fn sandbox(self) -> bool { match self { - Self::Binary | Self::Jpeg | Self::Markdown | Self::Png => true, + Self::Binary | Self::Jpeg | Self::Markdown | Self::Pdf | Self::Png => true, Self::Flac | Self::Mp3 | Self::Mp4 | Self::Webm => false, } } @@ -77,6 +85,7 @@ mod tests { case("foo.md", Some(ResourceType::Markdown)); case("foo.mp3", Some(ResourceType::Mp3)); case("foo.mp4", Some(ResourceType::Mp4)); + case("foo.pdf", Some(ResourceType::Pdf)); case("foo.png", Some(ResourceType::Png)); case("foo.webm", Some(ResourceType::Webm)); diff --git a/src/subcommand/serve.rs b/src/subcommand/serve.rs index 3ef9ac97..84b48b37 100644 --- a/src/subcommand/serve.rs +++ b/src/subcommand/serve.rs @@ -16,7 +16,8 @@ use { }, std::net::TcpStream, templates::{ - AudioHtml, DirectoryHtml, FilesHtml, ImageHtml, MediaHtml, PackageHtml, PackagesHtml, VideoHtml, + AudioHtml, DirectoryHtml, DocumentHtml, FilesHtml, ImageHtml, MediaHtml, PackageHtml, + PackagesHtml, VideoHtml, }, tokio::{net::TcpListener, runtime, task::block_in_place}, tower_http::set_header::SetResponseHeaderLayer, @@ -324,6 +325,10 @@ impl Serve { "/media/audio/{fingerprint}/item/{item}", get(route::media_audio_item), ) + .route( + "/media/document/{fingerprint}/item/{item}", + get(route::media_document_item), + ) .route( "/media/image/{fingerprint}/item/{item}", get(route::media_image_item), diff --git a/src/subcommand/serve/route.rs b/src/subcommand/serve/route.rs index bf3ceeff..08c9a530 100644 --- a/src/subcommand/serve/route.rs +++ b/src/subcommand/serve/route.rs @@ -176,6 +176,25 @@ pub(crate) async fn media_audio_item( }) } +pub(crate) async fn media_document_item( + server: ServerExtension, + Path((fingerprint, Ordinal(item))): Path<(Fingerprint, Ordinal)>, + range: Option>, +) -> ServerResult { + block_in_place(|| { + Ok( + server + .media_item( + fingerprint, + item, + MediaType::Document, + MediaItemResource::Original, + )? + .range(range), + ) + }) +} + pub(crate) async fn media_image_item( server: ServerExtension, Path((fingerprint, Ordinal(item))): Path<(Fingerprint, Ordinal)>, @@ -354,6 +373,15 @@ pub(crate) async fn package_item( .page(server_config.url.clone()) .into_response(), ), + Media::Document { .. } => Ok( + DocumentHtml { + document: index, + fingerprint, + metadata, + } + .page(server_config.url.clone()) + .into_response(), + ), Media::Image { .. } => Ok( ImageHtml { fingerprint, diff --git a/src/subcommand/serve/tests.rs b/src/subcommand/serve/tests.rs index 34509343..3b0f6732 100644 --- a/src/subcommand/serve/tests.rs +++ b/src/subcommand/serve/tests.rs @@ -1321,6 +1321,58 @@ fn media_audio_item_response() { .send(); } +#[test] +fn media_document_item_out_of_range() { + let server = TestServer::new(); + + let fingerprint = PackageBuilder::new() + .metadata(&Metadata { + media: Some(Media::Document { + items: vec![Item::test("foo.pdf")], + }), + ..default() + }) + .file("foo.pdf", b"foo") + .upload(&server); + + server + .get(format!("/media/document/{fingerprint}/item/2")) + .status(StatusCode::NOT_FOUND) + .assert_body(format!( + "document 2 does not exist, package {fingerprint} has 1 document" + )) + .send(); +} + +#[test] +fn media_document_item_response() { + let server = TestServer::new(); + + let pdf: &[u8] = b"%PDF-1.7\n"; + + let fingerprint = PackageBuilder::new() + .metadata(&Metadata { + media: Some(Media::Document { + items: vec![Item::test("foo.pdf")], + }), + ..default() + }) + .file("foo.pdf", pdf) + .upload(&server); + + server + .get(format!("/media/document/{fingerprint}/item/1")) + .assert_header(header::ACCEPT_RANGES, "bytes") + .assert_header(header::CACHE_CONTROL, "public, max-age=31536000, immutable") + .assert_header(header::CONTENT_LENGTH, pdf.len().to_string()) + .assert_header(header::CONTENT_SECURITY_POLICY, "sandbox") + .assert_header(header::CONTENT_TYPE, "application/pdf") + .assert_header(header::ETAG, format!("\"{}\"", Hash::bytes(pdf))) + .assert_header_absent(header::CONTENT_DISPOSITION) + .assert_body(pdf) + .send(); +} + #[test] fn media_image_item_out_of_range() { let server = TestServer::new(); @@ -1870,6 +1922,32 @@ fn package_item_audio_out_of_range() { .send(); } +#[test] +fn package_item_document() { + let server = TestServer::new(); + + let metadata = Metadata { + media: Some(Media::Document { + items: vec![Item::test("foo.pdf")], + }), + ..default() + }; + + let fingerprint = PackageBuilder::new() + .metadata(&metadata) + .file("foo.pdf", b"%PDF-1.7\n") + .upload(&server); + + server + .get(format!("/package/{fingerprint}/item/1")) + .assert_page(DocumentHtml { + document: 0, + fingerprint, + metadata, + }) + .send(); +} + #[test] fn package_item_image() { let server = TestServer::new(); diff --git a/src/templates.rs b/src/templates.rs index c124a755..d4c2e30a 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -3,13 +3,14 @@ use super::*; pub use self::{directory::DirectoryHtml, package::PackageHtml, page::PageHtml}; pub(crate) use self::{ - audio::AudioHtml, directory_table::DirectoryTableHtml, files::FilesHtml, image::ImageHtml, - info::InfoHtml, media::MediaHtml, packages::PackagesHtml, video::VideoHtml, + audio::AudioHtml, directory_table::DirectoryTableHtml, document::DocumentHtml, files::FilesHtml, + image::ImageHtml, info::InfoHtml, media::MediaHtml, packages::PackagesHtml, video::VideoHtml, }; mod audio; mod directory; mod directory_table; +mod document; mod files; mod image; mod info; diff --git a/src/templates/document.rs b/src/templates/document.rs new file mode 100644 index 00000000..88582142 --- /dev/null +++ b/src/templates/document.rs @@ -0,0 +1,123 @@ +use super::*; + +#[derive(Boilerplate)] +pub(crate) struct DocumentHtml { + pub(crate) document: usize, + pub(crate) fingerprint: Fingerprint, + pub(crate) metadata: Metadata, +} + +impl DocumentHtml { + fn document(&self) -> &Item { + let Media::Document { items } = self.metadata.media.as_ref().unwrap() else { + unreachable!(); + }; + + &items[self.document] + } +} + +impl Page for DocumentHtml { + fn next(&self) -> Option { + self + .metadata + .media + .as_ref() + .unwrap() + .next_item_url(self.fingerprint, self.document) + } + + fn open_graph_image(&self) -> Option { + OpenGraphImage::artwork(&self.metadata, self.fingerprint) + } + + fn prev(&self) -> Option { + self + .metadata + .media + .as_ref() + .unwrap() + .prev_item_url(self.fingerprint, self.document) + } + + fn stylesheet(&self) -> Option<&'static str> { + Some("/static/document.css") + } + + fn title(&self) -> String { + format!( + "{} · filepack", + self.document().display_title(self.document), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn document() { + assert_eq!( + DocumentHtml { + document: 0, + fingerprint: test::FINGERPRINT.parse().unwrap(), + metadata: Metadata { + media: Some(Media::Document { + items: vec![Item::test("foo.pdf")], + }), + ..default() + }, + } + .to_string(), + format!( + "\n", + test::FINGERPRINT, + ), + ); + } + + #[test] + fn navigation() { + let html = DocumentHtml { + document: 0, + fingerprint: test::FINGERPRINT.parse().unwrap(), + metadata: Metadata { + media: Some(Media::Document { + items: vec![Item::test("foo.pdf"), Item::test("bar.pdf")], + }), + ..default() + }, + }; + + assert_eq!(html.prev(), None); + assert_eq!( + html.next(), + Some(format!("/package/{}/item/2", test::FINGERPRINT)), + ); + } + + #[test] + fn title() { + let mut html = DocumentHtml { + document: 0, + fingerprint: test::FINGERPRINT.parse().unwrap(), + metadata: Metadata { + media: Some(Media::Document { + items: vec![Item::test("foo.pdf")], + }), + ..default() + }, + }; + + assert_eq!(Page::title(&html), "Document 1 · filepack"); + + let Some(Media::Document { items }) = html.metadata.media.as_mut() else { + unreachable!(); + }; + + items[0].title = Some("bar".parse().unwrap()); + + assert_eq!(Page::title(&html), "bar · filepack"); + } +} diff --git a/src/templates/package.rs b/src/templates/package.rs index 0f361c2b..f14f62b3 100644 --- a/src/templates/package.rs +++ b/src/templates/package.rs @@ -265,6 +265,60 @@ mod tests { ); } + #[test] + fn document() { + let metadata = Metadata { + media: Some(Media::Document { + items: vec![Item::test("foo.pdf")], + }), + ..default() + }; + + assert_eq!( + PackageHtml { + colophon: None, + directory: Directory::new(), + fingerprint: test::FINGERPRINT.parse().unwrap(), + metadata: Some(metadata), + mounted: false, + readme: None, + totals: Totals { + directories: 0, + directory_size: 0, + file_size: 9, + files: 1, + }, + } + .to_string(), + unindent(&format!( + " +

{fingerprint}

+
+
fingerprint
+
{fingerprint}
+
size
+
9 B
+
files
+
1 files
+
media
+
document
+
documents
+
1
+
format
+
PDF
+
+
    +
  1. + Document 1 +
  2. +
+ ", + fingerprint = test::FINGERPRINT, + hash = test::HASH, + )), + ); + } + #[test] fn duration_saturates() { let audio = Item { diff --git a/src/yaml.rs b/src/yaml.rs index 283ac69b..595193d1 100644 --- a/src/yaml.rs +++ b/src/yaml.rs @@ -1,10 +1,12 @@ use super::*; pub(crate) use self::{ - audio::Audio, image::Image, media::Media, metadata::Metadata, package::Package, video::Video, + audio::Audio, document::Document, image::Image, media::Media, metadata::Metadata, + package::Package, video::Video, }; mod audio; +mod document; mod image; mod media; mod metadata; diff --git a/src/yaml/document.rs b/src/yaml/document.rs new file mode 100644 index 00000000..67999e16 --- /dev/null +++ b/src/yaml/document.rs @@ -0,0 +1,7 @@ +use super::*; + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Document { + pub(crate) path: RelativePath, +} diff --git a/src/yaml/media.rs b/src/yaml/media.rs index fc394cfd..3eaae056 100644 --- a/src/yaml/media.rs +++ b/src/yaml/media.rs @@ -4,6 +4,7 @@ use super::*; #[serde(deny_unknown_fields, rename_all = "snake_case", tag = "type")] pub(crate) enum Media { Audio { items: Vec