Compare commits
	
		
			No commits in common. "6ceb79374a1814258f13ed4d7bcf4f580d84df7c" and "bfe7fcde9948f16afbdb1b456b0ff27dd0c2addc" have entirely different histories.
		
	
	
		
			6ceb79374a
			...
			bfe7fcde99
		
	
		
					 15 changed files with 270 additions and 542 deletions
				
			
		
							
								
								
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -3,4 +3,3 @@ | |||
| /result | ||||
| flamegraph.svg | ||||
| perf.data* | ||||
| .env | ||||
							
								
								
									
										53
									
								
								API.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								API.md
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,53 @@ | |||
| # transbeam websocket api | ||||
| 
 | ||||
| - After opening the connection, the client sends an upload manifest to | ||||
|   the server. This is a JSON object containing the following keys: | ||||
|   - `files`: a list of metadata objects for all the files to be | ||||
|     uploaded, in the exact order they will be sent.  This list must | ||||
|     contain at least 1 file and at most 256 files. Each file metadata | ||||
|     object has the following keys, all required: | ||||
|     - `name`: The name of the file. This will be sanitised on the | ||||
|       server side, but the sanitisation library isn't especially | ||||
|       restrictive; most Unicode code points will be allowed through | ||||
|       as-is. | ||||
|     - `size`: The exact size of the file, in bytes. | ||||
|     - `modtime`: The modification time of the file, as milliseconds | ||||
|       since the Unix epoch. | ||||
|   - `lifetime`: an integer number of days the files should be kept | ||||
|     for. | ||||
| 
 | ||||
| - Once the server receives the metadata, it will respond with a | ||||
|   JSON-encoded object containing at least the field `type`, and | ||||
|   possibly other fields as well. The types of message, and their | ||||
|   associated extra fields if any, are as follows: | ||||
|   - `ready`: The server will accept the upload and is ready to receive | ||||
|     data. | ||||
|     - `code`: A code string that can be used to download the files, | ||||
|       starting now. | ||||
|   - `too_big`: The upload is rejected because the total size of the | ||||
|     files is bigger than the server is willing to accept. | ||||
|     - `max_size`: The maximum total upload size the server will | ||||
|       accept.  This is subject to change if the admin changes the | ||||
|       config, or if the server's storage space is filling up. | ||||
|   - `too_long`: The upload is rejected because the requested lifetime | ||||
|     is longer than the server will allow. | ||||
|     - `max_days`: The maximum number of days the client can request | ||||
|       files be kept for. | ||||
|   - `error`: A miscellaneous error has occurred. | ||||
|     - `details`: A string with more information about the error. | ||||
| 
 | ||||
|   If the message type is anything other than `ready`, the connection | ||||
|   will be closed by the server. | ||||
| 
 | ||||
| - If the server is ready to receive files, the client begins sending | ||||
|   chunks of data from the files, as raw binary blobs.  The client must | ||||
|   transmit each file's data in order from start to finish, and must | ||||
|   transmit the files in the same order they were listed in the | ||||
|   metadata.  The size of the chunks isn't currently specified, and | ||||
|   it's fine for a chunk to span the end of one file and the start of | ||||
|   the next.  After sending each chunk (that is, each complete | ||||
|   websocket message), the client must wait for the server to | ||||
|   acknowledge the chunk by sending back the string "ack", and then | ||||
|   send the next chunk if there is one. Once all chunks have been sent | ||||
|   and acknowledged, or once the server has sent a message other than | ||||
|   "ack" to indicate an error, the connection will be closed. | ||||
							
								
								
									
										14
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								Cargo.lock
									
										
									
										generated
									
									
									
								
							|  | @ -392,12 +392,6 @@ version = "1.1.0" | |||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "bytesize" | ||||
| version = "1.1.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "6c58ec36aac5066d5ca17df51b3e70279f5670a72102f5752cb7e7c856adfc70" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "bytestring" | ||||
| version = "1.0.0" | ||||
|  | @ -547,12 +541,6 @@ dependencies = [ | |||
|  "subtle", | ||||
| ] | ||||
| 
 | ||||
| [[package]] | ||||
| name = "dotenv" | ||||
| version = "0.15.0" | ||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||||
| checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" | ||||
| 
 | ||||
