From 007289ffe55ee1fffba9a848169136bed6fa46ed Mon Sep 17 00:00:00 2001 From: xenofem Date: Tue, 24 May 2022 15:14:31 -0400 Subject: [PATCH] allow downloading individual files from bundle --- Cargo.lock | 160 ++++++++++++++++++ Cargo.toml | 2 + README.md | 1 + src/download.rs | 113 +++++++++++-- src/main.rs | 61 +++++-- src/store.rs | 50 +----- src/timestamp.rs | 38 +++++ src/upload.rs | 5 +- src/zip.rs | 6 + static/css/transbeam.css | 82 +++++++++ static/images/feather-icons/clock.svg | 1 + static/images/feather-icons/download.svg | 1 + static/index.html | 2 +- .../js/{download.js => download-landing.js} | 0 templates/download.html | 46 +++++ 15 files changed, 499 insertions(+), 69 deletions(-) create mode 100644 src/timestamp.rs create mode 100644 static/images/feather-icons/clock.svg create mode 100644 static/images/feather-icons/download.svg rename static/js/{download.js => download-landing.js} (100%) create mode 100644 templates/download.html diff --git a/Cargo.lock b/Cargo.lock index 9e19214..451fec0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,12 +309,54 @@ dependencies = [ "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]] name = "askama_escape" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "atty" version = "0.2.14" @@ -523,6 +565,41 @@ dependencies = [ "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]] name = "derive_more" version = "0.99.17" @@ -732,12 +809,24 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4a1e36c821dbe04574f602848a19f742f4fb3c98d40449f11bcad18d6b17421" +[[package]] +name = "humansize" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02296996cb8796d7c6e3bc2d9211b7802812d36999a51bb754123ead7d37d026" + [[package]] name = "humantime" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.2.3" @@ -879,6 +968,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.5.1" @@ -921,6 +1016,16 @@ dependencies = [ "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]] name = "ntapi" version = "0.3.7" @@ -930,6 +1035,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.13.1" @@ -1126,6 +1240,12 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cc38e8fa666e2de3c4aba7edeb5ffc5246c1c2ed0e3d17e560aeeba736b23f" + [[package]] name = "ryu" version = "1.0.9" @@ -1193,6 +1313,29 @@ dependencies = [ "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]] name = "sha-1" version = "0.10.0" @@ -1257,6 +1400,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "subtle" version = "2.4.1" @@ -1381,6 +1530,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82e1a7758622a465f8cee077614c73484dac5b836c02ff6a40d5d1010324d7" +dependencies = [ + "serde", +] + [[package]] name = "tracing" version = "0.1.34" @@ -1423,6 +1581,7 @@ dependencies = [ "actix-http", "actix-web", "actix-web-actors", + "askama", "bytes", "bytesize", "crc32fast", @@ -1438,6 +1597,7 @@ dependencies = [ "sanitise-file-name", "serde", "serde_json", + "serde_with", "thiserror", "time", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 51e2fa3..6d1940b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ actix-files = "0.6.0" actix-http = "3.0.4" actix-web = "4.0.1" actix-web-actors = "4.1.0" +askama = "0.11.1" bytes = "1.1.0" bytesize = "1.1.0" crc32fast = "1.3.2" @@ -28,6 +29,7 @@ rand = "0.8.5" sanitise-file-name = "1.0.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_with = "1.13.0" thiserror = "1" time = "0.3.9" tokio = { version = "1.17.0", features = ["full"] } diff --git a/README.md b/README.md index 64b38a0..7be6a73 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ sender has gone offline - Easy to send multiple files at once - they're bundled into a zip 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 should be safe across platforms - Rudimentary password authentication for uploading files diff --git a/src/download.rs b/src/download.rs index 6a4aeb2..21fabda 100644 --- a/src/download.rs +++ b/src/download.rs @@ -1,3 +1,4 @@ +use core::fmt; use std::{ cmp, fs::File, @@ -14,6 +15,8 @@ use futures_core::{ready, Stream}; use inotify::{Inotify, WatchMask}; use log::trace; use pin_project_lite::pin_project; +use serde::{de, Deserialize, Deserializer}; +use time::OffsetDateTime; use std::{os::unix::fs::MetadataExt, time::SystemTime}; use actix_web::{ @@ -30,7 +33,55 @@ use actix_web::{ 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(self, v: u64) -> Result { + Ok(DownloadSelection::One(v as usize)) + } + + fn visit_str(self, v: &str) -> Result + where E: de::Error { + if v == "all" { + Ok(DownloadSelection::All) + } else if let Ok(n) = v.parse::() { + Ok(DownloadSelection::One(n)) + } else { + Err(de::Error::invalid_value(de::Unexpected::Str(v), &self)) + } + } +} + +impl<'de> Deserialize<'de> for DownloadSelection { + fn deserialize>( + de: D + ) -> Result { + de.deserialize_any(SelectionVisitor) + } +} // This is copied substantially from actix-files, with some tweaks @@ -38,24 +89,64 @@ pub(crate) struct DownloadingFile { pub(crate) file: File, pub(crate) storage_path: PathBuf, pub(crate) info: StoredFile, + pub(crate) selection: DownloadSelection, } 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 { let ino = self.file.metadata().map(|md| md.ino()).unwrap_or_default(); + let modtime = self.modtime(); EntityTag::new_strong(format!( - "{:x}:{:x}:{:x}:{:x}", + "{:x}:{}:{:x}:{:x}:{:x}", ino, - self.info.size, - self.info.modtime.unix_timestamp() as u64, - self.info.modtime.nanosecond(), + self.selection, + self.size(), + modtime.unix_timestamp() as u64, + modtime.nanosecond(), )) } /// Creates an `HttpResponse` with file as a streaming body. pub fn into_response(self, req: &HttpRequest) -> HttpResponse { + let total_size = self.size(); 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 not_modified = not_modified(req, &etag, &last_modified); @@ -68,14 +159,14 @@ impl DownloadingFile { header::CONTENT_DISPOSITION, ContentDisposition { 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::ETAG, etag)); res.insert_header((header::ACCEPT_RANGES, "bytes")); - let mut length = self.info.size; + let mut length = total_size; let mut offset = 0; // check for range header @@ -97,7 +188,7 @@ impl DownloadingFile { "bytes {}-{}/{}", offset, offset + length - 1, - self.info.size + total_size, ), )); } else { @@ -118,9 +209,9 @@ impl DownloadingFile { .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); } diff --git a/src/main.rs b/src/main.rs index adcf60d..5eb24f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ mod download; mod store; +mod timestamp; mod upload; 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_web::{ @@ -11,11 +12,12 @@ use actix_web::{ HttpServer, Responder, }; use actix_web_actors::ws; +use askama::Template; use bytesize::ByteSize; use log::{error, warn}; use serde::{Deserialize, Serialize}; -use store::FileStore; -use tokio::sync::RwLock; +use store::{FileStore, StoredFile}; +use tokio::{fs::File, sync::RwLock}; const APP_NAME: &str = "transbeam"; @@ -49,9 +51,19 @@ pub fn log_auth_failure(ip_addr: &str) { warn!("Incorrect authentication attempt from {}", ip_addr); } + #[derive(Deserialize)] struct DownloadRequest { code: String, + download: Option, +} + +#[derive(Template)] +#[template(path = "download.html")] +struct DownloadInfo<'a> { + file: StoredFile, + code: &'a str, + available: u64, } #[get("/download")] @@ -62,29 +74,52 @@ async fn handle_download( ) -> actix_web::Result { let code = &download.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); - if let Some(info) = info { - let storage_path = data.config.storage_dir.join(code); - let file = File::open(&storage_path)?; + 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 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 { - file, + file: file.into_std().await, storage_path, info, + selection, } - .into_response(&req)) + .into_response(&req)) } 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, data: web::Data, + report: bool, ) -> actix_web::Result { - let ip_addr = get_ip_addr(&req, data.config.reverse_proxy); - log_auth_failure(&ip_addr); + if report { + let ip_addr = get_ip_addr(&req, data.config.reverse_proxy); + log_auth_failure(&ip_addr); + } Ok(NamedFile::open(data.config.static_dir.join("404.html"))? .set_status_code(StatusCode::NOT_FOUND) .into_response(&req)) diff --git a/src/store.rs b/src/store.rs index 98ef749..f8dfe47 100644 --- a/src/store.rs +++ b/src/store.rs @@ -10,12 +10,15 @@ use rand::{ thread_rng, Rng, }; use serde::{Deserialize, Serialize}; +use serde_with::skip_serializing_none; use time::OffsetDateTime; use tokio::{ fs::File, io::{AsyncReadExt, AsyncWriteExt}, }; +use crate::upload::UploadedFile; + const STATE_FILE_NAME: &str = "files.json"; 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'-') } +#[skip_serializing_none] #[derive(Clone, Deserialize, Serialize)] pub struct StoredFile { pub name: String, pub size: u64, - #[serde(with = "timestamp")] + #[serde(with = "crate::timestamp")] pub modtime: OffsetDateTime, - #[serde(with = "timestamp")] + #[serde(with = "crate::timestamp")] pub expiry: OffsetDateTime, -} - -pub(crate) mod timestamp { - use core::fmt; - - use serde::{de::Visitor, Deserializer, Serializer}; - use time::OffsetDateTime; - - pub(crate) fn serialize( - time: &OffsetDateTime, - ser: S, - ) -> Result { - 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(self, v: i64) -> Result { - Ok(v) - } - - fn visit_u64(self, v: u64) -> Result { - Ok(v as i64) - } - } - - pub(crate) fn deserialize<'de, D: Deserializer<'de>>( - de: D, - ) -> Result { - Ok( - OffsetDateTime::from_unix_timestamp(de.deserialize_i64(I64Visitor)?) - .unwrap_or_else(|_| OffsetDateTime::now_utc()), - ) - } + pub contents: Option>, } async fn is_valid_entry(key: &str, info: &StoredFile, storage_dir: &Path) -> bool { diff --git a/src/timestamp.rs b/src/timestamp.rs new file mode 100644 index 0000000..fb79450 --- /dev/null +++ b/src/timestamp.rs @@ -0,0 +1,38 @@ +use core::fmt; + +use serde::{de::Visitor, Deserializer, Serializer}; +use time::OffsetDateTime; + +pub(crate) fn serialize( + time: &OffsetDateTime, + ser: S, +) -> Result { + 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(self, v: i64) -> Result { + Ok(v) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(v as i64) + } +} + +pub(crate) fn deserialize<'de, D: Deserializer<'de>>( + de: D, +) -> Result { + Ok( + OffsetDateTime::from_unix_timestamp(de.deserialize_i64(I64Visitor)?) + .unwrap_or_else(|_| OffsetDateTime::now_utc()), + ) +} diff --git a/src/upload.rs b/src/upload.rs index 02cb518..c266a82 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -93,9 +93,11 @@ impl Actor for Uploader { type Context = ::Context; +#[derive(Clone, Deserialize, Serialize)] pub struct UploadedFile { pub name: String, pub size: u64, + #[serde(with = "crate::timestamp")] pub modtime: OffsetDateTime, } @@ -273,7 +275,7 @@ impl Uploader { let (writer, name, size, modtime): (Box, _, _, _) = if files.len() > 1 { info!("Wrapping in zipfile generator"); 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 download_filename = super::APP_NAME.to_owned() + &now.format(FILENAME_DATE_FORMAT).unwrap() @@ -293,6 +295,7 @@ impl Uploader { size, modtime, expiry: OffsetDateTime::now_utc() + lifetime * time::Duration::DAY, + contents: if files.len() > 1 { Some(files) } else { None }, }; let state = self.app_state.clone(); let storage_filename = self.storage_filename.clone(); diff --git a/src/zip.rs b/src/zip.rs index dbb2106..c0001e8 100644 --- a/src/zip.rs +++ b/src/zip.rs @@ -37,6 +37,12 @@ fn file_entries_size(files: &[UploadedFile]) -> u64 { 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 { let mut total = 0; for file in files.iter() { diff --git a/static/css/transbeam.css b/static/css/transbeam.css index 8690fd4..48d38ba 100644 --- a/static/css/transbeam.css +++ b/static/css/transbeam.css @@ -127,6 +127,18 @@ td { padding: 10px; } +td.file_unavailable { + display: none; +} + +tr.unavailable td.file_unavailable { + display: revert; +} + +tr.unavailable td.file_download { + display: none; +} + .delete_button { background-color: #888; mask-image: url("../images/feather-icons/x.svg"); @@ -142,6 +154,75 @@ td { 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 { text-align: right; } @@ -162,6 +243,7 @@ button, .fake_button, input[type="submit"] { border-radius: 4px; padding: 6px 12px; cursor: pointer; + text-decoration: none; } button:hover, .fake_button:hover, input[type="submit"]:hover { diff --git a/static/images/feather-icons/clock.svg b/static/images/feather-icons/clock.svg new file mode 100644 index 0000000..ea3f5e5 --- /dev/null +++ b/static/images/feather-icons/clock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/images/feather-icons/download.svg b/static/images/feather-icons/download.svg new file mode 100644 index 0000000..76767a9 --- /dev/null +++ b/static/images/feather-icons/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/index.html b/static/index.html index e930318..7c82084 100644 --- a/static/index.html +++ b/static/index.html @@ -8,7 +8,7 @@ - + transbeam diff --git a/static/js/download.js b/static/js/download-landing.js similarity index 100% rename from static/js/download.js rename to static/js/download-landing.js diff --git a/templates/download.html b/templates/download.html new file mode 100644 index 0000000..413cca4 --- /dev/null +++ b/templates/download.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + {{ file.name }} - transbeam + + + +
+
{{ file.name }}
+
{{ bytesize::to_string(file.size.clone(), false).replace(" ", "") }}
+
+
+ {% match file.contents %} + {% when Some with (files) %} +
+

Contents

+ + {% for f in files %} + + + + + + + {% endfor %} +
{{ bytesize::to_string(f.size.clone(), false).replace(" ", "") }}{{ f.name }}
+
+ {% else %} + {% endmatch %} + + +