refactor config variables, add upload password
This commit is contained in:
parent
bfe7fcde99
commit
eb53030043
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -3,3 +3,4 @@
|
||||||
/result
|
/result
|
||||||
flamegraph.svg
|
flamegraph.svg
|
||||||
perf.data*
|
perf.data*
|
||||||
|
.env
|
2
API.md
2
API.md
|
@ -15,6 +15,7 @@
|
||||||
since the Unix epoch.
|
since the Unix epoch.
|
||||||
- `lifetime`: an integer number of days the files should be kept
|
- `lifetime`: an integer number of days the files should be kept
|
||||||
for.
|
for.
|
||||||
|
- `password`: the uploader password.
|
||||||
|
|
||||||
- Once the server receives the metadata, it will respond with a
|
- Once the server receives the metadata, it will respond with a
|
||||||
JSON-encoded object containing at least the field `type`, and
|
JSON-encoded object containing at least the field `type`, and
|
||||||
|
@ -33,6 +34,7 @@
|
||||||
is longer than the server will allow.
|
is longer than the server will allow.
|
||||||
- `max_days`: The maximum number of days the client can request
|
- `max_days`: The maximum number of days the client can request
|
||||||
files be kept for.
|
files be kept for.
|
||||||
|
- `incorrect_password`: The password was incorrect.
|
||||||
- `error`: A miscellaneous error has occurred.
|
- `error`: A miscellaneous error has occurred.
|
||||||
- `details`: A string with more information about the error.
|
- `details`: A string with more information about the error.
|
||||||
|
|
||||||
|
|
14
Cargo.lock
generated
14
Cargo.lock
generated
|
@ -392,6 +392,12 @@ version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8"
|
checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytesize"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6c58ec36aac5066d5ca17df51b3e70279f5670a72102f5752cb7e7c856adfc70"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytestring"
|
name = "bytestring"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
@ -541,6 +547,12 @@ dependencies = [
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dotenv"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.31"
|
version = "0.8.31"
|
||||||
|
@ -1412,7 +1424,9 @@ dependencies = [
|
||||||
"actix-web",
|
"actix-web",
|
||||||
"actix-web-actors",
|
"actix-web-actors",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"bytesize",
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
|
"dotenv",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"inotify",
|
"inotify",
|
||||||
|
|
|
@ -14,7 +14,9 @@ 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"
|
||||||
bytes = "1.1.0"
|
bytes = "1.1.0"
|
||||||
|
bytesize = "1.1.0"
|
||||||
crc32fast = "1.3.2"
|
crc32fast = "1.3.2"
|
||||||
|
dotenv = "0.15"
|
||||||
env_logger = "0.9"
|
env_logger = "0.9"
|
||||||
futures-core = "0.3"
|
futures-core = "0.3"
|
||||||
inotify = "0.10"
|
inotify = "0.10"
|
||||||
|
|
24
README.md
24
README.md
|
@ -22,17 +22,27 @@
|
||||||
## configuration
|
## configuration
|
||||||
|
|
||||||
transbeam is configured with the following environment variables:
|
transbeam is configured with the following environment variables:
|
||||||
- `TRANSBEAM_STORAGE_DIR`: path where uploaded files should be stored (default: `./storage`)
|
- `TRANSBEAM_STORAGE_DIR`: path where uploaded files should be stored
|
||||||
- `TRANSBEAM_STATIC_DIR`: path where the web app's static files live (default: `./static`)
|
(default: `./storage`)
|
||||||
- `TRANSBEAM_PORT`: port to listen on localhost for http requests (default: 8080)
|
- `TRANSBEAM_STATIC_DIR`: path where the web app's static files live
|
||||||
- `TRANSBEAM_MAX_LIFETIME`: maximum number of days files can be kept for (default: 30)
|
(default: `./static`)
|
||||||
- `TRANSBEAM_MAX_UPLOAD_SIZE`: maximum size, in bytes, of a fileset
|
- `TRANSBEAM_PORT`: port to listen on localhost for http requests
|
||||||
being uploaded (default: 16GiB)
|
(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_MAX_STORAGE_SIZE`: maximum total size, in bytes, of all
|
- `TRANSBEAM_MAX_STORAGE_SIZE`: maximum total size, in bytes, of all
|
||||||
files being stored by transbeam (default: 64GiB)
|
files being stored by transbeam (default: 64G)
|
||||||
- `TRANSBEAM_MNEMONIC_CODES`: generate memorable download codes using
|
- `TRANSBEAM_MNEMONIC_CODES`: generate memorable download codes using
|
||||||
English words, rather than random alphanumeric strings (default:
|
English words, rather than random alphanumeric strings (default:
|
||||||
true)
|
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
|
- `RUST_LOG`: log levels, for the app as a whole and/or for specific
|
||||||
submodules/libraries. See
|
submodules/libraries. See
|
||||||
[`env_logger`](https://docs.rs/env_logger/latest/env_logger/)'s
|
[`env_logger`](https://docs.rs/env_logger/latest/env_logger/)'s
|
||||||
|
|
183
src/main.rs
183
src/main.rs
|
@ -3,20 +3,48 @@ mod store;
|
||||||
mod upload;
|
mod upload;
|
||||||
mod zip;
|
mod zip;
|
||||||
|
|
||||||
use std::{fs::File, path::PathBuf};
|
use std::{fmt::Debug, fs::File, path::PathBuf, str::FromStr};
|
||||||
|
|
||||||
use actix_web::{
|
use actix_web::{
|
||||||
get, middleware::Logger, web, App, HttpRequest, HttpResponse, HttpServer, Responder,
|
get, middleware::Logger, post, web, App, HttpRequest, HttpResponse, HttpServer, Responder,
|
||||||
};
|
};
|
||||||
use actix_web_actors::ws;
|
use actix_web_actors::ws;
|
||||||
use log::error;
|
use bytesize::ByteSize;
|
||||||
use serde::Deserialize;
|
use log::{error, warn};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use store::FileStore;
|
use store::FileStore;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
const APP_NAME: &str = "transbeam";
|
const APP_NAME: &str = "transbeam";
|
||||||
|
|
||||||
type AppData = web::Data<RwLock<FileStore>>;
|
struct AppState {
|
||||||
|
file_store: RwLock<FileStore>,
|
||||||
|
config: Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Config {
|
||||||
|
max_upload_size: u64,
|
||||||
|
max_lifetime: u16,
|
||||||
|
upload_password: String,
|
||||||
|
storage_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);
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DownloadRequest {
|
struct DownloadRequest {
|
||||||
|
@ -27,16 +55,18 @@ struct DownloadRequest {
|
||||||
async fn handle_download(
|
async fn handle_download(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
download: web::Query<DownloadRequest>,
|
download: web::Query<DownloadRequest>,
|
||||||
data: AppData,
|
data: web::Data<AppState>,
|
||||||
) -> actix_web::Result<HttpResponse> {
|
) -> actix_web::Result<HttpResponse> {
|
||||||
|
let ip_addr = get_ip_addr(&req, data.config.reverse_proxy);
|
||||||
let code = &download.code;
|
let code = &download.code;
|
||||||
if !store::is_valid_storage_code(code) {
|
if !store::is_valid_storage_code(code) {
|
||||||
|
log_auth_failure(&ip_addr);
|
||||||
return Ok(HttpResponse::NotFound().finish());
|
return Ok(HttpResponse::NotFound().finish());
|
||||||
}
|
}
|
||||||
let data = data.read().await;
|
let file_store = data.file_store.read().await;
|
||||||
let info = data.lookup_file(code);
|
let info = file_store.lookup_file(code);
|
||||||
if let Some(info) = info {
|
if let Some(info) = info {
|
||||||
let storage_path = store::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)?;
|
||||||
Ok(download::DownloadingFile {
|
Ok(download::DownloadingFile {
|
||||||
file,
|
file,
|
||||||
|
@ -45,50 +75,153 @@ async fn handle_download(
|
||||||
}
|
}
|
||||||
.into_response(&req))
|
.into_response(&req))
|
||||||
} else {
|
} else {
|
||||||
|
log_auth_failure(&ip_addr);
|
||||||
Ok(HttpResponse::NotFound().finish())
|
Ok(HttpResponse::NotFound().finish())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/upload")]
|
#[get("/upload")]
|
||||||
async fn handle_upload(req: HttpRequest, stream: web::Payload, data: AppData) -> impl Responder {
|
async fn handle_upload(
|
||||||
ws::start(upload::Uploader::new(data), &req, stream)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
async fn main() -> std::io::Result<()> {
|
async fn main() -> std::io::Result<()> {
|
||||||
|
dotenv::dotenv().ok();
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
let data: AppData = web::Data::new(RwLock::new(FileStore::load().await?));
|
let static_dir: PathBuf = env_or_else("TRANSBEAM_STATIC_DIR", || PathBuf::from("static"));
|
||||||
start_reaper(data.clone());
|
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 static_dir = PathBuf::from(
|
let data = web::Data::new(AppState {
|
||||||
std::env::var("TRANSBEAM_STATIC_DIR").unwrap_or_else(|_| String::from("static")),
|
file_store: RwLock::new(FileStore::load(storage_dir.clone(), max_storage_size).await?),
|
||||||
);
|
config: Config {
|
||||||
let port = std::env::var("TRANSBEAM_PORT")
|
max_upload_size,
|
||||||
.ok()
|
max_lifetime,
|
||||||
.and_then(|p| p.parse::<u16>().ok())
|
upload_password,
|
||||||
.unwrap_or(8080);
|
storage_dir,
|
||||||
|
reverse_proxy,
|
||||||
|
mnemonic_codes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
start_reaper(data.clone());
|
||||||
|
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
App::new()
|
App::new()
|
||||||
.app_data(data.clone())
|
.app_data(data.clone())
|
||||||
.wrap(Logger::default())
|
.wrap(if data.config.reverse_proxy {
|
||||||
.service(handle_upload)
|
Logger::new(r#"%{r}a "%r" %s %b "%{Referer}i" "%{User-Agent}i" %T"#)
|
||||||
|
} else {
|
||||||
|
Logger::default()
|
||||||
|
})
|
||||||
.service(handle_download)
|
.service(handle_download)
|
||||||
|
.service(handle_upload)
|
||||||
|
.service(check_upload_password)
|
||||||
|
.service(upload_limits)
|
||||||
.service(actix_files::Files::new("/", static_dir.clone()).index_file("index.html"))
|
.service(actix_files::Files::new("/", static_dir.clone()).index_file("index.html"))
|
||||||
})
|
})
|
||||||
.bind(("127.0.0.1", port))?
|
.bind((
|
||||||
|
if reverse_proxy {
|
||||||
|
"127.0.0.1"
|
||||||
|
} else {
|
||||||
|
"0.0.0.0"
|
||||||
|
},
|
||||||
|
port,
|
||||||
|
))?
|
||||||
.run()
|
.run()
|
||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start_reaper(data: AppData) {
|
fn start_reaper(data: web::Data<AppState>) {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
actix_web::rt::System::new().block_on(async {
|
actix_web::rt::System::new().block_on(async {
|
||||||
loop {
|
loop {
|
||||||
actix_web::rt::time::sleep(core::time::Duration::from_secs(86400)).await;
|
actix_web::rt::time::sleep(core::time::Duration::from_secs(86400)).await;
|
||||||
if let Err(e) = data.write().await.remove_expired_files().await {
|
if let Err(e) = data.file_store.write().await.remove_expired_files().await {
|
||||||
error!("Error reaping expired files: {}", e);
|
error!("Error reaping expired files: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
152
src/store.rs
152
src/store.rs
|
@ -1,6 +1,10 @@
|
||||||
use std::{collections::HashMap, io::ErrorKind, path::PathBuf, str::FromStr};
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
io::ErrorKind,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info};
|
||||||
use rand::{
|
use rand::{
|
||||||
distributions::{Alphanumeric, DistString},
|
distributions::{Alphanumeric, DistString},
|
||||||
thread_rng, Rng,
|
thread_rng, Rng,
|
||||||
|
@ -13,17 +17,13 @@ use tokio::{
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATE_FILE_NAME: &str = "files.json";
|
const STATE_FILE_NAME: &str = "files.json";
|
||||||
const DEFAULT_STORAGE_DIR: &str = "storage";
|
const MAX_STORAGE_FILES: usize = 1024;
|
||||||
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() -> String {
|
pub fn gen_storage_code(use_mnemonic: bool) -> String {
|
||||||
if std::env::var("TRANSBEAM_MNEMONIC_CODES").as_deref() == Ok("false") {
|
if use_mnemonic {
|
||||||
Alphanumeric.sample_string(&mut thread_rng(), 8)
|
|
||||||
} else {
|
|
||||||
mnemonic::to_string(thread_rng().gen::<[u8; 4]>())
|
mnemonic::to_string(thread_rng().gen::<[u8; 4]>())
|
||||||
|
} else {
|
||||||
|
Alphanumeric.sample_string(&mut thread_rng(), 8)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -33,32 +33,6 @@ 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'-')
|
||||||
}
|
}
|
||||||
|
|
||||||
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)]
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
pub struct StoredFile {
|
pub struct StoredFile {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
@ -110,13 +84,13 @@ pub(crate) mod timestamp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn is_valid_entry(key: &str, info: &StoredFile) -> bool {
|
async fn is_valid_entry(key: &str, info: &StoredFile, storage_dir: &Path) -> bool {
|
||||||
if info.expiry < OffsetDateTime::now_utc() {
|
if info.expiry < OffsetDateTime::now_utc() {
|
||||||
info!("File {} has expired", key);
|
info!("File {} has expired", key);
|
||||||
return false;
|
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
|
f
|
||||||
} else {
|
} else {
|
||||||
error!(
|
error!(
|
||||||
|
@ -141,10 +115,35 @@ async fn is_valid_entry(key: &str, info: &StoredFile) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct FileStore(HashMap<String, StoredFile>);
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
impl FileStore {
|
impl FileStore {
|
||||||
pub(crate) async fn load() -> std::io::Result<Self> {
|
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;
|
let open_result = File::open(storage_dir.join(STATE_FILE_NAME)).await;
|
||||||
match open_result {
|
match open_result {
|
||||||
Ok(mut f) => {
|
Ok(mut f) => {
|
||||||
let mut buf = String::new();
|
let mut buf = String::new();
|
||||||
|
@ -160,22 +159,28 @@ impl FileStore {
|
||||||
error!("Invalid key in persistent storage: {}", key);
|
error!("Invalid key in persistent storage: {}", key);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if is_valid_entry(&key, &info).await {
|
if is_valid_entry(&key, &info, &storage_dir).await {
|
||||||
filtered.insert(key, info);
|
filtered.insert(key, info);
|
||||||
} else {
|
} else {
|
||||||
info!("Deleting file {}", key);
|
info!("Deleting file {}", key);
|
||||||
if let Err(e) = tokio::fs::remove_file(storage_dir().join(&key)).await {
|
delete_file_if_exists(&storage_dir.join(&key)).await?;
|
||||||
warn!("Failed to delete file {}: {}", key, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut loaded = Self(filtered);
|
let mut loaded = Self {
|
||||||
|
files: filtered,
|
||||||
|
storage_dir,
|
||||||
|
max_storage_size,
|
||||||
|
};
|
||||||
loaded.save().await?;
|
loaded.save().await?;
|
||||||
Ok(loaded)
|
Ok(loaded)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if let ErrorKind::NotFound = e.kind() {
|
if let ErrorKind::NotFound = e.kind() {
|
||||||
Ok(Self(HashMap::new()))
|
Ok(Self {
|
||||||
|
files: HashMap::new(),
|
||||||
|
storage_dir,
|
||||||
|
max_storage_size,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
Err(e)
|
Err(e)
|
||||||
}
|
}
|
||||||
|
@ -184,17 +189,25 @@ impl FileStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn total_size(&self) -> u64 {
|
fn total_size(&self) -> u64 {
|
||||||
self.0.iter().fold(0, |acc, (_, f)| acc + f.size)
|
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())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn save(&mut self) -> std::io::Result<()> {
|
async fn save(&mut self) -> std::io::Result<()> {
|
||||||
info!("saving updated state: {} entries", self.0.len());
|
info!("saving updated state: {} entries", self.files.len());
|
||||||
File::create(storage_dir().join(STATE_FILE_NAME))
|
File::create(self.storage_dir.join(STATE_FILE_NAME))
|
||||||
.await?
|
.await?
|
||||||
.write_all(&serde_json::to_vec_pretty(&self.0)?)
|
.write_all(&serde_json::to_vec_pretty(&self.files)?)
|
||||||
.await
|
.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
|
/// 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
|
/// something's broken, or a u64 of the maximum allowed file size
|
||||||
/// if the file was too big, or a unit if everything worked.
|
/// if the file was too big, or a unit if everything worked.
|
||||||
|
@ -202,37 +215,42 @@ impl FileStore {
|
||||||
&mut self,
|
&mut self,
|
||||||
key: String,
|
key: String,
|
||||||
file: StoredFile,
|
file: StoredFile,
|
||||||
) -> std::io::Result<Result<(), u64>> {
|
) -> Result<(), FileAddError> {
|
||||||
let remaining_size = max_total_size().saturating_sub(self.total_size());
|
if self.full() {
|
||||||
let allowed_size = std::cmp::min(remaining_size, max_single_size());
|
return Err(FileAddError::Full);
|
||||||
if file.size > allowed_size {
|
|
||||||
return Ok(Err(allowed_size));
|
|
||||||
}
|
}
|
||||||
self.0.insert(key, file);
|
let available_size = self.available_size();
|
||||||
self.save().await.map(Ok)
|
if file.size > available_size {
|
||||||
|
return Err(FileAddError::TooBig(available_size));
|
||||||
|
}
|
||||||
|
self.files.insert(key, file);
|
||||||
|
self.save().await?;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn lookup_file(&self, key: &str) -> Option<StoredFile> {
|
pub(crate) fn lookup_file(&self, key: &str) -> Option<StoredFile> {
|
||||||
self.0.get(key).cloned()
|
self.files.get(key).cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn remove_file(&mut self, key: &str) -> std::io::Result<()> {
|
pub(crate) async fn remove_file(&mut self, key: &str) -> std::io::Result<()> {
|
||||||
debug!("removing entry {} from state", key);
|
debug!("removing entry {} from state", key);
|
||||||
self.0.remove(key);
|
self.files.remove(key);
|
||||||
self.save().await
|
self.save().await?;
|
||||||
|
if is_valid_storage_code(key) {
|
||||||
|
delete_file_if_exists(&self.storage_dir.join(key)).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn remove_expired_files(&mut self) -> std::io::Result<()> {
|
pub(crate) async fn remove_expired_files(&mut self) -> std::io::Result<()> {
|
||||||
info!("Checking for expired files");
|
info!("Checking for expired files");
|
||||||
let now = OffsetDateTime::now_utc();
|
let now = OffsetDateTime::now_utc();
|
||||||
for (key, file) in std::mem::take(&mut self.0).into_iter() {
|
for (key, file) in std::mem::take(&mut self.files).into_iter() {
|
||||||
if file.expiry > now {
|
if file.expiry > now {
|
||||||
self.0.insert(key, file);
|
self.files.insert(key, file);
|
||||||
} else {
|
} else {
|
||||||
info!("Deleting expired file {}", key);
|
info!("Deleting expired file {}", key);
|
||||||
if let Err(e) = tokio::fs::remove_file(storage_dir().join(&key)).await {
|
delete_file_if_exists(&self.storage_dir.join(&key)).await?;
|
||||||
warn!("Failed to delete expired file {}: {}", key, e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.save().await
|
self.save().await
|
||||||
|
|
|
@ -2,6 +2,7 @@ use std::{collections::HashSet, fs::File, io::Write};
|
||||||
|
|
||||||
use actix::{fut::future::ActorFutureExt, Actor, ActorContext, AsyncContext, StreamHandler};
|
use actix::{fut::future::ActorFutureExt, Actor, ActorContext, AsyncContext, StreamHandler};
|
||||||
use actix_http::ws::{CloseReason, Item};
|
use actix_http::ws::{CloseReason, Item};
|
||||||
|
use actix_web::web;
|
||||||
use actix_web_actors::ws::{self, CloseCode};
|
use actix_web_actors::ws::{self, CloseCode};
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use log::{debug, error, info, trace};
|
use log::{debug, error, info, trace};
|
||||||
|
@ -9,7 +10,11 @@ use serde::{Deserialize, Serialize};
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
use unicode_normalization::UnicodeNormalization;
|
use unicode_normalization::UnicodeNormalization;
|
||||||
|
|
||||||
use crate::store::{self, storage_dir, StoredFile};
|
use crate::{
|
||||||
|
log_auth_failure,
|
||||||
|
store::{self, FileAddError, StoredFile},
|
||||||
|
AppState,
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_FILES: usize = 256;
|
const MAX_FILES: usize = 256;
|
||||||
const FILENAME_DATE_FORMAT: &[time::format_description::FormatItem] =
|
const FILENAME_DATE_FORMAT: &[time::format_description::FormatItem] =
|
||||||
|
@ -29,10 +34,14 @@ enum Error {
|
||||||
NoFiles,
|
NoFiles,
|
||||||
#[error("Number of files submitted by client exceeded the maximum limit")]
|
#[error("Number of files submitted by client exceeded the maximum limit")]
|
||||||
TooManyFiles,
|
TooManyFiles,
|
||||||
#[error("Requested lifetime was too long")]
|
#[error("Requested lifetime was too long, can be at most {0} days")]
|
||||||
TooLong,
|
TooLong(u16),
|
||||||
#[error("Upload size was too large, can be at most {0} bytes")]
|
#[error("Upload size was too large, can be at most {0} bytes")]
|
||||||
TooBig(u64),
|
TooBig(u64),
|
||||||
|
#[error("File storage is full")]
|
||||||
|
Full,
|
||||||
|
#[error("Incorrect password")]
|
||||||
|
IncorrectPassword,
|
||||||
#[error("Websocket was closed by client before completing transfer")]
|
#[error("Websocket was closed by client before completing transfer")]
|
||||||
ClosedEarly(Option<CloseReason>),
|
ClosedEarly(Option<CloseReason>),
|
||||||
#[error("Client sent more data than they were supposed to")]
|
#[error("Client sent more data than they were supposed to")]
|
||||||
|
@ -50,8 +59,10 @@ impl Error {
|
||||||
Self::DuplicateFilename
|
Self::DuplicateFilename
|
||||||
| Self::NoFiles
|
| Self::NoFiles
|
||||||
| Self::TooManyFiles
|
| Self::TooManyFiles
|
||||||
| Self::TooLong
|
| Self::TooLong(_)
|
||||||
| Self::TooBig(_) => CloseCode::Policy,
|
| Self::TooBig(_)
|
||||||
|
| Self::Full
|
||||||
|
| Self::IncorrectPassword => CloseCode::Policy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -59,17 +70,19 @@ impl Error {
|
||||||
pub struct Uploader {
|
pub struct Uploader {
|
||||||
writer: Option<Box<dyn Write>>,
|
writer: Option<Box<dyn Write>>,
|
||||||
storage_filename: String,
|
storage_filename: String,
|
||||||
app_data: super::AppData,
|
app_state: web::Data<AppState>,
|
||||||
bytes_remaining: u64,
|
bytes_remaining: u64,
|
||||||
|
ip_addr: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Uploader {
|
impl Uploader {
|
||||||
pub(crate) fn new(app_data: super::AppData) -> Self {
|
pub(crate) fn new(app_state: web::Data<AppState>, ip_addr: String) -> Self {
|
||||||
Self {
|
Self {
|
||||||
writer: None,
|
writer: None,
|
||||||
storage_filename: store::gen_storage_code(),
|
storage_filename: store::gen_storage_code(app_state.config.mnemonic_codes),
|
||||||
app_data,
|
app_state,
|
||||||
bytes_remaining: 0,
|
bytes_remaining: 0,
|
||||||
|
ip_addr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -117,7 +130,8 @@ impl RawUploadedFile {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct UploadManifest {
|
struct UploadManifest {
|
||||||
files: Vec<RawUploadedFile>,
|
files: Vec<RawUploadedFile>,
|
||||||
lifetime: u32,
|
lifetime: u16,
|
||||||
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
@ -125,7 +139,8 @@ struct UploadManifest {
|
||||||
enum ServerMessage {
|
enum ServerMessage {
|
||||||
Ready { code: String },
|
Ready { code: String },
|
||||||
TooBig { max_size: u64 },
|
TooBig { max_size: u64 },
|
||||||
TooLong { max_days: u32 },
|
TooLong { max_days: u16 },
|
||||||
|
IncorrectPassword,
|
||||||
Error { details: String },
|
Error { details: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -135,9 +150,10 @@ impl From<&Error> for ServerMessage {
|
||||||
Error::TooBig(max_size) => ServerMessage::TooBig {
|
Error::TooBig(max_size) => ServerMessage::TooBig {
|
||||||
max_size: *max_size,
|
max_size: *max_size,
|
||||||
},
|
},
|
||||||
Error::TooLong => ServerMessage::TooLong {
|
Error::TooLong(max_days) => ServerMessage::TooLong {
|
||||||
max_days: store::max_lifetime(),
|
max_days: *max_days,
|
||||||
},
|
},
|
||||||
|
Error::IncorrectPassword => ServerMessage::IncorrectPassword,
|
||||||
_ => ServerMessage::Error {
|
_ => ServerMessage::Error {
|
||||||
details: e.to_string(),
|
details: e.to_string(),
|
||||||
},
|
},
|
||||||
|
@ -189,6 +205,9 @@ fn ack(ctx: &mut Context) {
|
||||||
impl Uploader {
|
impl Uploader {
|
||||||
fn notify_error_and_cleanup(&mut self, e: Error, ctx: &mut Context) {
|
fn notify_error_and_cleanup(&mut self, e: Error, ctx: &mut Context) {
|
||||||
error!("{}", e);
|
error!("{}", e);
|
||||||
|
if let Error::IncorrectPassword = e {
|
||||||
|
log_auth_failure(&self.ip_addr);
|
||||||
|
}
|
||||||
ctx.text(serde_json::to_string(&ServerMessage::from(&e)).unwrap());
|
ctx.text(serde_json::to_string(&ServerMessage::from(&e)).unwrap());
|
||||||
ctx.close(Some(ws::CloseReason {
|
ctx.close(Some(ws::CloseReason {
|
||||||
code: e.close_code(),
|
code: e.close_code(),
|
||||||
|
@ -207,9 +226,13 @@ impl Uploader {
|
||||||
let UploadManifest {
|
let UploadManifest {
|
||||||
files: raw_files,
|
files: raw_files,
|
||||||
lifetime,
|
lifetime,
|
||||||
|
password,
|
||||||
} = serde_json::from_slice(text.as_bytes())?;
|
} = serde_json::from_slice(text.as_bytes())?;
|
||||||
if lifetime > store::max_lifetime() {
|
if std::env::var("TRANSBEAM_UPLOAD_PASSWORD") != Ok(password) {
|
||||||
return Err(Error::TooLong);
|
return Err(Error::IncorrectPassword);
|
||||||
|
}
|
||||||
|
if lifetime > self.app_state.config.max_lifetime {
|
||||||
|
return Err(Error::TooLong(self.app_state.config.max_lifetime));
|
||||||
}
|
}
|
||||||
info!("Received file list: {} files", raw_files.len());
|
info!("Received file list: {} files", raw_files.len());
|
||||||
debug!("{:?}", raw_files);
|
debug!("{:?}", raw_files);
|
||||||
|
@ -234,7 +257,14 @@ impl Uploader {
|
||||||
self.bytes_remaining += file.size;
|
self.bytes_remaining += file.size;
|
||||||
files.push(file);
|
files.push(file);
|
||||||
}
|
}
|
||||||
let storage_path = storage_dir().join(self.storage_filename.clone());
|
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());
|
||||||
info!("storing to: {:?}", storage_path);
|
info!("storing to: {:?}", storage_path);
|
||||||
let writer = File::options()
|
let writer = File::options()
|
||||||
.write(true)
|
.write(true)
|
||||||
|
@ -264,25 +294,32 @@ impl Uploader {
|
||||||
modtime,
|
modtime,
|
||||||
expiry: OffsetDateTime::now_utc() + lifetime * time::Duration::DAY,
|
expiry: OffsetDateTime::now_utc() + lifetime * time::Duration::DAY,
|
||||||
};
|
};
|
||||||
let data = self.app_data.clone();
|
let state = self.app_state.clone();
|
||||||
let storage_filename = self.storage_filename.clone();
|
let storage_filename = self.storage_filename.clone();
|
||||||
ctx.spawn(
|
ctx.spawn(
|
||||||
actix::fut::wrap_future(async move {
|
actix::fut::wrap_future(async move {
|
||||||
debug!("Spawned future to add entry {} to state", storage_filename);
|
debug!("Spawned future to add entry {} to state", storage_filename);
|
||||||
data.write()
|
state
|
||||||
|
.file_store
|
||||||
|
.write()
|
||||||
.await
|
.await
|
||||||
.add_file(storage_filename, stored_file)
|
.add_file(storage_filename, stored_file)
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
.map(|res, u: &mut Self, ctx: &mut Context| match res {
|
.map(|res, u: &mut Self, ctx: &mut Context| match res {
|
||||||
Ok(Ok(())) => ctx.text(
|
Ok(()) => ctx.text(
|
||||||
serde_json::to_string(&ServerMessage::Ready {
|
serde_json::to_string(&ServerMessage::Ready {
|
||||||
code: u.storage_filename.clone(),
|
code: u.storage_filename.clone(),
|
||||||
})
|
})
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
),
|
),
|
||||||
Ok(Err(size)) => u.notify_error_and_cleanup(Error::TooBig(size), ctx),
|
Err(FileAddError::TooBig(size)) => {
|
||||||
Err(e) => u.notify_error_and_cleanup(Error::from(e), ctx),
|
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)
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -332,17 +369,20 @@ impl Uploader {
|
||||||
"Cleaning up after failed upload of {}",
|
"Cleaning up after failed upload of {}",
|
||||||
self.storage_filename
|
self.storage_filename
|
||||||
);
|
);
|
||||||
let data = self.app_data.clone();
|
let state = self.app_state.clone();
|
||||||
let filename = self.storage_filename.clone();
|
let filename = self.storage_filename.clone();
|
||||||
ctx.wait(
|
ctx.wait(
|
||||||
actix::fut::wrap_future(async move {
|
actix::fut::wrap_future(async move {
|
||||||
debug!("Spawned future to remove entry {} from state", filename);
|
debug!("Spawned future to remove entry {} from state", filename);
|
||||||
data.write().await.remove_file(&filename).await.unwrap();
|
state
|
||||||
|
.file_store
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.remove_file(&filename)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
})
|
})
|
||||||
.map(stop_and_flush),
|
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,55 +11,73 @@
|
||||||
<script src="transbeam.js"></script>
|
<script src="transbeam.js"></script>
|
||||||
<title>transbeam</title>
|
<title>transbeam</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="no_files selecting">
|
<body class="noscript landing">
|
||||||
<div id="header">
|
<div id="header">
|
||||||
<img src="images/site-icons/transbeam.svg" height="128">
|
<img src="images/site-icons/transbeam.svg" height="128">
|
||||||
<h1>transbeam</h1>
|
<h1>transbeam</h1>
|
||||||
</div>
|
</div>
|
||||||
<div id="message"></div>
|
<div id="download" class="section">
|
||||||
<div id="upload_controls">
|
<h3 class="section_heading">Download</h3>
|
||||||
<div>
|
|
||||||
<button id="upload">Upload</button>
|
|
||||||
</div>
|
|
||||||
<div id="lifetime_container">
|
|
||||||
<label>
|
|
||||||
Keep files for:
|
|
||||||
<select id="lifetime">
|
|
||||||
<option value="1">1 day</option>
|
|
||||||
<option value="7">1 week</option>
|
|
||||||
<option value="14" selected>2 weeks</option>
|
|
||||||
<option value="30">1 month</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="download_code_container">
|
|
||||||
<div id="download_code_main">
|
|
||||||
<div>Download code: <span id="download_code"></span></div><div class="copy_button"></div>
|
|
||||||
</div>
|
|
||||||
<div id="copied_message">Link copied!</div>
|
|
||||||
</div>
|
|
||||||
<div id="progress_container">
|
|
||||||
<div id="progress"></div>
|
|
||||||
<div id="progress_bar"></div>
|
|
||||||
</div>
|
|
||||||
<table id="file_list">
|
|
||||||
</table>
|
|
||||||
<label id="file_input_container">
|
|
||||||
<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">
|
<form id="download_form" action="download" method="get">
|
||||||
<div>
|
<div>
|
||||||
<label>
|
<label>
|
||||||
<h4>Or enter a code to download files:</h4>
|
|
||||||
<input type="text" id="download_code_input" name="code" placeholder="Download code"/>
|
<input type="text" id="download_code_input" name="code" placeholder="Download code"/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<input id="download_button" type="submit" value="Download"/>
|
<input id="download_button" type="submit" value="Download"/>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
<div id="lifetime_container">
|
||||||
|
<label>
|
||||||
|
Keep files for:
|
||||||
|
<select id="lifetime">
|
||||||
|
<option value="1">1 day</option>
|
||||||
|
<option value="7">1 week</option>
|
||||||
|
<option value="14" selected>2 weeks</option>
|
||||||
|
<option value="30">1 month</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="download_code_container">
|
||||||
|
<div id="download_code_main">
|
||||||
|
<div>Download code: <span id="download_code"></span></div><div class="copy_button"></div>
|
||||||
|
</div>
|
||||||
|
<div id="copied_message">Link copied!</div>
|
||||||
|
</div>
|
||||||
|
<div id="progress_container">
|
||||||
|
<div id="progress"></div>
|
||||||
|
<div id="progress_bar"></div>
|
||||||
|
</div>
|
||||||
|
<table id="file_list">
|
||||||
|
</table>
|
||||||
|
<label id="file_input_container">
|
||||||
|
<input type="file" multiple id="file_input"/>
|
||||||
|
<span class="fake_button" id="file_input_message">Select files to upload...</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div id="footer">
|
<div id="footer">
|
||||||
<h5>(c) 2022 xenofem, MIT licensed</h5>
|
<h5>(c) 2022 xenofem, MIT licensed</h5>
|
||||||
<h5><a target="_blank" href="https://git.xeno.science/xenofem/transbeam">source</a></h5>
|
<h5><a target="_blank" href="https://git.xeno.science/xenofem/transbeam">source</a></h5>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* List of classes the body can have:
|
* List of classes the body can have:
|
||||||
*
|
*
|
||||||
|
* landing: haven't entered upload password yet
|
||||||
* no_files: no files are selected
|
* no_files: no files are selected
|
||||||
* selecting: upload hasn't started yet
|
* selecting: upload hasn't started yet
|
||||||
* uploading: upload is in progress
|
* uploading: upload is in progress
|
||||||
|
@ -8,6 +9,14 @@
|
||||||
* error: an error has occurred
|
* 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; }
|
#message { display: none; }
|
||||||
body.completed #message {
|
body.completed #message {
|
||||||
display: revert;
|
display: revert;
|
||||||
|
@ -20,9 +29,14 @@ body.error #message {
|
||||||
border-color: #f24;
|
border-color: #f24;
|
||||||
}
|
}
|
||||||
|
|
||||||
#upload_controls { display: none; }
|
#upload_password_form { display: none; }
|
||||||
body.selecting #upload_controls { display: revert; }
|
body.landing #upload_password_form { display: revert; }
|
||||||
body.no_files #upload_controls { display: none; }
|
|
||||||
|
body.landing #upload_controls { display: none; }
|
||||||
|
|
||||||
|
#upload_settings { display: none; }
|
||||||
|
body.selecting #upload_settings { display: revert; }
|
||||||
|
body.no_files #upload_settings { display: none; }
|
||||||
|
|
||||||
body.selecting #download_code_container { display: none; }
|
body.selecting #download_code_container { display: none; }
|
||||||
|
|
||||||
|
@ -37,6 +51,3 @@ body.selecting .delete_button { display: revert; }
|
||||||
|
|
||||||
#file_input_container { display: none; }
|
#file_input_container { display: none; }
|
||||||
body.selecting #file_input_container { display: revert; }
|
body.selecting #file_input_container { display: revert; }
|
||||||
|
|
||||||
#download { display: none; }
|
|
||||||
body.no_files #download { display: revert; }
|
|
||||||
|
|
|
@ -153,17 +153,13 @@ button:disabled, input:disabled + .fake_button, input[type="submit"]:disabled {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#download {
|
input[type="text"], input[type="password"] {
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#download_code_input {
|
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#footer {
|
.section {
|
||||||
margin-top: 30px;
|
margin: 30px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#footer h5 {
|
#footer h5 {
|
||||||
|
|
|
@ -11,6 +11,8 @@ let totalBytes = 0;
|
||||||
|
|
||||||
let maxSize = null;
|
let maxSize = null;
|
||||||
|
|
||||||
|
let uploadPassword;
|
||||||
|
|
||||||
let messageBox;
|
let messageBox;
|
||||||
let fileInput;
|
let fileInput;
|
||||||
let fileList;
|
let fileList;
|
||||||
|
@ -21,15 +23,37 @@ let progress;
|
||||||
let progressBar;
|
let progressBar;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.body.className = "landing";
|
||||||
|
|
||||||
messageBox = document.getElementById('message');
|
messageBox = document.getElementById('message');
|
||||||
fileInput = document.getElementById('file_input');
|
fileInput = document.getElementById('file_input');
|
||||||
fileList = document.getElementById('file_list');
|
fileList = document.getElementById('file_list');
|
||||||
uploadButton = document.getElementById('upload');
|
uploadButton = document.getElementById('upload_button');
|
||||||
lifetimeInput = document.getElementById('lifetime');
|
lifetimeInput = document.getElementById('lifetime');
|
||||||
downloadCode = document.getElementById('download_code');
|
downloadCode = document.getElementById('download_code');
|
||||||
progress = document.getElementById('progress');
|
progress = document.getElementById('progress');
|
||||||
progressBar = document.getElementById('progress_bar');
|
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', () => {
|
fileInput.addEventListener('input', () => {
|
||||||
for (const file of fileInput.files) { addFile(file); }
|
for (const file of fileInput.files) { addFile(file); }
|
||||||
updateFiles();
|
updateFiles();
|
||||||
|
@ -71,8 +95,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
updateFiles();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateFiles() {
|
function updateFiles() {
|
||||||
|
@ -162,7 +184,11 @@ function sendManifest() {
|
||||||
size: file.size,
|
size: file.size,
|
||||||
modtime: file.lastModified,
|
modtime: file.lastModified,
|
||||||
}));
|
}));
|
||||||
socket.send(JSON.stringify({ lifetime, files: fileMetadata }));
|
socket.send(JSON.stringify({
|
||||||
|
files: fileMetadata,
|
||||||
|
lifetime,
|
||||||
|
password: uploadPassword,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(msg) {
|
function handleMessage(msg) {
|
||||||
|
@ -202,6 +228,9 @@ function handleMessage(msg) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
displayError(`The maximum retention time for uploads is ${reply.max_days} days`);
|
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') {
|
} else if (reply.type === 'error') {
|
||||||
displayError(reply.details);
|
displayError(reply.details);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
const UNITS = [
|
const UNITS = [
|
||||||
{ name: 'GB', size: Math.pow(2, 30) },
|
{ name: 'GB', size: Math.pow(10, 9) },
|
||||||
{ name: 'MB', size: Math.pow(2, 20) },
|
{ name: 'MB', size: Math.pow(10, 6) },
|
||||||
{ name: 'KB', size: Math.pow(2, 10) },
|
{ name: 'KB', size: Math.pow(10, 3) },
|
||||||
];
|
];
|
||||||
|
|
||||||
function displaySize(bytes) {
|
function displaySize(bytes) {
|
||||||
|
|
Loading…
Reference in a new issue