| [[package]] | ||||
| name = "encoding_rs" | ||||
| version = "0.8.31" | ||||
|  | @ -1424,9 +1412,7 @@ dependencies = [ | |||
|  "actix-web", | ||||
|  "actix-web-actors", | ||||
|  "bytes", | ||||
|  "bytesize", | ||||
|  "crc32fast", | ||||
|  "dotenv", | ||||
|  "env_logger", | ||||
|  "futures-core", | ||||
|  "inotify", | ||||
|  |  | |||
|  | @ -14,9 +14,7 @@ actix-http = "3.0.4" | |||
| actix-web = "4.0.1" | ||||
| actix-web-actors = "4.1.0" | ||||
| bytes = "1.1.0" | ||||
| bytesize = "1.1.0" | ||||
| crc32fast = "1.3.2" | ||||
| dotenv = "0.15" | ||||
| env_logger = "0.9" | ||||
| futures-core = "0.3" | ||||
| inotify = "0.10" | ||||
|  |  | |||
							
								
								
									
										30
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										30
									
								
								README.md
									
										
									
									
									
								
							|  | @ -17,33 +17,22 @@ | |||
|   file for receivers, with zero compression so extraction is quick | ||||
| - Sanitises filenames, using sensible non-obnoxious defaults that | ||||
|   should be safe across platforms | ||||
| - Rudimentary password authentication for uploading files | ||||
| - Fires a laser beam that turns you trans | ||||
| 
 | ||||
| ## configuration | ||||
| 
 | ||||
| transbeam is configured with the following environment variables: | ||||
| - `TRANSBEAM_STORAGE_DIR`: path where uploaded files should be stored | ||||
|   (default: `./storage`) | ||||
| - `TRANSBEAM_STATIC_DIR`: path where the web app's static files live | ||||
|   (default: `./static`) | ||||
| - `TRANSBEAM_PORT`: port to listen on localhost for http requests | ||||
|   (default: 8080) | ||||
| - `TRANSBEAM_MAX_LIFETIME`: maximum number of days files can be kept | ||||
|   for (default: 30) | ||||
| - `TRANSBEAM_MAX_UPLOAD_SIZE`: maximum size of a fileset being | ||||
|   uploaded (default: 16G) | ||||
| - `TRANSBEAM_STORAGE_DIR`: path where uploaded files should be stored (default: `./storage`) | ||||
| - `TRANSBEAM_STATIC_DIR`: path where the web app's static files live (default: `./static`) | ||||
| - `TRANSBEAM_PORT`: port to listen on localhost for http requests (default: 8080) | ||||
| - `TRANSBEAM_MAX_LIFETIME`: maximum number of days files can be kept for (default: 30) | ||||
| - `TRANSBEAM_MAX_UPLOAD_SIZE`: maximum size, in bytes, of a fileset | ||||
|   being uploaded (default: 16GiB) | ||||
| - `TRANSBEAM_MAX_STORAGE_SIZE`: maximum total size, in bytes, of all | ||||
|   files being stored by transbeam (default: 64G) | ||||
|   files being stored by transbeam (default: 64GiB) | ||||
| - `TRANSBEAM_MNEMONIC_CODES`: generate memorable download codes using | ||||
|   English words, rather than random alphanumeric strings (default: | ||||
|   true) | ||||
| - `TRANSBEAM_UPLOAD_PASSWORD`: password for uploading files. This | ||||
|   isn't meant to be a hardcore security measure, just a defense | ||||
|   against casual internet randos filling up your storage.  I strongly | ||||
|   recommend setting up fail2ban to throttle password attempts; | ||||
|   transbeam logs failed attempts along with IP addresses, in the form | ||||
|   `Incorrect authentication attempt from 203.0.113.12`. | ||||
| - `RUST_LOG`: log levels, for the app as a whole and/or for specific | ||||
|   submodules/libraries. See | ||||
|   [`env_logger`](https://docs.rs/env_logger/latest/env_logger/)'s | ||||
|  | @ -68,3 +57,8 @@ git clone https://git.xeno.science/xenofem/transbeam | |||
| cd transbeam | ||||
| cargo run --release | ||||
| ``` | ||||
| 
 | ||||
| ## todo | ||||
| 
 | ||||
| - uploader auth | ||||
| - downloader auth | ||||
|  |  | |||
							
								
								
									
										198
									
								
								src/main.rs
									
										
									
									
									
								
							
							
						
						
									
										198
									
								
								src/main.rs
									
										
									
									
									
								
							|  | @ -3,51 +3,20 @@ mod store; | |||
| mod upload; | ||||
| mod zip; | ||||
| 
 | ||||
| use std::{fmt::Debug, fs::File, path::PathBuf, str::FromStr}; | ||||
| use std::{fs::File, path::PathBuf}; | ||||
| 
 | ||||
| use actix_files::NamedFile; | ||||
| use actix_web::{ | ||||
|     get, http::StatusCode, middleware::Logger, post, web, App, HttpRequest, HttpResponse, | ||||
|     HttpServer, Responder, | ||||
|     get, middleware::Logger, web, App, HttpRequest, HttpResponse, HttpServer, Responder, | ||||
| }; | ||||
| use actix_web_actors::ws; | ||||
| use bytesize::ByteSize; | ||||
| use log::{error, warn}; | ||||
| use serde::{Deserialize, Serialize}; | ||||
| use log::error; | ||||
| use serde::Deserialize; | ||||
| use store::FileStore; | ||||
| use tokio::sync::RwLock; | ||||
| 
 | ||||
| const APP_NAME: &str = "transbeam"; | ||||
| 
 | ||||
| struct AppState { | ||||
|     file_store: RwLock<FileStore>, | ||||
|     config: Config, | ||||
| } | ||||
| 
 | ||||
| struct Config { | ||||
|     max_upload_size: u64, | ||||
|     max_lifetime: u16, | ||||
|     upload_password: String, | ||||
|     storage_dir: PathBuf, | ||||
|     static_dir: PathBuf, | ||||
|     reverse_proxy: bool, | ||||
|     mnemonic_codes: bool, | ||||
| } | ||||
| 
 | ||||
| pub fn get_ip_addr(req: &HttpRequest, reverse_proxy: bool) -> String { | ||||
|     let conn_info = req.connection_info(); | ||||
|     if reverse_proxy { | ||||
|         conn_info.realip_remote_addr() | ||||
|     } else { | ||||
|         conn_info.peer_addr() | ||||
|     } | ||||
|     .unwrap() | ||||
|     .to_owned() | ||||
| } | ||||
| 
 | ||||
| pub fn log_auth_failure(ip_addr: &str) { | ||||
|     warn!("Incorrect authentication attempt from {}", ip_addr); | ||||
| } | ||||
| type AppData = web::Data<RwLock<FileStore>>; | ||||
| 
 | ||||
| #[derive(Deserialize)] | ||||
| struct DownloadRequest { | ||||
|  | @ -58,15 +27,16 @@ struct DownloadRequest { | |||
| async fn handle_download( | ||||
|     req: HttpRequest, | ||||
|     download: web::Query<DownloadRequest>, | ||||
|     data: web::Data<AppState>, | ||||
|     data: AppData, | ||||
| ) -> actix_web::Result<HttpResponse> { | ||||
|     let code = &download.code; | ||||
|     if !store::is_valid_storage_code(code) { | ||||
|         return download_not_found(req, data); | ||||
|         return Ok(HttpResponse::NotFound().finish()); | ||||
|     } | ||||
|     let info = data.file_store.read().await.lookup_file(code); | ||||
|     let data = data.read().await; | ||||
|     let info = data.lookup_file(code); | ||||
|     if let Some(info) = info { | ||||
|         let storage_path = data.config.storage_dir.join(code); | ||||
|         let storage_path = store::storage_dir().join(code); | ||||
|         let file = File::open(&storage_path)?; | ||||
|         Ok(download::DownloadingFile { | ||||
|             file, | ||||
|  | @ -75,164 +45,50 @@ async fn handle_download( | |||
|         } | ||||
|         .into_response(&req)) | ||||
|     } else { | ||||
|         download_not_found(req, data) | ||||
|         Ok(HttpResponse::NotFound().finish()) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| fn download_not_found( | ||||
|     req: HttpRequest, | ||||
|     data: web::Data<AppState>, | ||||
| ) -> actix_web::Result<HttpResponse> { | ||||
|     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)) | ||||
| } | ||||
| 
 | ||||
| #[get("/upload")] | ||||
| async fn handle_upload( | ||||
|     req: HttpRequest, | ||||
|     stream: web::Payload, | ||||
|     data: web::Data<AppState>, | ||||
| ) -> impl Responder { | ||||
|     if data.file_store.read().await.full() { | ||||
|         return Ok(HttpResponse::BadRequest().finish()); | ||||
|     } | ||||
|     let ip_addr = get_ip_addr(&req, data.config.reverse_proxy); | ||||
|     ws::start(upload::Uploader::new(data, ip_addr), &req, stream) | ||||
| } | ||||
| 
 | ||||
| #[derive(Deserialize)] | ||||
| struct UploadPasswordCheck { | ||||
|     password: String, | ||||
| } | ||||
| 
 | ||||
| #[post("/upload/check_password")] | ||||
| async fn check_upload_password( | ||||
|     req: HttpRequest, | ||||
|     body: web::Json<UploadPasswordCheck>, | ||||
|     data: web::Data<AppState>, | ||||
| ) -> impl Responder { | ||||
|     let ip_addr = get_ip_addr(&req, data.config.reverse_proxy); | ||||
|     if body.password != data.config.upload_password { | ||||
|         log_auth_failure(&ip_addr); | ||||
|         HttpResponse::Forbidden().finish() | ||||
|     } else { | ||||
|         HttpResponse::NoContent().finish() | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Serialize)] | ||||
| struct UploadLimits { | ||||
|     open: bool, | ||||
|     max_size: u64, | ||||
|     max_lifetime: u16, | ||||
| } | ||||
| 
 | ||||
| #[get("/upload/limits.json")] | ||||
| async fn upload_limits(data: web::Data<AppState>) -> impl Responder { | ||||
|     let file_store = data.file_store.read().await; | ||||
|     let open = !file_store.full(); | ||||
|     let available_size = file_store.available_size(); | ||||
|     let max_size = std::cmp::min(available_size, data.config.max_upload_size); | ||||
|     web::Json(UploadLimits { | ||||
|         open, | ||||
|         max_size, | ||||
|         max_lifetime: data.config.max_lifetime, | ||||
|     }) | ||||
| } | ||||
| 
 | ||||
| fn env_or<T: FromStr>(var: &str, default: T) -> T | ||||
| where | ||||
|     <T as FromStr>::Err: Debug, | ||||
| { | ||||
|     std::env::var(var) | ||||
|         .map(|val| { | ||||
|             val.parse::<T>() | ||||
|                 .unwrap_or_else(|_| panic!("Invalid value {} for variable {}", val, var)) | ||||
|         }) | ||||
|         .unwrap_or(default) | ||||
| } | ||||
| 
 | ||||
| fn env_or_else<T: FromStr, F: FnOnce() -> T>(var: &str, default: F) -> T | ||||
| where | ||||
|     <T as FromStr>::Err: Debug, | ||||
| { | ||||
|     std::env::var(var) | ||||
|         .map(|val| { | ||||
|             val.parse::<T>() | ||||
|                 .unwrap_or_else(|_| panic!("Invalid value {} for variable {}", val, var)) | ||||
|         }) | ||||
|         .ok() | ||||
|         .unwrap_or_else(default) | ||||
| async fn handle_upload(req: HttpRequest, stream: web::Payload, data: AppData) -> impl Responder { | ||||
|     ws::start(upload::Uploader::new(data), &req, stream) | ||||
| } | ||||
| 
 | ||||
| #[actix_web::main] | ||||
| async fn main() -> std::io::Result<()> { | ||||
|     dotenv::dotenv().ok(); | ||||
|     env_logger::init(); | ||||
| 
 | ||||
|     let static_dir: PathBuf = env_or_else("TRANSBEAM_STATIC_DIR", || PathBuf::from("static")); | ||||
|     let storage_dir: PathBuf = env_or_else("TRANSBEAM_STORAGE_DIR", || PathBuf::from("storage")); | ||||
|     let port: u16 = env_or("TRANSBEAM_PORT", 8080); | ||||
|     let mnemonic_codes: bool = env_or("TRANSBEAM_MNEMONIC_CODES", true); | ||||
|     let reverse_proxy: bool = env_or("TRANSBEAM_REVERSE_PROXY", false); | ||||
|     let max_lifetime: u16 = env_or("TRANSBEAM_MAX_LIFETIME", 30); | ||||
|     let max_upload_size: u64 = | ||||
|         env_or::<ByteSize>("TRANSBEAM_MAX_UPLOAD_SIZE", ByteSize(16 * bytesize::GB)).as_u64(); | ||||
|     let max_storage_size: u64 = | ||||
|         env_or::<ByteSize>("TRANSBEAM_MAX_STORAGE_SIZE", ByteSize(64 * bytesize::GB)).as_u64(); | ||||
|     let upload_password: String = | ||||
|         std::env::var("TRANSBEAM_UPLOAD_PASSWORD").expect("TRANSBEAM_UPLOAD_PASSWORD must be set!"); | ||||
| 
 | ||||
|     let data = web::Data::new(AppState { | ||||
|         file_store: RwLock::new(FileStore::load(storage_dir.clone(), max_storage_size).await?), | ||||
|         config: Config { | ||||
|             max_upload_size, | ||||
|             max_lifetime, | ||||
|             upload_password, | ||||
|             storage_dir, | ||||
|             static_dir: static_dir.clone(), | ||||
|             reverse_proxy, | ||||
|             mnemonic_codes, | ||||
|         }, | ||||
|     }); | ||||
|     let data: AppData = web::Data::new(RwLock::new(FileStore::load().await?)); | ||||
|     start_reaper(data.clone()); | ||||
| 
 | ||||
|     let static_dir = PathBuf::from( | ||||
|         std::env::var("TRANSBEAM_STATIC_DIR").unwrap_or_else(|_| String::from("static")), | ||||
|     ); | ||||
|     let port = std::env::var("TRANSBEAM_PORT") | ||||
|         .ok() | ||||
|         .and_then(|p| p.parse::<u16>().ok()) | ||||
|         .unwrap_or(8080); | ||||
| 
 | ||||
|     HttpServer::new(move || { | ||||
|         App::new() | ||||
|             .app_data(data.clone()) | ||||
|             .wrap(if data.config.reverse_proxy { | ||||
|                 Logger::new(r#"%{r}a "%r" %s %b "%{Referer}i" "%{User-Agent}i" %T"#) | ||||
|             } else { | ||||
|                 Logger::default() | ||||
|             }) | ||||
|             .service(handle_download) | ||||
|             .wrap(Logger::default()) | ||||
|             .service(handle_upload) | ||||
|             .service(check_upload_password) | ||||
|             .service(upload_limits) | ||||
|             .service(handle_download) | ||||
|             .service(actix_files::Files::new("/", static_dir.clone()).index_file("index.html")) | ||||
|     }) | ||||
|     .bind(( | ||||
|         if reverse_proxy { | ||||
|             "127.0.0.1" | ||||
|         } else { | ||||
|             "0.0.0.0" | ||||
|         }, | ||||
|         port, | ||||
|     ))? | ||||
|     .bind(("127.0.0.1", port))? | ||||
|     .run() | ||||
|     .await?; | ||||
|     Ok(()) | ||||
| } | ||||
| 
 | ||||
| fn start_reaper(data: web::Data<AppState>) { | ||||
| fn start_reaper(data: AppData) { | ||||
|     std::thread::spawn(move || { | ||||
|         actix_web::rt::System::new().block_on(async { | ||||
|             loop { | ||||
|                 actix_web::rt::time::sleep(core::time::Duration::from_secs(86400)).await; | ||||
|                 if let Err(e) = data.file_store.write().await.remove_expired_files().await { | ||||
|                 if let Err(e) = data.write().await.remove_expired_files().await { | ||||
|                     error!("Error reaping expired files: {}", e); | ||||
|                 } | ||||
|             } | ||||
|  |  | |||
							
								
								
									
										152
									
								
								src/store.rs
									
										
									
									
									
								
							
							
						
						
									
										152
									
								
								src/store.rs
									
										
									
									
									
								
							|  | @ -1,10 +1,6 @@ | |||
| use std::{ | ||||
|     collections::HashMap, | ||||
|     io::ErrorKind, | ||||
|     path::{Path, PathBuf}, | ||||
| }; | ||||
| use std::{collections::HashMap, io::ErrorKind, path::PathBuf, str::FromStr}; | ||||
| 
 | ||||
| use log::{debug, error, info}; | ||||
| use log::{debug, error, info, warn}; | ||||
| use rand::{ | ||||
|     distributions::{Alphanumeric, DistString}, | ||||
|     thread_rng, Rng, | ||||
|  | @ -17,13 +13,17 @@ use tokio::{ | |||
| }; | ||||
| 
 | ||||
| const STATE_FILE_NAME: &str = "files.json"; | ||||
| const MAX_STORAGE_FILES: usize = 1024; | ||||
| const DEFAULT_STORAGE_DIR: &str = "storage"; | ||||
| const DEFAULT_MAX_LIFETIME: u32 = 30; | ||||
| const GIGA: u64 = 1024 * 1024 * 1024; | ||||
| const DEFAULT_MAX_UPLOAD_SIZE: u64 = 16 * GIGA; | ||||
| const DEFAULT_MAX_STORAGE_SIZE: u64 = 64 * GIGA; | ||||
| 
 | ||||
| pub fn gen_storage_code(use_mnemonic: bool) -> String { | ||||
|     if use_mnemonic { | ||||
|         mnemonic::to_string(thread_rng().gen::<[u8; 4]>()) | ||||
|     } else { | ||||
| pub fn gen_storage_code() -> String { | ||||
|     if std::env::var("TRANSBEAM_MNEMONIC_CODES").as_deref() == Ok("false") { | ||||
|         Alphanumeric.sample_string(&mut thread_rng(), 8) | ||||
|     } else { | ||||
|         mnemonic::to_string(thread_rng().gen::<[u8; 4]>()) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|  | @ -33,6 +33,32 @@ pub fn is_valid_storage_code(s: &str) -> bool { | |||
|         .all(|c| c.is_ascii_alphanumeric() || c == &b'-') | ||||
| } | ||||
| 
 | ||||
| pub(crate) fn storage_dir() -> PathBuf { | ||||
|     PathBuf::from( | ||||
|         std::env::var("TRANSBEAM_STORAGE_DIR") | ||||
|             .unwrap_or_else(|_| String::from(DEFAULT_STORAGE_DIR)), | ||||
|     ) | ||||
| } | ||||
| 
 | ||||
| fn parse_env_var<T: FromStr>(var: &str, default: T) -> T { | ||||
|     std::env::var(var) | ||||
|         .ok() | ||||
|         .and_then(|val| val.parse::<T>().ok()) | ||||
|         .unwrap_or(default) | ||||
| } | ||||
| 
 | ||||
| pub(crate) fn max_lifetime() -> u32 { | ||||
|     parse_env_var("TRANSBEAM_MAX_LIFETIME", DEFAULT_MAX_LIFETIME) | ||||
| } | ||||
| 
 | ||||
| pub(crate) fn max_single_size() -> u64 { | ||||
|     parse_env_var("TRANSBEAM_MAX_UPLOAD_SIZE", DEFAULT_MAX_UPLOAD_SIZE) | ||||
| } | ||||
| 
 | ||||
| pub(crate) fn max_total_size() -> u64 { | ||||
|     parse_env_var("TRANSBEAM_MAX_STORAGE_SIZE", DEFAULT_MAX_STORAGE_SIZE) | ||||
| } | ||||
| 
 | ||||
| #[derive(Clone, Deserialize, Serialize)] | ||||
| pub struct StoredFile { | ||||
|     pub name: String, | ||||
|  | @ -84,13 +110,13 @@ pub(crate) mod timestamp { | |||
|     } | ||||
| } | ||||
| 
 | ||||
| async fn is_valid_entry(key: &str, info: &StoredFile, storage_dir: &Path) -> bool { | ||||
| async fn is_valid_entry(key: &str, info: &StoredFile) -> bool { | ||||
|     if info.expiry < OffsetDateTime::now_utc() { | ||||
|         info!("File {} has expired", key); | ||||
|         return false; | ||||
|     } | ||||
| 
 | ||||
|     let file = if let Ok(f) = File::open(storage_dir.join(&key)).await { | ||||
|     let file = if let Ok(f) = File::open(storage_dir().join(&key)).await { | ||||
|         f | ||||
|     } else { | ||||
|         error!( | ||||
|  | @ -115,35 +141,10 @@ async fn is_valid_entry(key: &str, info: &StoredFile, storage_dir: &Path) -> boo | |||
|     true | ||||
| } | ||||
| 
 | ||||
| async fn delete_file_if_exists(file: &PathBuf) -> std::io::Result<()> { | ||||
|     if let Err(e) = tokio::fs::remove_file(file).await { | ||||
|         if e.kind() != ErrorKind::NotFound { | ||||
|             error!("Failed to delete file {}: {}", file.to_string_lossy(), e); | ||||
|             return Err(e); | ||||
|         } | ||||
|     } | ||||
|     Ok(()) | ||||
| } | ||||
| 
 | ||||
| #[derive(thiserror::Error, Debug)] | ||||
| pub enum FileAddError { | ||||
|     #[error("Failed to write metadata to filesystem")] | ||||
|     FileSystem(#[from] std::io::Error), | ||||
|     #[error("File was too large, available space is {0} bytes")] | ||||
|     TooBig(u64), | ||||
|     #[error("File store is full")] | ||||
|     Full, | ||||
| } | ||||
| 
 | ||||
| pub struct FileStore { | ||||
|     files: HashMap<String, StoredFile>, | ||||
|     storage_dir: PathBuf, | ||||
|     max_storage_size: u64, | ||||
| } | ||||
| 
 | ||||
| pub(crate) struct FileStore(HashMap<String, StoredFile>); | ||||
| impl FileStore { | ||||
|     pub(crate) async fn load(storage_dir: PathBuf, max_storage_size: u64) -> std::io::Result<Self> { | ||||
|         let open_result = File::open(storage_dir.join(STATE_FILE_NAME)).await; | ||||
|     pub(crate) async fn load() -> std::io::Result<Self> { | ||||
|         let open_result = File::open(storage_dir().join(STATE_FILE_NAME)).await; | ||||
|         match open_result { | ||||
|             Ok(mut f) => { | ||||
|                 let mut buf = String::new(); | ||||
|  | @ -159,28 +160,22 @@ impl FileStore { | |||
|                         error!("Invalid key in persistent storage: {}", key); | ||||
|                         continue; | ||||
|                     } | ||||
|                     if is_valid_entry(&key, &info, &storage_dir).await { | ||||
|                     if is_valid_entry(&key, &info).await { | ||||
|                         filtered.insert(key, info); | ||||
|                     } else { | ||||
|                         info!("Deleting file {}", key); | ||||
|                         delete_file_if_exists(&storage_dir.join(&key)).await?; | ||||
|                         if let Err(e) = tokio::fs::remove_file(storage_dir().join(&key)).await { | ||||
|                             warn!("Failed to delete file {}: {}", key, e); | ||||
|                         } | ||||
|                     } | ||||
|                 let mut loaded = Self { | ||||
|                     files: filtered, | ||||
|                     storage_dir, | ||||
|                     max_storage_size, | ||||
|                 }; | ||||
|                 } | ||||
|                 let mut loaded = Self(filtered); | ||||
|                 loaded.save().await?; | ||||
|                 Ok(loaded) | ||||
|             } | ||||
|             Err(e) => { | ||||
|                 if let ErrorKind::NotFound = e.kind() { | ||||
|                     Ok(Self { | ||||
|                         files: HashMap::new(), | ||||
|                         storage_dir, | ||||
|                         max_storage_size, | ||||
|                     }) | ||||
|                     Ok(Self(HashMap::new())) | ||||
|                 } else { | ||||
|                     Err(e) | ||||
|                 } | ||||
|  | @ -189,25 +184,17 @@ impl FileStore { | |||
|     } | ||||
| 
 | ||||
|     fn total_size(&self) -> u64 { | ||||
|         self.files.iter().fold(0, |acc, (_, f)| acc + f.size) | ||||
|     } | ||||
| 
 | ||||
|     pub fn available_size(&self) -> u64 { | ||||
|         self.max_storage_size.saturating_sub(self.total_size()) | ||||
|         self.0.iter().fold(0, |acc, (_, f)| acc + f.size) | ||||
|     } | ||||
| 
 | ||||
|     async fn save(&mut self) -> std::io::Result<()> { | ||||
|         info!("saving updated state: {} entries", self.files.len()); | ||||
|         File::create(self.storage_dir.join(STATE_FILE_NAME)) | ||||
|         info!("saving updated state: {} entries", self.0.len()); | ||||
|         File::create(storage_dir().join(STATE_FILE_NAME)) | ||||
|             .await? | ||||
|             .write_all(&serde_json::to_vec_pretty(&self.files)?) | ||||
|             .write_all(&serde_json::to_vec_pretty(&self.0)?) | ||||
|             .await | ||||
|     } | ||||
| 
 | ||||
|     pub fn full(&self) -> bool { | ||||
|         self.available_size() == 0 || self.files.len() >= MAX_STORAGE_FILES | ||||
|     } | ||||
| 
 | ||||
|     /// Attempts to add a file to the store. Returns an I/O error if
 | ||||
|     /// something's broken, or a u64 of the maximum allowed file size
 | ||||
|     /// if the file was too big, or a unit if everything worked.
 | ||||
|  | @ -215,42 +202,37 @@ impl FileStore { | |||
|         &mut self, | ||||
|         key: String, | ||||
|         file: StoredFile, | ||||
|     ) -> Result<(), FileAddError> { | ||||
|         if self.full() { | ||||
|             return Err(FileAddError::Full); | ||||
|     ) -> std::io::Result<Result<(), u64>> { | ||||
|         let remaining_size = max_total_size().saturating_sub(self.total_size()); | ||||
|         let allowed_size = std::cmp::min(remaining_size, max_single_size()); | ||||
|         if file.size > allowed_size { | ||||
|             return Ok(Err(allowed_size)); | ||||
|         } | ||||
|         let available_size = self.available_size(); | ||||
|         if file.size > available_size { | ||||
|             return Err(FileAddError::TooBig(available_size)); | ||||
|         } | ||||
|         self.files.insert(key, file); | ||||
|         self.save().await?; | ||||
|         Ok(()) | ||||
|         self.0.insert(key, file); | ||||
|         self.save().await.map(Ok) | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) fn lookup_file(&self, key: &str) -> Option<StoredFile> { | ||||
|         self.files.get(key).cloned() | ||||
|         self.0.get(key).cloned() | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) async fn remove_file(&mut self, key: &str) -> std::io::Result<()> { | ||||
|         debug!("removing entry {} from state", key); | ||||
|         self.files.remove(key); | ||||
|         self.save().await?; | ||||
|         if is_valid_storage_code(key) { | ||||
|             delete_file_if_exists(&self.storage_dir.join(key)).await?; | ||||
|         } | ||||
|         Ok(()) | ||||
|         self.0.remove(key); | ||||
|         self.save().await | ||||
|     } | ||||
| 
 | ||||
|     pub(crate) async fn remove_expired_files(&mut self) -> std::io::Result<()> { | ||||
|         info!("Checking for expired files"); | ||||
|         let now = OffsetDateTime::now_utc(); | ||||
|         for (key, file) in std::mem::take(&mut self.files).into_iter() { | ||||
|         for (key, file) in std::mem::take(&mut self.0).into_iter() { | ||||
|             if file.expiry > now { | ||||
|                 self.files.insert(key, file); | ||||
|                 self.0.insert(key, file); | ||||
|             } else { | ||||
|                 info!("Deleting expired file {}", key); | ||||
|                 delete_file_if_exists(&self.storage_dir.join(&key)).await?; | ||||
|                 if let Err(e) = tokio::fs::remove_file(storage_dir().join(&key)).await { | ||||
|                     warn!("Failed to delete expired file {}: {}", key, e); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|         self.save().await | ||||
|  |  | |||
|  | @ -2,7 +2,6 @@ use std::{collections::HashSet, fs::File, io::Write}; | |||
| 
 | ||||
| use actix::{fut::future::ActorFutureExt, Actor, ActorContext, AsyncContext, StreamHandler}; | ||||
| use actix_http::ws::{CloseReason, Item}; | ||||
| use actix_web::web; | ||||
| use actix_web_actors::ws::{self, CloseCode}; | ||||
| use bytes::Bytes; | ||||
| use log::{debug, error, info, trace}; | ||||
|  | @ -10,11 +9,7 @@ use serde::{Deserialize, Serialize}; | |||
| use time::OffsetDateTime; | ||||
| use unicode_normalization::UnicodeNormalization; | ||||
| 
 | ||||
| use crate::{ | ||||
|     log_auth_failure, | ||||
|     store::{self, FileAddError, StoredFile}, | ||||
|     AppState, | ||||
| }; | ||||
| use crate::store::{self, storage_dir, StoredFile}; | ||||
| 
 | ||||
| const MAX_FILES: usize = 256; | ||||
| const FILENAME_DATE_FORMAT: &[time::format_description::FormatItem] = | ||||
|  | @ -34,14 +29,10 @@ enum Error { | |||
|     NoFiles, | ||||
|     #[error("Number of files submitted by client exceeded the maximum limit")] | ||||
|     TooManyFiles, | ||||
|     #[error("Requested lifetime was too long, can be at most {0} days")] | ||||
|     TooLong(u16), | ||||
|     #[error("Requested lifetime was too long")] | ||||
|     TooLong, | ||||
|     #[error("Upload size was too large, can be at most {0} bytes")] | ||||
|     TooBig(u64), | ||||
|     #[error("File storage is full")] | ||||
|     Full, | ||||
|     #[error("Incorrect password")] | ||||
|     IncorrectPassword, | ||||
|     #[error("Websocket was closed by client before completing transfer")] | ||||
|     ClosedEarly(Option<CloseReason>), | ||||
|     #[error("Client sent more data than they were supposed to")] | ||||
|  | @ -59,10 +50,8 @@ impl Error { | |||
|             Self::DuplicateFilename | ||||
|             | Self::NoFiles | ||||
|             | Self::TooManyFiles | ||||
|             | Self::TooLong(_) | ||||
|             | Self::TooBig(_) | ||||
|             | Self::Full | ||||
|             | Self::IncorrectPassword => CloseCode::Policy, | ||||
|             | Self::TooLong | ||||
|             | Self::TooBig(_) => CloseCode::Policy, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -70,19 +59,17 @@ impl Error { | |||
| pub struct Uploader { | ||||
|     writer: Option<Box<dyn Write>>, | ||||
|     storage_filename: String, | ||||
|     app_state: web::Data<AppState>, | ||||
|     app_data: super::AppData, | ||||
|     bytes_remaining: u64, | ||||
|     ip_addr: String, | ||||
| } | ||||
| 
 | ||||
| impl Uploader { | ||||
|     pub(crate) fn new(app_state: web::Data<AppState>, ip_addr: String) -> Self { | ||||
|     pub(crate) fn new(app_data: super::AppData) -> Self { | ||||
|         Self { | ||||
|             writer: None, | ||||
|             storage_filename: store::gen_storage_code(app_state.config.mnemonic_codes), | ||||
|             app_state, | ||||
|             storage_filename: store::gen_storage_code(), | ||||
|             app_data, | ||||
|             bytes_remaining: 0, | ||||
|             ip_addr, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -130,8 +117,7 @@ impl RawUploadedFile { | |||
| #[derive(Deserialize)] | ||||
| struct UploadManifest { | ||||
|     files: Vec<RawUploadedFile>, | ||||
|     lifetime: u16, | ||||
|     password: String, | ||||
|     lifetime: u32, | ||||
| } | ||||
| 
 | ||||
| #[derive(Serialize)] | ||||
|  | @ -139,8 +125,7 @@ struct UploadManifest { | |||
| enum ServerMessage { | ||||
|     Ready { code: String }, | ||||
|     TooBig { max_size: u64 }, | ||||
|     TooLong { max_days: u16 }, | ||||
|     IncorrectPassword, | ||||
|     TooLong { max_days: u32 }, | ||||
|     Error { details: String }, | ||||
| } | ||||
| 
 | ||||
|  | @ -150,10 +135,9 @@ impl From<&Error> for ServerMessage { | |||
|             Error::TooBig(max_size) => ServerMessage::TooBig { | ||||
|                 max_size: *max_size, | ||||
|             }, | ||||
|             Error::TooLong(max_days) => ServerMessage::TooLong { | ||||
|                 max_days: *max_days, | ||||
|             Error::TooLong => ServerMessage::TooLong { | ||||
|                 max_days: store::max_lifetime(), | ||||
|             }, | ||||
|             Error::IncorrectPassword => ServerMessage::IncorrectPassword, | ||||
|             _ => ServerMessage::Error { | ||||
|                 details: e.to_string(), | ||||
|             }, | ||||
|  | @ -205,9 +189,6 @@ fn ack(ctx: &mut Context) { | |||
| impl Uploader { | ||||
|     fn notify_error_and_cleanup(&mut self, e: Error, ctx: &mut Context) { | ||||
|         error!("{}", e); | ||||
|         if let Error::IncorrectPassword = e { | ||||
|             log_auth_failure(&self.ip_addr); | ||||
|         } | ||||
|         ctx.text(serde_json::to_string(&ServerMessage::from(&e)).unwrap()); | ||||
|         ctx.close(Some(ws::CloseReason { | ||||
|             code: e.close_code(), | ||||
|  | @ -226,13 +207,9 @@ impl Uploader { | |||
|                 let UploadManifest { | ||||
|                     files: raw_files, | ||||
|                     lifetime, | ||||
|                     password, | ||||
|                 } = serde_json::from_slice(text.as_bytes())?; | ||||
|                 if std::env::var("TRANSBEAM_UPLOAD_PASSWORD") != Ok(password) { | ||||
|                     return Err(Error::IncorrectPassword); | ||||
|                 } | ||||
|                 if lifetime > self.app_state.config.max_lifetime { | ||||
|                     return Err(Error::TooLong(self.app_state.config.max_lifetime)); | ||||
|                 if lifetime > store::max_lifetime() { | ||||
|                     return Err(Error::TooLong); | ||||
|                 } | ||||
|                 info!("Received file list: {} files", raw_files.len()); | ||||
|                 debug!("{:?}", raw_files); | ||||
|  | @ -257,14 +234,7 @@ impl Uploader { | |||
|                     self.bytes_remaining += file.size; | ||||
|                     files.push(file); | ||||
|                 } | ||||
|                 if self.bytes_remaining > self.app_state.config.max_upload_size { | ||||
|                     return Err(Error::TooBig(self.app_state.config.max_upload_size)); | ||||
|                 } | ||||
|                 let storage_path = self | ||||
|                     .app_state | ||||
|                     .config | ||||
|                     .storage_dir | ||||
|                     .join(self.storage_filename.clone()); | ||||
|                 let storage_path = storage_dir().join(self.storage_filename.clone()); | ||||
|                 info!("storing to: {:?}", storage_path); | ||||
|                 let writer = File::options() | ||||
|                     .write(true) | ||||
|  | @ -294,32 +264,25 @@ impl Uploader { | |||
|                     modtime, | ||||
|                     expiry: OffsetDateTime::now_utc() + lifetime * time::Duration::DAY, | ||||
|                 }; | ||||
|                 let state = self.app_state.clone(); | ||||
|                 let data = self.app_data.clone(); | ||||
|                 let storage_filename = self.storage_filename.clone(); | ||||
|                 ctx.spawn( | ||||
|                     actix::fut::wrap_future(async move { | ||||
|                         debug!("Spawned future to add entry {} to state", storage_filename); | ||||
|                         state | ||||
|                             .file_store | ||||
|                             .write() | ||||
|                         data.write() | ||||
|                             .await | ||||
|                             .add_file(storage_filename, stored_file) | ||||
|                             .await | ||||
|                     }) | ||||
|                     .map(|res, u: &mut Self, ctx: &mut Context| match res { | ||||
|                         Ok(()) => ctx.text( | ||||
|                         Ok(Ok(())) => ctx.text( | ||||
|                             serde_json::to_string(&ServerMessage::Ready { | ||||
|                                 code: u.storage_filename.clone(), | ||||
|                             }) | ||||
|                             .unwrap(), | ||||
|                         ), | ||||
|                         Err(FileAddError::TooBig(size)) => { | ||||
|                             u.notify_error_and_cleanup(Error::TooBig(size), ctx) | ||||
|                         } | ||||
|                         Err(FileAddError::Full) => u.notify_error_and_cleanup(Error::Full, ctx), | ||||
|                         Err(FileAddError::FileSystem(e)) => { | ||||
|                             u.notify_error_and_cleanup(Error::from(e), ctx) | ||||
|                         } | ||||
|                         Ok(Err(size)) => u.notify_error_and_cleanup(Error::TooBig(size), ctx), | ||||
|                         Err(e) => u.notify_error_and_cleanup(Error::from(e), ctx), | ||||
|                     }), | ||||
|                 ); | ||||
|             } | ||||
|  | @ -369,20 +332,17 @@ impl Uploader { | |||
|             "Cleaning up after failed upload of {}", | ||||
|             self.storage_filename | ||||
|         ); | ||||
|         let state = self.app_state.clone(); | ||||
|         let data = self.app_data.clone(); | ||||
|         let filename = self.storage_filename.clone(); | ||||
|         ctx.wait( | ||||
|             actix::fut::wrap_future(async move { | ||||
|                 debug!("Spawned future to remove entry {} from state", filename); | ||||
|                 state | ||||
|                     .file_store | ||||
|                     .write() | ||||
|                     .await | ||||
|                     .remove_file(&filename) | ||||
|                     .await | ||||
|                     .unwrap(); | ||||
|                 data.write().await.remove_file(&filename).await.unwrap(); | ||||
|             }) | ||||
|             .map(stop_and_flush), | ||||
|         ); | ||||
|         if let Err(e) = std::fs::remove_file(storage_dir().join(&self.storage_filename)) { | ||||
|             error!("Failed to remove file {}: {}", self.storage_filename, e); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  |  | |||
|  | @ -1,38 +0,0 @@ | |||
| <!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="apple-touch-icon" href="images/site-icons/transbeam-apple.png"/> | ||||
|     <link rel="manifest" href="manifest.json"/> | ||||
|     <script src="js/download.js"></script> | ||||
|     <title>transbeam</title> | ||||
|   </head> | ||||
|   <body> | ||||
|     <div id="header"> | ||||
|       <a href="./"> | ||||
|       <img src="images/site-icons/transbeam.svg" height="128"> | ||||
|       <h1>transbeam</h1> | ||||
|       </a> | ||||
|     </div> | ||||
|     <div id="download" class="section"> | ||||
|       <h3>The download code you entered wasn't found. The download may have expired.</h3> | ||||
|       <form id="download_form" action="download" method="get"> | ||||
|         <div> | ||||
|           <label> | ||||
|             <input type="text" id="download_code_input" name="code" placeholder="Download code"/> | ||||
|           </label> | ||||
|         </div> | ||||
|         <input id="download_button" type="submit" value="Download"/> | ||||
|       </form> | ||||
|     </div> | ||||
|     <div class="section"> | ||||
|       <a href="./"><h3>< Back</h3></a> | ||||
|     </div> | ||||
|     <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> | ||||
|  | @ -3,51 +3,23 @@ | |||
|   <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/states.css"/> | ||||
|     <link rel="stylesheet" type="text/css" href="transbeam.css"/> | ||||
|     <link rel="stylesheet" type="text/css" href="states.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> | ||||
|     <script src="js/upload.js"></script> | ||||
|     <script src="util.js"></script> | ||||
|     <script src="transbeam.js"></script> | ||||
|     <title>transbeam</title> | ||||
|   </head> | ||||
|   <body class="noscript landing"> | ||||
|   <body class="no_files selecting"> | ||||
|     <div id="header"> | ||||
|       <img src="images/site-icons/transbeam.svg" height="128"> | ||||
|       <h1>transbeam</h1> | ||||
|     </div> | ||||
|     <div id="download" class="section"> | ||||
|       <h3 class="section_heading">Download</h3> | ||||
|       <form id="download_form" action="download" method="get"> | ||||
|         <div> | ||||
|           <label> | ||||
|             <input type="text" id="download_code_input" name="code" placeholder="Download code"/> | ||||
|           </label> | ||||
|         </div> | ||||
|         <input id="download_button" type="submit" value="Download"/> | ||||
|       </form> | ||||
|     </div> | ||||
|     <noscript>Javascript is required to upload files :(</noscript> | ||||
|     <div id="upload" class="section"> | ||||
|       <h3 class="section_heading">Upload</h3> | ||||
|     <div id="message"></div> | ||||
|       <div> | ||||
|         <form id="upload_password_form"> | ||||
|           <div> | ||||
|             <label> | ||||
|               <input id="upload_password" type="password" placeholder="Password"/> | ||||
|             </label> | ||||
|           </div> | ||||
|           <div> | ||||
|             <input type="submit" id="submit_upload_password" value="Submit" /> | ||||
|           </div> | ||||
|         </form> | ||||
|       </div> | ||||
|     <div id="upload_controls"> | ||||
|         <div id="upload_settings"> | ||||
|       <div> | ||||
|             <button id="upload_button">Upload</button> | ||||
|         <button id="upload">Upload</button> | ||||
|       </div> | ||||
|       <div id="lifetime_container"> | ||||
|         <label> | ||||
|  | @ -77,7 +49,16 @@ | |||
|       <input type="file" multiple id="file_input"/> | ||||
|       <span class="fake_button" id="file_input_message">Select files to upload...</span> | ||||
|     </label> | ||||
|     <div id="download"> | ||||
|       <form id="download_form" action="download" method="get"> | ||||
|         <div> | ||||
|           <label> | ||||
|             <h4>Or enter a code to download files:</h4> | ||||
|             <input type="text" id="download_code_input" name="code" placeholder="Download code"/> | ||||
|           </label> | ||||
|         </div> | ||||
|         <input id="download_button" type="submit" value="Download"/> | ||||
|       </form> | ||||
|     </div> | ||||
|     <div id="footer"> | ||||
|       <h5>(c) 2022 xenofem, MIT licensed</h5> | ||||
|  |  | |||
|  | @ -1,26 +0,0 @@ | |||
| document.addEventListener('DOMContentLoaded', () => { | ||||
|     const downloadCodeInput = document.getElementById('download_code_input'); | ||||
|     const downloadButton = document.getElementById('download_button'); | ||||
|     const downloadForm = document.getElementById('download_form'); | ||||
|     downloadCodeInput.addEventListener('beforeinput', (e) => { | ||||
|         if (/^[a-zA-Z0-9-]+$/.test(e.data)) { return; } | ||||
|         e.preventDefault(); | ||||
|         if (e.data === ' ') { | ||||
|             downloadCodeInput.value += '-'; | ||||
|         } | ||||
|     }); | ||||
|     const disableEnableDownload = () => { downloadButton.disabled = (downloadCodeInput.value === ''); }; | ||||
|     disableEnableDownload(); | ||||
|     downloadCodeInput.addEventListener('input', disableEnableDownload); | ||||
|     downloadForm.addEventListener('submit', (e) => { | ||||
|         if (downloadCodeInput.value === '') { | ||||
|             e.preventDefault(); | ||||
|         } else { | ||||
|             setTimeout(() => { | ||||
|                 downloadCodeInput.value = ''; | ||||
|                 downloadButton.disabled = true; | ||||
|             }, 0); | ||||
|         } | ||||
|     }); | ||||
|     downloadCodeInput.focus(); | ||||
| }); | ||||
|  | @ -1,7 +1,6 @@ | |||
| /** | ||||
|  * List of classes the body can have: | ||||
|  * | ||||
|  * landing: haven't entered upload password yet | ||||
|  * no_files: no files are selected | ||||
|  * selecting: upload hasn't started yet | ||||
|  * uploading: upload is in progress | ||||
|  | @ -9,14 +8,6 @@ | |||
|  * error: an error has occurred | ||||
|  */ | ||||
| 
 | ||||
| .section_heading { display: none; } | ||||
| body.landing .section_heading { display: revert; } | ||||
| 
 | ||||
| #download { display: none; } | ||||
| body.landing #download { display: revert; } | ||||
| 
 | ||||
| body.noscript #upload { display: none; } | ||||
| 
 | ||||
| #message { display: none; } | ||||
| body.completed #message { | ||||
|     display: revert; | ||||
|  | @ -29,14 +20,9 @@ body.error #message { | |||
|     border-color: #f24; | ||||
| } | ||||
| 
 | ||||
| #upload_password_form { display: none; } | ||||
| body.landing #upload_password_form { display: revert; } | ||||
| 
 | ||||
| body.landing #upload_controls { display: none; } | ||||
| 
 | ||||
| #upload_settings { display: none; } | ||||
| body.selecting #upload_settings { display: revert; } | ||||
| body.no_files #upload_settings { display: none; } | ||||
| #upload_controls { display: none; } | ||||
| body.selecting #upload_controls { display: revert; } | ||||
| body.no_files #upload_controls { display: none; } | ||||
| 
 | ||||
| body.selecting #download_code_container { display: none; } | ||||
| 
 | ||||
|  | @ -51,3 +37,6 @@ body.selecting .delete_button { display: revert; } | |||
| 
 | ||||
| #file_input_container { display: none; } | ||||
| body.selecting #file_input_container { display: revert; } | ||||
| 
 | ||||
| #download { display: none; } | ||||
| body.no_files #download { display: revert; } | ||||
|  | @ -8,11 +8,6 @@ body { | |||
| 
 | ||||
| #header h1 { | ||||
|     margin-top: 5px; | ||||
|     color: black; | ||||
| } | ||||
| 
 | ||||
| #header a { | ||||
|     text-decoration: none; | ||||
| } | ||||
| 
 | ||||
| #message { | ||||
|  | @ -158,13 +153,17 @@ button:disabled, input:disabled + .fake_button, input[type="submit"]:disabled { | |||
|     margin-top: 10px; | ||||
| } | ||||
| 
 | ||||
| input[type="text"], input[type="password"] { | ||||
| #download { | ||||
|     margin-top: 40px; | ||||
| } | ||||
| 
 | ||||
| #download_code_input { | ||||
|     font-size: 18px; | ||||
|     margin-bottom: 10px; | ||||
| } | ||||
| 
 | ||||
| .section { | ||||
|     margin: 30px auto; | ||||
| #footer { | ||||
|     margin-top: 30px; | ||||
| } | ||||
| 
 | ||||
| #footer h5 { | ||||
|  | @ -11,8 +11,6 @@ let totalBytes = 0; | |||
| 
 | ||||
| let maxSize = null; | ||||
| 
 | ||||
| let uploadPassword; | ||||
| 
 | ||||
| let messageBox; | ||||
| let fileInput; | ||||
| let fileList; | ||||
|  | @ -23,37 +21,15 @@ let progress; | |||
| let progressBar; | ||||
| 
 | ||||
| document.addEventListener('DOMContentLoaded', () => { | ||||
|     document.body.className = "landing"; | ||||
| 
 | ||||
|     messageBox = document.getElementById('message'); | ||||
|     fileInput = document.getElementById('file_input'); | ||||
|     fileList = document.getElementById('file_list'); | ||||
|     uploadButton = document.getElementById('upload_button'); | ||||
|     uploadButton = document.getElementById('upload'); | ||||
|     lifetimeInput = document.getElementById('lifetime'); | ||||
|     downloadCode = document.getElementById('download_code'); | ||||
|     progress = document.getElementById('progress'); | ||||
|     progressBar = document.getElementById('progress_bar'); | ||||
| 
 | ||||
|     const uploadPasswordInput = document.getElementById('upload_password'); | ||||
|     const uploadPasswordForm = document.getElementById('upload_password_form'); | ||||
|     uploadPasswordForm.addEventListener('submit', (e) => { | ||||
|         e.preventDefault(); | ||||
|         uploadPassword = uploadPasswordInput.value; | ||||
|         fetch('/upload/check_password', { | ||||
|             method: 'POST', | ||||
|             headers: { 'Content-Type': 'application/json' }, | ||||
|             body: JSON.stringify({ password: uploadPassword }), | ||||
|         }).then((res) => { | ||||
|             if (res.ok) { | ||||
|                 updateFiles(); | ||||
|             } else { | ||||
|                 messageBox.textContent = (res.status === 403) ? 'Incorrect password' : 'An error occurred'; | ||||
|                 uploadPasswordInput.value = ''; | ||||
|                 document.body.className = 'error landing'; | ||||
|             } | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     fileInput.addEventListener('input', () => { | ||||
|         for (const file of fileInput.files) { addFile(file); } | ||||
|         updateFiles(); | ||||
|  | @ -71,6 +47,32 @@ document.addEventListener('DOMContentLoaded', () => { | |||
|     downloadCodeContainer.addEventListener('mouseleave', () => { | ||||
|         downloadCodeContainer.className = ''; | ||||
|     }); | ||||
| 
 | ||||
|     const downloadCodeInput = document.getElementById('download_code_input'); | ||||
|     const downloadButton = document.getElementById('download_button'); | ||||
|     const downloadForm = document.getElementById('download_form'); | ||||
|     downloadCodeInput.addEventListener('beforeinput', (e) => { | ||||
|         if (/^[a-zA-Z0-9-]+$/.test(e.data)) { return; } | ||||
|         e.preventDefault(); | ||||
|         if (e.data === ' ') { | ||||
|             downloadCodeInput.value += '-'; | ||||
|         } | ||||
|     }); | ||||
|     const disableEnableDownload = () => { downloadButton.disabled = (downloadCodeInput.value === ''); }; | ||||
|     disableEnableDownload(); | ||||
|     downloadCodeInput.addEventListener('input', disableEnableDownload); | ||||
|     downloadForm.addEventListener('submit', (e) => { | ||||
|         if (downloadCodeInput.value === '') { | ||||
|             e.preventDefault(); | ||||
|         } else { | ||||
|             setTimeout(() => { | ||||
|                 downloadCodeInput.value = ''; | ||||
|                 downloadButton.disabled = true; | ||||
|             }, 0); | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
|     updateFiles(); | ||||
| }); | ||||
| 
 | ||||
| function updateFiles() { | ||||
|  | @ -160,11 +162,7 @@ function sendManifest() { | |||
|         size: file.size, | ||||
|         modtime: file.lastModified, | ||||
|     })); | ||||
|     socket.send(JSON.stringify({ | ||||
|         files: fileMetadata, | ||||
|         lifetime, | ||||
|         password: uploadPassword, | ||||
|     })); | ||||
|     socket.send(JSON.stringify({ lifetime, files: fileMetadata })); | ||||
| } | ||||
| 
 | ||||
| function handleMessage(msg) { | ||||
|  | @ -204,9 +202,6 @@ function handleMessage(msg) { | |||
|                 } | ||||
|             } | ||||
|             displayError(`The maximum retention time for uploads is ${reply.max_days} days`); | ||||
|         } else if (reply.type === 'incorrect_password') { | ||||
|             messageBox.textContent = ('Incorrect password'); | ||||
|             document.body.className = 'error landing'; | ||||
|         } else if (reply.type === 'error') { | ||||
|             displayError(reply.details); | ||||
|         } | ||||
|  | @ -1,7 +1,7 @@ | |||
| const UNITS = [ | ||||
|     { name: 'GB', size: Math.pow(10, 9) }, | ||||
|     { name: 'MB', size: Math.pow(10, 6) }, | ||||
|     { name: 'KB', size: Math.pow(10, 3) }, | ||||
|     { name: 'GB', size: Math.pow(2, 30) }, | ||||
|     { name: 'MB', size: Math.pow(2, 20) }, | ||||
|     { name: 'KB', size: Math.pow(2, 10) }, | ||||
| ]; | ||||
| 
 | ||||
| function displaySize(bytes) { | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue