allow downloading individual files from bundle
This commit is contained in:
		
							parent
							
								
									43d03869ab
								
							
						
					
					
						commit
						007289ffe5
					
				
					 15 changed files with 499 additions and 69 deletions
				
			
		
							
								
								
									
										160
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										160
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
								
							|  | @ -309,12 +309,54 @@ dependencies = [ | ||||||
|  "alloc-no-stdlib", |  "alloc-no-stdlib", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "askama" | ||||||
|  | version = "0.11.1" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "fb98f10f371286b177db5eeb9a6e5396609555686a35e1d4f7b9a9c6d8af0139" | ||||||
|  | dependencies = [ | ||||||
|  |  "askama_derive", | ||||||
|  |  "askama_escape", | ||||||
|  |  "askama_shared", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "askama_derive" | ||||||
|  | version = "0.11.2" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "87bf87e6e8b47264efa9bde63d6225c6276a52e05e91bf37eaa8afd0032d6b71" | ||||||
|  | dependencies = [ | ||||||
|  |  "askama_shared", | ||||||
|  |  "proc-macro2", | ||||||
|  |  "syn", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "askama_escape" | name = "askama_escape" | ||||||
| version = "0.10.3" | version = "0.10.3" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" | checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "askama_shared" | ||||||
|  | version = "0.12.2" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "bf722b94118a07fcbc6640190f247334027685d4e218b794dbfe17c32bf38ed0" | ||||||
|  | dependencies = [ | ||||||
|  |  "askama_escape", | ||||||
|  |  "humansize", | ||||||
|  |  "mime", | ||||||
|  |  "mime_guess", | ||||||
|  |  "nom", | ||||||
|  |  "num-traits", | ||||||
|  |  "percent-encoding", | ||||||
|  |  "proc-macro2", | ||||||
|  |  "quote", | ||||||
|  |  "serde", | ||||||
|  |  "syn", | ||||||
|  |  "toml", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "atty" | name = "atty" | ||||||
| version = "0.2.14" | version = "0.2.14" | ||||||
|  | @ -523,6 +565,41 @@ dependencies = [ | ||||||
|  "typenum", |  "typenum", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "darling" | ||||||
|  | version = "0.13.4" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" | ||||||
|  | dependencies = [ | ||||||
|  |  "darling_core", | ||||||
|  |  "darling_macro", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "darling_core" | ||||||
|  | version = "0.13.4" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" | ||||||
|  | dependencies = [ | ||||||
|  |  "fnv", | ||||||
|  |  "ident_case", | ||||||
|  |  "proc-macro2", | ||||||
|  |  "quote", | ||||||
|  |  "strsim", | ||||||
|  |  "syn", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "darling_macro" | ||||||
|  | version = "0.13.4" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" | ||||||
|  | dependencies = [ | ||||||
|  |  "darling_core", | ||||||
|  |  "quote", | ||||||
|  |  "syn", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "derive_more" | name = "derive_more" | ||||||
| version = "0.99.17" | version = "0.99.17" | ||||||
|  | @ -732,12 +809,24 @@ version = "1.0.2" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" | checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "humansize" | ||||||
|  | version = "1.1.1" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "02296996cb8796d7c6e3bc2d9211b7802812d36999a51bb754123ead7d37d026" | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "humantime" | name = "humantime" | ||||||
| version = "2.1.0" | version = "2.1.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" | checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "ident_case" | ||||||
|  | version = "1.0.1" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "idna" | name = "idna" | ||||||
| version = "0.2.3" | version = "0.2.3" | ||||||
|  | @ -879,6 +968,12 @@ dependencies = [ | ||||||
|  "unicase", |  "unicase", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "minimal-lexical" | ||||||
|  | version = "0.2.1" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "miniz_oxide" | name = "miniz_oxide" | ||||||
| version = "0.5.1" | version = "0.5.1" | ||||||
|  | @ -921,6 +1016,16 @@ dependencies = [ | ||||||
|  "lazy_static", |  "lazy_static", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "nom" | ||||||
|  | version = "7.1.1" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "a8903e5a29a317527874d0402f867152a3d21c908bb0b933e416c65e301d4c36" | ||||||
|  | dependencies = [ | ||||||
|  |  "memchr", | ||||||
|  |  "minimal-lexical", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "ntapi" | name = "ntapi" | ||||||
| version = "0.3.7" | version = "0.3.7" | ||||||
|  | @ -930,6 +1035,15 @@ dependencies = [ | ||||||
|  "winapi", |  "winapi", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "num-traits" | ||||||
|  | version = "0.2.15" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" | ||||||
|  | dependencies = [ | ||||||
|  |  "autocfg", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "num_cpus" | name = "num_cpus" | ||||||
| version = "1.13.1" | version = "1.13.1" | ||||||
|  | @ -1126,6 +1240,12 @@ dependencies = [ | ||||||
|  "semver", |  "semver", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "rustversion" | ||||||
|  | version = "1.0.6" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "f2cc38e8fa666e2de3c4aba7edeb5ffc5246c1c2ed0e3d17e560aeeba736b23f" | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "ryu" | name = "ryu" | ||||||
| version = "1.0.9" | version = "1.0.9" | ||||||
|  | @ -1193,6 +1313,29 @@ dependencies = [ | ||||||
|  "serde", |  "serde", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "serde_with" | ||||||
|  | version = "1.13.0" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "b827f2113224f3f19a665136f006709194bdfdcb1fdc1e4b2b5cbac8e0cced54" | ||||||
|  | dependencies = [ | ||||||
|  |  "rustversion", | ||||||
|  |  "serde", | ||||||
|  |  "serde_with_macros", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "serde_with_macros" | ||||||
|  | version = "1.5.2" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" | ||||||
|  | dependencies = [ | ||||||
|  |  "darling", | ||||||
|  |  "proc-macro2", | ||||||
|  |  "quote", | ||||||
|  |  "syn", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "sha-1" | name = "sha-1" | ||||||
| version = "0.10.0" | version = "0.10.0" | ||||||
|  | @ -1257,6 +1400,12 @@ dependencies = [ | ||||||
|  "winapi", |  "winapi", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "strsim" | ||||||
|  | version = "0.10.0" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "subtle" | name = "subtle" | ||||||
| version = "2.4.1" | version = "2.4.1" | ||||||
|  | @ -1381,6 +1530,15 @@ dependencies = [ | ||||||
|  "tracing", |  "tracing", | ||||||
| ] | ] | ||||||
| 
 | 
 | ||||||
|  | [[package]] | ||||||
|  | name = "toml" | ||||||
|  | version = "0.5.9" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" | ||||||
|  | dependencies = [ | ||||||
|  |  "serde", | ||||||
|  | ] | ||||||
|  | 
 | ||||||
| [[package]] | [[package]] | ||||||
| name = "tracing" | name = "tracing" | ||||||
| version = "0.1.34" | version = "0.1.34" | ||||||
|  | @ -1423,6 +1581,7 @@ dependencies = [ | ||||||
|  "actix-http", |  "actix-http", | ||||||
|  "actix-web", |  "actix-web", | ||||||
|  "actix-web-actors", |  "actix-web-actors", | ||||||
|  |  "askama", | ||||||
|  "bytes", |  "bytes", | ||||||
|  "bytesize", |  "bytesize", | ||||||
|  "crc32fast", |  "crc32fast", | ||||||
|  | @ -1438,6 +1597,7 @@ dependencies = [ | ||||||
|  "sanitise-file-name", |  "sanitise-file-name", | ||||||
|  "serde", |  "serde", | ||||||
|  "serde_json", |  "serde_json", | ||||||
|  |  "serde_with", | ||||||
|  "thiserror", |  "thiserror", | ||||||
|  "time", |  "time", | ||||||
|  "tokio", |  "tokio", | ||||||
|  |  | ||||||
|  | @ -13,6 +13,7 @@ actix-files = "0.6.0" | ||||||
| actix-http = "3.0.4" | actix-http = "3.0.4" | ||||||
| actix-web = "4.0.1" | actix-web = "4.0.1" | ||||||
| actix-web-actors = "4.1.0" | actix-web-actors = "4.1.0" | ||||||
|  | askama = "0.11.1" | ||||||
| bytes = "1.1.0" | bytes = "1.1.0" | ||||||
| bytesize = "1.1.0" | bytesize = "1.1.0" | ||||||
| crc32fast = "1.3.2" | crc32fast = "1.3.2" | ||||||
|  | @ -28,6 +29,7 @@ rand = "0.8.5" | ||||||
| sanitise-file-name = "1.0.0" | sanitise-file-name = "1.0.0" | ||||||
| serde = { version = "1.0", features = ["derive"] } | serde = { version = "1.0", features = ["derive"] } | ||||||
| serde_json = "1.0" | serde_json = "1.0" | ||||||
|  | serde_with = "1.13.0" | ||||||
| thiserror = "1" | thiserror = "1" | ||||||
| time = "0.3.9" | time = "0.3.9" | ||||||
| tokio = { version = "1.17.0", features = ["full"] } | tokio = { version = "1.17.0", features = ["full"] } | ||||||
|  |  | ||||||
|  | @ -15,6 +15,7 @@ | ||||||
|   sender has gone offline |   sender has gone offline | ||||||
| - Easy to send multiple files at once - they're bundled into a zip | - Easy to send multiple files at once - they're bundled into a zip | ||||||
|   file for receivers, with zero compression so extraction is quick |   file for receivers, with zero compression so extraction is quick | ||||||
|  | - Can also download individual files out of an uploaded collection | ||||||
| - Sanitises filenames, using sensible non-obnoxious defaults that | - Sanitises filenames, using sensible non-obnoxious defaults that | ||||||
|   should be safe across platforms |   should be safe across platforms | ||||||
| - Rudimentary password authentication for uploading files | - Rudimentary password authentication for uploading files | ||||||
|  |  | ||||||
							
								
								
									
										113
									
								
								src/download.rs
									
										
									
									
									
								
							
							
						
						
									
										113
									
								
								src/download.rs
									
										
									
									
									
								
							|  | @ -1,3 +1,4 @@ | ||||||
|  | use core::fmt; | ||||||
| use std::{ | use std::{ | ||||||
|     cmp, |     cmp, | ||||||
|     fs::File, |     fs::File, | ||||||
|  | @ -14,6 +15,8 @@ use futures_core::{ready, Stream}; | ||||||
| use inotify::{Inotify, WatchMask}; | use inotify::{Inotify, WatchMask}; | ||||||
| use log::trace; | use log::trace; | ||||||
| use pin_project_lite::pin_project; | use pin_project_lite::pin_project; | ||||||
|  | use serde::{de, Deserialize, Deserializer}; | ||||||
|  | use time::OffsetDateTime; | ||||||
| use std::{os::unix::fs::MetadataExt, time::SystemTime}; | use std::{os::unix::fs::MetadataExt, time::SystemTime}; | ||||||
| 
 | 
 | ||||||
| use actix_web::{ | use actix_web::{ | ||||||
|  | @ -30,7 +33,55 @@ use actix_web::{ | ||||||
| 
 | 
 | ||||||
| use actix_files::HttpRange; | use actix_files::HttpRange; | ||||||
| 
 | 
 | ||||||
| use crate::store::StoredFile; | use crate::{store::StoredFile, upload::UploadedFile}; | ||||||
|  | 
 | ||||||
|  | #[derive(Clone, Copy)] | ||||||
|  | pub enum DownloadSelection { | ||||||
|  |     One(usize), | ||||||
|  |     All, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl fmt::Display for DownloadSelection { | ||||||
|  |     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||||||
|  |         match self { | ||||||
|  |             DownloadSelection::All => write!(f, "all"), | ||||||
|  |             DownloadSelection::One(n) => n.fmt(f), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | struct SelectionVisitor; | ||||||
|  | 
 | ||||||
|  | impl<'de> de::Visitor<'de> for SelectionVisitor { | ||||||
|  |     type Value = DownloadSelection; | ||||||
|  | 
 | ||||||
|  |     fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { | ||||||
|  |         write!(formatter, r#"a nonnegative integer or the string "all""#) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E> { | ||||||
|  |         Ok(DownloadSelection::One(v as usize)) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn visit_str<E>(self, v: &str) -> Result<Self::Value, E> | ||||||
|  |     where E: de::Error { | ||||||
|  |         if v == "all" { | ||||||
|  |             Ok(DownloadSelection::All) | ||||||
|  |         } else if let Ok(n) = v.parse::<usize>() { | ||||||
|  |             Ok(DownloadSelection::One(n)) | ||||||
|  |         } else { | ||||||
|  |             Err(de::Error::invalid_value(de::Unexpected::Str(v), &self)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl<'de> Deserialize<'de> for DownloadSelection { | ||||||
|  |     fn deserialize<D: Deserializer<'de>>( | ||||||
|  |         de: D | ||||||
|  |     ) -> Result<DownloadSelection, D::Error> { | ||||||
|  |         de.deserialize_any(SelectionVisitor) | ||||||
|  |     } | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| // This is copied substantially from actix-files, with some tweaks
 | // This is copied substantially from actix-files, with some tweaks
 | ||||||
| 
 | 
 | ||||||
|  | @ -38,24 +89,64 @@ pub(crate) struct DownloadingFile { | ||||||
|     pub(crate) file: File, |     pub(crate) file: File, | ||||||
|     pub(crate) storage_path: PathBuf, |     pub(crate) storage_path: PathBuf, | ||||||
|     pub(crate) info: StoredFile, |     pub(crate) info: StoredFile, | ||||||
|  |     pub(crate) selection: DownloadSelection, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl DownloadingFile { | impl DownloadingFile { | ||||||
|  |     fn selected(&self) -> Option<&UploadedFile> { | ||||||
|  |         match self.selection { | ||||||
|  |             DownloadSelection::All => None, | ||||||
|  |             DownloadSelection::One(n) => Some(self.info.contents.as_ref()?.get(n)?), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn name(&self) -> &str { | ||||||
|  |         match self.selected() { | ||||||
|  |             None => &self.info.name, | ||||||
|  |             Some(f) => &f.name, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn size(&self) -> u64 { | ||||||
|  |         match self.selected() { | ||||||
|  |             None => self.info.size, | ||||||
|  |             Some(f) => f.size, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn modtime(&self) -> OffsetDateTime { | ||||||
|  |         match self.selected() { | ||||||
|  |             None => self.info.modtime, | ||||||
|  |             Some(f) => f.modtime, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn baseline_offset(&self) -> u64 { | ||||||
|  |         if let (DownloadSelection::One(n), Some(files)) = (self.selection, self.info.contents.as_ref()) { | ||||||
|  |             crate::zip::file_data_offset(&files, n) | ||||||
|  |         } else { | ||||||
|  |             0 | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     fn etag(&self) -> EntityTag { |     fn etag(&self) -> EntityTag { | ||||||
|         let ino = self.file.metadata().map(|md| md.ino()).unwrap_or_default(); |         let ino = self.file.metadata().map(|md| md.ino()).unwrap_or_default(); | ||||||
|  |         let modtime = self.modtime(); | ||||||
|         EntityTag::new_strong(format!( |         EntityTag::new_strong(format!( | ||||||
|             "{:x}:{:x}:{:x}:{:x}", |             "{:x}:{}:{:x}:{:x}:{:x}", | ||||||
|             ino, |             ino, | ||||||
|             self.info.size, |             self.selection, | ||||||
|             self.info.modtime.unix_timestamp() as u64, |             self.size(), | ||||||
|             self.info.modtime.nanosecond(), |             modtime.unix_timestamp() as u64, | ||||||
|  |             modtime.nanosecond(), | ||||||
|         )) |         )) | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     /// Creates an `HttpResponse` with file as a streaming body.
 |     /// Creates an `HttpResponse` with file as a streaming body.
 | ||||||
|     pub fn into_response(self, req: &HttpRequest) -> HttpResponse<BoxBody> { |     pub fn into_response(self, req: &HttpRequest) -> HttpResponse<BoxBody> { | ||||||
|  |         let total_size = self.size(); | ||||||
|         let etag = self.etag(); |         let etag = self.etag(); | ||||||
|         let last_modified = HttpDate::from(SystemTime::from(self.info.modtime)); |         let last_modified = HttpDate::from(SystemTime::from(self.modtime())); | ||||||
| 
 | 
 | ||||||
|         let precondition_failed = precondition_failed(req, &etag, &last_modified); |         let precondition_failed = precondition_failed(req, &etag, &last_modified); | ||||||
|         let not_modified = not_modified(req, &etag, &last_modified); |         let not_modified = not_modified(req, &etag, &last_modified); | ||||||
|  | @ -68,14 +159,14 @@ impl DownloadingFile { | ||||||
|             header::CONTENT_DISPOSITION, |             header::CONTENT_DISPOSITION, | ||||||
|             ContentDisposition { |             ContentDisposition { | ||||||
|                 disposition: DispositionType::Attachment, |                 disposition: DispositionType::Attachment, | ||||||
|                 parameters: vec![DispositionParam::Filename(self.info.name)], |                 parameters: vec![DispositionParam::Filename(self.name().to_string())], | ||||||
|             }, |             }, | ||||||
|         )); |         )); | ||||||
|         res.insert_header((header::LAST_MODIFIED, last_modified)); |         res.insert_header((header::LAST_MODIFIED, last_modified)); | ||||||
|         res.insert_header((header::ETAG, etag)); |         res.insert_header((header::ETAG, etag)); | ||||||
|         res.insert_header((header::ACCEPT_RANGES, "bytes")); |         res.insert_header((header::ACCEPT_RANGES, "bytes")); | ||||||
| 
 | 
 | ||||||
|         let mut length = self.info.size; |         let mut length = total_size; | ||||||
|         let mut offset = 0; |         let mut offset = 0; | ||||||
| 
 | 
 | ||||||
|         // check for range header
 |         // check for range header
 | ||||||
|  | @ -97,7 +188,7 @@ impl DownloadingFile { | ||||||
|                             "bytes {}-{}/{}", |                             "bytes {}-{}/{}", | ||||||
|                             offset, |                             offset, | ||||||
|                             offset + length - 1, |                             offset + length - 1, | ||||||
|                             self.info.size |                             total_size, | ||||||
|                         ), |                         ), | ||||||
|                     )); |                     )); | ||||||
|                 } else { |                 } else { | ||||||
|  | @ -118,9 +209,9 @@ impl DownloadingFile { | ||||||
|                 .map_into_boxed_body(); |                 .map_into_boxed_body(); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         let reader = new_live_reader(length, offset, self.file, self.storage_path); |         let reader = new_live_reader(length, self.baseline_offset() + offset, self.file, self.storage_path); | ||||||
| 
 | 
 | ||||||
|         if offset != 0 || length != self.info.size { |         if offset != 0 || length != total_size { | ||||||
|             res.status(StatusCode::PARTIAL_CONTENT); |             res.status(StatusCode::PARTIAL_CONTENT); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										53
									
								
								src/main.rs
									
										
									
									
									
								
							
							
						
						
									
										53
									
								
								src/main.rs
									
										
									
									
									
								
							|  | @ -1,9 +1,10 @@ | ||||||
| mod download; | mod download; | ||||||
| mod store; | mod store; | ||||||
|  | mod timestamp; | ||||||
| mod upload; | mod upload; | ||||||
| mod zip; | mod zip; | ||||||
| 
 | 
 | ||||||
| use std::{fmt::Debug, fs::File, path::PathBuf, str::FromStr}; | use std::{fmt::Debug, path::PathBuf, str::FromStr}; | ||||||
| 
 | 
 | ||||||
| use actix_files::NamedFile; | use actix_files::NamedFile; | ||||||
| use actix_web::{ | use actix_web::{ | ||||||
|  | @ -11,11 +12,12 @@ use actix_web::{ | ||||||
|     HttpServer, Responder, |     HttpServer, Responder, | ||||||
| }; | }; | ||||||
| use actix_web_actors::ws; | use actix_web_actors::ws; | ||||||
|  | use askama::Template; | ||||||
| use bytesize::ByteSize; | use bytesize::ByteSize; | ||||||
| use log::{error, warn}; | use log::{error, warn}; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| use store::FileStore; | use store::{FileStore, StoredFile}; | ||||||
| use tokio::sync::RwLock; | use tokio::{fs::File, sync::RwLock}; | ||||||
| 
 | 
 | ||||||
| const APP_NAME: &str = "transbeam"; | const APP_NAME: &str = "transbeam"; | ||||||
| 
 | 
 | ||||||
|  | @ -49,9 +51,19 @@ pub fn log_auth_failure(ip_addr: &str) { | ||||||
|     warn!("Incorrect authentication attempt from {}", ip_addr); |     warn!("Incorrect authentication attempt from {}", ip_addr); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | 
 | ||||||
| #[derive(Deserialize)] | #[derive(Deserialize)] | ||||||
| struct DownloadRequest { | struct DownloadRequest { | ||||||
|     code: String, |     code: String, | ||||||
|  |     download: Option<download::DownloadSelection>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[derive(Template)] | ||||||
|  | #[template(path = "download.html")] | ||||||
|  | struct DownloadInfo<'a> { | ||||||
|  |     file: StoredFile, | ||||||
|  |     code: &'a str, | ||||||
|  |     available: u64, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| #[get("/download")] | #[get("/download")] | ||||||
|  | @ -62,29 +74,52 @@ async fn handle_download( | ||||||
| ) -> actix_web::Result<HttpResponse> { | ) -> actix_web::Result<HttpResponse> { | ||||||
|     let code = &download.code; |     let code = &download.code; | ||||||
|     if !store::is_valid_storage_code(code) { |     if !store::is_valid_storage_code(code) { | ||||||
|         return download_not_found(req, data); |         return not_found(req, data, true); | ||||||
|     } |     } | ||||||
|     let info = data.file_store.read().await.lookup_file(code); |     let info = data.file_store.read().await.lookup_file(code); | ||||||
|     if let Some(info) = info { |     let info = if let Some(i) = info { | ||||||
|  |         i | ||||||
|  |     } else { | ||||||
|  |         return not_found(req, data, true) | ||||||
|  |     }; | ||||||
|  | 
 | ||||||
|     let storage_path = data.config.storage_dir.join(code); |     let storage_path = data.config.storage_dir.join(code); | ||||||
|         let file = File::open(&storage_path)?; |     let file = File::open(&storage_path).await?; | ||||||
|  |     if let Some(selection) = download.download { | ||||||
|  |         if let download::DownloadSelection::One(n) = selection { | ||||||
|  |             if let Some(ref files) = info.contents { | ||||||
|  |                 if n >= files.len() { | ||||||
|  |                     return not_found(req, data, false); | ||||||
|  |                 } | ||||||
|  |             } else { | ||||||
|  |                 return not_found(req, data, false); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|         Ok(download::DownloadingFile { |         Ok(download::DownloadingFile { | ||||||
|             file, |             file: file.into_std().await, | ||||||
|             storage_path, |             storage_path, | ||||||
|             info, |             info, | ||||||
|  |             selection, | ||||||
|         } |         } | ||||||
|            .into_response(&req)) |            .into_response(&req)) | ||||||
|     } else { |     } else { | ||||||
|         download_not_found(req, data) |         Ok(HttpResponse::Ok().body(DownloadInfo { | ||||||
|  |             file: info, | ||||||
|  |             code, | ||||||
|  |             available: file.metadata().await?.len(), | ||||||
|  |         }.render().unwrap())) | ||||||
|     } |     } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| fn download_not_found( | fn not_found( | ||||||
|     req: HttpRequest, |     req: HttpRequest, | ||||||
|     data: web::Data<AppState>, |     data: web::Data<AppState>, | ||||||
|  |     report: bool, | ||||||
| ) -> actix_web::Result<HttpResponse> { | ) -> actix_web::Result<HttpResponse> { | ||||||
|  |     if report { | ||||||
|         let ip_addr = get_ip_addr(&req, data.config.reverse_proxy); |         let ip_addr = get_ip_addr(&req, data.config.reverse_proxy); | ||||||
|         log_auth_failure(&ip_addr); |         log_auth_failure(&ip_addr); | ||||||
|  |     } | ||||||
|     Ok(NamedFile::open(data.config.static_dir.join("404.html"))? |     Ok(NamedFile::open(data.config.static_dir.join("404.html"))? | ||||||
|         .set_status_code(StatusCode::NOT_FOUND) |         .set_status_code(StatusCode::NOT_FOUND) | ||||||
|         .into_response(&req)) |         .into_response(&req)) | ||||||
|  |  | ||||||
							
								
								
									
										50
									
								
								src/store.rs
									
										
									
									
									
								
							
							
						
						
									
										50
									
								
								src/store.rs
									
										
									
									
									
								
							|  | @ -10,12 +10,15 @@ use rand::{ | ||||||
|     thread_rng, Rng, |     thread_rng, Rng, | ||||||
| }; | }; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
|  | use serde_with::skip_serializing_none; | ||||||
| use time::OffsetDateTime; | use time::OffsetDateTime; | ||||||
| use tokio::{ | use tokio::{ | ||||||
|     fs::File, |     fs::File, | ||||||
|     io::{AsyncReadExt, AsyncWriteExt}, |     io::{AsyncReadExt, AsyncWriteExt}, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | use crate::upload::UploadedFile; | ||||||
|  | 
 | ||||||
| const STATE_FILE_NAME: &str = "files.json"; | const STATE_FILE_NAME: &str = "files.json"; | ||||||
| const MAX_STORAGE_FILES: usize = 1024; | const MAX_STORAGE_FILES: usize = 1024; | ||||||
| 
 | 
 | ||||||
|  | @ -33,55 +36,16 @@ pub fn is_valid_storage_code(s: &str) -> bool { | ||||||
|         .all(|c| c.is_ascii_alphanumeric() || c == &b'-') |         .all(|c| c.is_ascii_alphanumeric() || c == &b'-') | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #[skip_serializing_none] | ||||||
| #[derive(Clone, Deserialize, Serialize)] | #[derive(Clone, Deserialize, Serialize)] | ||||||
| pub struct StoredFile { | pub struct StoredFile { | ||||||
|     pub name: String, |     pub name: String, | ||||||
|     pub size: u64, |     pub size: u64, | ||||||
|     #[serde(with = "timestamp")] |     #[serde(with = "crate::timestamp")] | ||||||
|     pub modtime: OffsetDateTime, |     pub modtime: OffsetDateTime, | ||||||
|     #[serde(with = "timestamp")] |     #[serde(with = "crate::timestamp")] | ||||||
|     pub expiry: OffsetDateTime, |     pub expiry: OffsetDateTime, | ||||||
| } |     pub contents: Option<Vec<UploadedFile>>, | ||||||
| 
 |  | ||||||
| pub(crate) mod timestamp { |  | ||||||
|     use core::fmt; |  | ||||||
| 
 |  | ||||||
|     use serde::{de::Visitor, Deserializer, Serializer}; |  | ||||||
|     use time::OffsetDateTime; |  | ||||||
| 
 |  | ||||||
|     pub(crate) fn serialize<S: Serializer>( |  | ||||||
|         time: &OffsetDateTime, |  | ||||||
|         ser: S, |  | ||||||
|     ) -> Result<S::Ok, S::Error> { |  | ||||||
|         ser.serialize_i64(time.unix_timestamp()) |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     struct I64Visitor; |  | ||||||
| 
 |  | ||||||
|     impl<'de> Visitor<'de> for I64Visitor { |  | ||||||
|         type Value = i64; |  | ||||||
| 
 |  | ||||||
|         fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { |  | ||||||
|             write!(formatter, "an integer") |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E> { |  | ||||||
|             Ok(v) |  | ||||||
|         } |  | ||||||
| 
 |  | ||||||
|         fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E> { |  | ||||||
|             Ok(v as i64) |  | ||||||
|         } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     pub(crate) fn deserialize<'de, D: Deserializer<'de>>( |  | ||||||
|         de: D, |  | ||||||
|     ) -> Result<OffsetDateTime, D::Error> { |  | ||||||
|         Ok( |  | ||||||
|             OffsetDateTime::from_unix_timestamp(de.deserialize_i64(I64Visitor)?) |  | ||||||
|                 .unwrap_or_else(|_| OffsetDateTime::now_utc()), |  | ||||||
|         ) |  | ||||||
|     } |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async fn is_valid_entry(key: &str, info: &StoredFile, storage_dir: &Path) -> bool { | async fn is_valid_entry(key: &str, info: &StoredFile, storage_dir: &Path) -> bool { | ||||||
|  |  | ||||||
							
								
								
									
										38
									
								
								src/timestamp.rs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								src/timestamp.rs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,38 @@ | ||||||
|  | use core::fmt; | ||||||
|  | 
 | ||||||
|  | use serde::{de::Visitor, Deserializer, Serializer}; | ||||||
|  | use time::OffsetDateTime; | ||||||
|  | 
 | ||||||
|  | pub(crate) fn serialize<S: Serializer>( | ||||||
|  |     time: &OffsetDateTime, | ||||||
|  |     ser: S, | ||||||
|  | ) -> Result<S::Ok, S::Error> { | ||||||
|  |     ser.serialize_i64(time.unix_timestamp()) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | struct I64Visitor; | ||||||
|  | 
 | ||||||
|  | impl<'de> Visitor<'de> for I64Visitor { | ||||||
|  |     type Value = i64; | ||||||
|  | 
 | ||||||
|  |     fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { | ||||||
|  |         write!(formatter, "an integer") | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn visit_i64<E>(self, v: i64) -> Result<Self::Value, E> { | ||||||
|  |         Ok(v) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E> { | ||||||
|  |         Ok(v as i64) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | pub(crate) fn deserialize<'de, D: Deserializer<'de>>( | ||||||
|  |     de: D, | ||||||
|  | ) -> Result<OffsetDateTime, D::Error> { | ||||||
|  |     Ok( | ||||||
|  |         OffsetDateTime::from_unix_timestamp(de.deserialize_i64(I64Visitor)?) | ||||||
|  |             .unwrap_or_else(|_| OffsetDateTime::now_utc()), | ||||||
|  |     ) | ||||||
|  | } | ||||||
|  | @ -93,9 +93,11 @@ impl Actor for Uploader { | ||||||
| 
 | 
 | ||||||
| type Context = <Uploader as Actor>::Context; | type Context = <Uploader as Actor>::Context; | ||||||
| 
 | 
 | ||||||
|  | #[derive(Clone, Deserialize, Serialize)] | ||||||
| pub struct UploadedFile { | pub struct UploadedFile { | ||||||
|     pub name: String, |     pub name: String, | ||||||
|     pub size: u64, |     pub size: u64, | ||||||
|  |     #[serde(with = "crate::timestamp")] | ||||||
|     pub modtime: OffsetDateTime, |     pub modtime: OffsetDateTime, | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -273,7 +275,7 @@ impl Uploader { | ||||||
|                 let (writer, name, size, modtime): (Box<dyn Write>, _, _, _) = if files.len() > 1 { |                 let (writer, name, size, modtime): (Box<dyn Write>, _, _, _) = if files.len() > 1 { | ||||||
|                     info!("Wrapping in zipfile generator"); |                     info!("Wrapping in zipfile generator"); | ||||||
|                     let now = OffsetDateTime::now_utc(); |                     let now = OffsetDateTime::now_utc(); | ||||||
|                     let zip_writer = super::zip::ZipGenerator::new(files, writer); |                     let zip_writer = super::zip::ZipGenerator::new(files.clone(), writer); | ||||||
|                     let size = zip_writer.total_size(); |                     let size = zip_writer.total_size(); | ||||||
|                     let download_filename = super::APP_NAME.to_owned() |                     let download_filename = super::APP_NAME.to_owned() | ||||||
|                         + &now.format(FILENAME_DATE_FORMAT).unwrap() |                         + &now.format(FILENAME_DATE_FORMAT).unwrap() | ||||||
|  | @ -293,6 +295,7 @@ impl Uploader { | ||||||
|                     size, |                     size, | ||||||
|                     modtime, |                     modtime, | ||||||
|                     expiry: OffsetDateTime::now_utc() + lifetime * time::Duration::DAY, |                     expiry: OffsetDateTime::now_utc() + lifetime * time::Duration::DAY, | ||||||
|  |                     contents: if files.len() > 1 { Some(files) } else { None }, | ||||||
|                 }; |                 }; | ||||||
|                 let state = self.app_state.clone(); |                 let state = self.app_state.clone(); | ||||||
|                 let storage_filename = self.storage_filename.clone(); |                 let storage_filename = self.storage_filename.clone(); | ||||||
|  |  | ||||||
|  | @ -37,6 +37,12 @@ fn file_entries_size(files: &[UploadedFile]) -> u64 { | ||||||
|     total |     total | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | pub fn file_data_offset(files: &[UploadedFile], idx: usize) -> u64 { | ||||||
|  |     file_entries_size(&files[..idx]) | ||||||
|  |         + LOCAL_HEADER_SIZE_MINUS_FILENAME | ||||||
|  |         + files[idx].name.len() as u64 | ||||||
|  | } | ||||||
|  | 
 | ||||||
| fn central_directory_size(files: &[UploadedFile]) -> u64 { | fn central_directory_size(files: &[UploadedFile]) -> u64 { | ||||||
|     let mut total = 0; |     let mut total = 0; | ||||||
|     for file in files.iter() { |     for file in files.iter() { | ||||||
|  |  | ||||||
|  | @ -127,6 +127,18 @@ td { | ||||||
|     padding: 10px; |     padding: 10px; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | td.file_unavailable { | ||||||
|  |     display: none; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | tr.unavailable td.file_unavailable { | ||||||
|  |     display: revert; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | tr.unavailable td.file_download { | ||||||
|  |     display: none; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .delete_button { | .delete_button { | ||||||
|     background-color: #888; |     background-color: #888; | ||||||
|     mask-image: url("../images/feather-icons/x.svg"); |     mask-image: url("../images/feather-icons/x.svg"); | ||||||
|  | @ -142,6 +154,75 @@ td { | ||||||
|     background-color: #f00; |     background-color: #f00; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | #download_toplevel { | ||||||
|  |     font-size: 24px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #download_toplevel div { | ||||||
|  |     margin-top: 5px; | ||||||
|  |     margin-bottom: 5px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #download_toplevel .file_name { | ||||||
|  |     font-weight: 500; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #download_toplevel .file_download { | ||||||
|  |     width: 75px; | ||||||
|  |     height: 75px; | ||||||
|  |     margin-left: auto; | ||||||
|  |     margin-right: auto; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #download_contents h3 { | ||||||
|  |     margin-bottom: 0; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #download_contents table { | ||||||
|  |     margin-top: 0; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .file_download { | ||||||
|  |     position: relative; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .file_download .download_button { | ||||||
|  |     position: absolute; | ||||||
|  |     top: 0; | ||||||
|  |     bottom: 0; | ||||||
|  |     left: 0; | ||||||
|  |     right: 0; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | td.file_download { | ||||||
|  |     padding: 15px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .download_button { | ||||||
|  |     background-color: #33f; | ||||||
|  |     mask-image: url("../images/feather-icons/download.svg"); | ||||||
|  |     mask-size: contain; | ||||||
|  |     mask-repeat: no-repeat; | ||||||
|  |     mask-position: center; | ||||||
|  |     padding-left: 15px; | ||||||
|  |     padding-right: 15px; | ||||||
|  |     cursor: pointer; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .download_button:hover { | ||||||
|  |     background-color: #00b; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .file_unavailable { | ||||||
|  |     background-color: #999; | ||||||
|  |     mask-image: url("../images/feather-icons/clock.svg"); | ||||||
|  |     mask-size: contain; | ||||||
|  |     mask-repeat: no-repeat; | ||||||
|  |     mask-position: center; | ||||||
|  |     padding-left: 15px; | ||||||
|  |     padding-right: 15px; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| td.file_size { | td.file_size { | ||||||
|     text-align: right; |     text-align: right; | ||||||
| } | } | ||||||
|  | @ -162,6 +243,7 @@ button, .fake_button, input[type="submit"] { | ||||||
|     border-radius: 4px; |     border-radius: 4px; | ||||||
|     padding: 6px 12px; |     padding: 6px 12px; | ||||||
|     cursor: pointer; |     cursor: pointer; | ||||||
|  |     text-decoration: none; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| button:hover, .fake_button:hover, input[type="submit"]:hover { | button:hover, .fake_button:hover, input[type="submit"]:hover { | ||||||
|  |  | ||||||
							
								
								
									
										1
									
								
								static/images/feather-icons/clock.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								static/images/feather-icons/clock.svg
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | ||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-clock"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg> | ||||||
| After Width: | Height: | Size: 304 B | 
							
								
								
									
										1
									
								
								static/images/feather-icons/download.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								static/images/feather-icons/download.svg
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | ||||||
|  | <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg> | ||||||
| After Width: | Height: | Size: 370 B | 
|  | @ -8,7 +8,7 @@ | ||||||
|     <link rel="apple-touch-icon" href="images/site-icons/transbeam-apple.png"/> |     <link rel="apple-touch-icon" href="images/site-icons/transbeam-apple.png"/> | ||||||
|     <link rel="manifest" href="manifest.json"/> |     <link rel="manifest" href="manifest.json"/> | ||||||
|     <script src="js/util.js"></script> |     <script src="js/util.js"></script> | ||||||
|     <script src="js/download.js"></script> |     <script src="js/download-landing.js"></script> | ||||||
|     <script src="js/upload.js"></script> |     <script src="js/upload.js"></script> | ||||||
|     <title>transbeam</title> |     <title>transbeam</title> | ||||||
|   </head> |   </head> | ||||||
|  |  | ||||||
							
								
								
									
										46
									
								
								templates/download.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								templates/download.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,46 @@ | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="en"> | ||||||
|  |   <head> | ||||||
|  |     <meta charset="utf-8"/> | ||||||
|  |     <meta name="viewport" content="width=device-width, initial-scale=1"/> | ||||||
|  |     <link rel="stylesheet" type="text/css" href="css/transbeam.css"/> | ||||||
|  |     <link rel="stylesheet" type="text/css" href="css/download.css"/> | ||||||
|  |     <link rel="apple-touch-icon" href="images/site-icons/transbeam-apple.png"/> | ||||||
|  |     <link rel="manifest" href="manifest.json"/> | ||||||
|  |     <script src="js/util.js"></script> | ||||||
|  |     <script src="js/download.js"></script> | ||||||
|  |     <title>{{ file.name }} - transbeam</title> | ||||||
|  |   </head> | ||||||
|  |   <body> | ||||||
|  |     <div id="header"> | ||||||
|  |       <img src="images/site-icons/transbeam.svg" height="128"> | ||||||
|  |       <h1>transbeam</h1> | ||||||
|  |     </div> | ||||||
|  |     <div id="download_toplevel" class="section"> | ||||||
|  |       <div class="file_name">{{ file.name }}</div> | ||||||
|  |       <div class="file_size">{{ bytesize::to_string(file.size.clone(), false).replace(" ", "") }}</div> | ||||||
|  |       <div class="file_download"><a class="download_button" href="download?code={{ code }}&download=all"></a></div> | ||||||
|  |     </div> | ||||||
|  |     {% match file.contents %} | ||||||
|  |       {% when Some with (files) %} | ||||||
|  |         <div id="download_contents" class="section"> | ||||||
|  |           <h3>Contents</h3> | ||||||
|  |           <table> | ||||||
|  |             {% for f in files %} | ||||||
|  |               <tr class="{% if zip::file_data_offset(files.as_ref(), loop.index0.clone()) > available %}unavailable{% endif %}"> | ||||||
|  |                 <td class="file_size">{{ bytesize::to_string(f.size.clone(), false).replace(" ", "") }}</td> | ||||||
|  |                 <td class="file_name">{{ f.name }}</td> | ||||||
|  |                 <td class="file_download"><a class="download_button" href="download?code={{ code }}&download={{ loop.index0 }}"></a></td> | ||||||
|  |                 <td class="file_unavailable"></td> | ||||||
|  |               </tr> | ||||||
|  |             {% endfor %} | ||||||
|  |           </table> | ||||||
|  |         </div> | ||||||
|  |       {% else %} | ||||||
|  |     {% endmatch %} | ||||||
|  |     <div id="footer"> | ||||||
|  |       <h5>(c) 2022 xenofem, MIT licensed</h5> | ||||||
|  |       <h5><a target="_blank" href="https://git.xeno.science/xenofem/transbeam">source</a></h5> | ||||||
|  |     </div> | ||||||
|  |   </body> | ||||||
|  | </html> | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue