refactor config variables, add upload password

This commit is contained in:
xenofem 2022-05-03 16:28:43 -04:00
parent bfe7fcde99
commit eb53030043
13 changed files with 456 additions and 182 deletions

3
.gitignore vendored
View file

@ -2,4 +2,5 @@
/storage
/result
flamegraph.svg
perf.data*
perf.data*
.env

2
API.md
View file

@ -15,6 +15,7 @@
since the Unix epoch.
- `lifetime`: an integer number of days the files should be kept
for.
- `password`: the uploader password.
- Once the server receives the metadata, it will respond with a
JSON-encoded object containing at least the field `type`, and
@ -33,6 +34,7 @@
is longer than the server will allow.
- `max_days`: The maximum number of days the client can request
files be kept for.
- `incorrect_password`: The password was incorrect.
- `error`: A miscellaneous error has occurred.
- `details`: A string with more information about the error.

14
Cargo.lock generated
View file

@ -392,6 +392,12 @@ 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"
@ -541,6 +547,12 @@ 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"
@ -1412,7 +1424,9 @@ dependencies = [
"actix-web",
"actix-web-actors",
"bytes",
"bytesize",
"crc32fast",
"dotenv",
"env_logger",
"futures-core",
"inotify",

View file

@ -14,7 +14,9 @@ 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"

View file

@ -22,17 +22,27 @@
## 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, in bytes, of a fileset
being uploaded (default: 16GiB)
- `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_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
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

View file

@ -3,20 +3,48 @@ mod store;
mod upload;
mod zip;
use std::{fs::File, path::PathBuf};
use std::{fmt::Debug, fs::File, path::PathBuf, str::FromStr};
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 log::error;
use serde::Deserialize;
use bytesize::ByteSize;
use log::{error, warn};
use serde::{Deserialize, Serialize};
use store::FileStore;
use tokio::sync::RwLock;
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)]
struct DownloadRequest {
@ -27,16 +55,18 @@ struct DownloadRequest {
async fn handle_download(
req: HttpRequest,
download: web::Query<DownloadRequest>,
data: AppData,
data: web::Data<AppState>,
) -> actix_web::Result<HttpResponse> {
let ip_addr = get_ip_addr(&req, data.config.reverse_proxy);
let code = &download.code;
if !store::is_valid_storage_code(code) {
log_auth_failure(&ip_addr);
return Ok(HttpResponse::NotFound().finish());
}
let data = data.read().await;
let info = data.lookup_file(code);
let file_store = data.file_store.read().await;
let info = file_store.lookup_file(code);
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)?;
Ok(download::DownloadingFile {
file,
@ -45,50 +75,153 @@ async fn handle_download(
}
.into_response(&req))
} else {
log_auth_failure(&ip_addr);
Ok(HttpResponse::NotFound().finish())
}
}
#[get("/upload")]
async fn handle_upload(req: HttpRequest, stream: web::Payload, data: AppData) -> impl Responder {
ws::start(upload::Uploader::new(data), &req, stream)
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)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok();
env_logger::init();
let data: AppData = web::Data::new(RwLock::new(FileStore::load().await?));
start_reaper(data.clone());
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 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);
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,
reverse_proxy,
mnemonic_codes,
},
});
start_reaper(data.clone());
HttpServer::new(move || {
App::new()
.app_data(data.clone())
.wrap(Logger::default())
.service(handle_upload)
.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)
.service(handle_upload)
.service(check_upload_password)
.service(upload_limits)
.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()
.await?;
Ok(())
}
fn start_reaper(data: AppData) {
fn start_reaper(data: web::Data<AppState>) {
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.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);
}
}

View file

@ -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::{
distributions::{Alphanumeric, DistString},
thread_rng, Rng,
@ -13,17 +17,13 @@ use tokio::{
};
const STATE_FILE_NAME: &str = "files.json";
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;
const MAX_STORAGE_FILES: usize = 1024;
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 {
pub fn gen_storage_code(use_mnemonic: bool) -> String {
if use_mnemonic {
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'-')
}
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,
@ -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() {
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!(
@ -141,10 +115,35 @@ async fn is_valid_entry(key: &str, info: &StoredFile) -> bool {
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 {
pub(crate) async fn load() -> std::io::Result<Self> {
let open_result = File::open(storage_dir().join(STATE_FILE_NAME)).await;
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;
match open_result {
Ok(mut f) => {
let mut buf = String::new();
@ -160,22 +159,28 @@ impl FileStore {
error!("Invalid key in persistent storage: {}", key);
continue;
}
if is_valid_entry(&key, &info).await {
if is_valid_entry(&key, &info, &storage_dir).await {
filtered.insert(key, info);
} else {
info!("Deleting file {}", key);
if let Err(e) = tokio::fs::remove_file(storage_dir().join(&key)).await {
warn!("Failed to delete file {}: {}", key, e);
}
delete_file_if_exists(&storage_dir.join(&key)).await?;
}
}
let mut loaded = Self(filtered);
let mut loaded = Self {
files: filtered,
storage_dir,
max_storage_size,
};
loaded.save().await?;
Ok(loaded)
}
Err(e) => {
if let ErrorKind::NotFound = e.kind() {
Ok(Self(HashMap::new()))
Ok(Self {
files: HashMap::new(),
storage_dir,
max_storage_size,
})
} else {
Err(e)
}
@ -184,17 +189,25 @@ impl FileStore {
}
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<()> {
info!("saving updated state: {} entries", self.0.len());
File::create(storage_dir().join(STATE_FILE_NAME))
info!("saving updated state: {} entries", self.files.len());
File::create(self.storage_dir.join(STATE_FILE_NAME))
.await?
.write_all(&serde_json::to_vec_pretty(&self.0)?)
.write_all(&serde_json::to_vec_pretty(&self.files)?)
.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.
@ -202,37 +215,42 @@ impl FileStore {
&mut self,
key: String,
file: StoredFile,
) -> 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));
) -> Result<(), FileAddError> {
if self.full() {
return Err(FileAddError::Full);
}
self.0.insert(key, file);
self.save().await.map(Ok)
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(())
}
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<()> {
debug!("removing entry {} from state", key);
self.0.remove(key);
self.save().await
self.files.remove(key);
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<()> {
info!("Checking for expired files");
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 {
self.0.insert(key, file);
self.files.insert(key, file);
} else {
info!("Deleting expired file {}", key);
if let Err(e) = tokio::fs::remove_file(storage_dir().join(&key)).await {
warn!("Failed to delete expired file {}: {}", key, e);
}
delete_file_if_exists(&self.storage_dir.join(&key)).await?;
}
}
self.save().await

View file

@ -2,6 +2,7 @@ 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};
@ -9,7 +10,11 @@ use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
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 FILENAME_DATE_FORMAT: &[time::format_description::FormatItem] =
@ -29,10 +34,14 @@ enum Error {
NoFiles,
#[error("Number of files submitted by client exceeded the maximum limit")]
TooManyFiles,
#[error("Requested lifetime was too long")]
TooLong,
#[error("Requested lifetime was too long, can be at most {0} days")]
TooLong(u16),
#[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")]
@ -50,8 +59,10 @@ impl Error {
Self::DuplicateFilename
| Self::NoFiles
| Self::TooManyFiles
| Self::TooLong
| Self::TooBig(_) => CloseCode::Policy,
| Self::TooLong(_)
| Self::TooBig(_)
| Self::Full
| Self::IncorrectPassword => CloseCode::Policy,
}
}
}
@ -59,17 +70,19 @@ impl Error {
pub struct Uploader {
writer: Option<Box<dyn Write>>,
storage_filename: String,
app_data: super::AppData,
app_state: web::Data<AppState>,
bytes_remaining: u64,
ip_addr: String,
}
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 {
writer: None,
storage_filename: store::gen_storage_code(),
app_data,
storage_filename: store::gen_storage_code(app_state.config.mnemonic_codes),
app_state,
bytes_remaining: 0,
ip_addr,
}
}
}
@ -117,7 +130,8 @@ impl RawUploadedFile {
#[derive(Deserialize)]
struct UploadManifest {
files: Vec<RawUploadedFile>,
lifetime: u32,
lifetime: u16,
password: String,
}
#[derive(Serialize)]
@ -125,7 +139,8 @@ struct UploadManifest {
enum ServerMessage {
Ready { code: String },
TooBig { max_size: u64 },
TooLong { max_days: u32 },
TooLong { max_days: u16 },
IncorrectPassword,
Error { details: String },
}
@ -135,9 +150,10 @@ impl From<&Error> for ServerMessage {
Error::TooBig(max_size) => ServerMessage::TooBig {
max_size: *max_size,
},
Error::TooLong => ServerMessage::TooLong {
max_days: store::max_lifetime(),
Error::TooLong(max_days) => ServerMessage::TooLong {
max_days: *max_days,
},
Error::IncorrectPassword => ServerMessage::IncorrectPassword,
_ => ServerMessage::Error {
details: e.to_string(),
},
@ -189,6 +205,9 @@ 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(),
@ -207,9 +226,13 @@ impl Uploader {
let UploadManifest {
files: raw_files,
lifetime,
password,
} = serde_json::from_slice(text.as_bytes())?;
if lifetime > store::max_lifetime() {
return Err(Error::TooLong);
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));
}
info!("Received file list: {} files", raw_files.len());
debug!("{:?}", raw_files);
@ -234,7 +257,14 @@ impl Uploader {
self.bytes_remaining += file.size;
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);
let writer = File::options()
.write(true)
@ -264,25 +294,32 @@ impl Uploader {
modtime,
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();
ctx.spawn(
actix::fut::wrap_future(async move {
debug!("Spawned future to add entry {} to state", storage_filename);
data.write()
state
.file_store
.write()
.await
.add_file(storage_filename, stored_file)
.await
})
.map(|res, u: &mut Self, ctx: &mut Context| match res {
Ok(Ok(())) => ctx.text(
Ok(()) => ctx.text(
serde_json::to_string(&ServerMessage::Ready {
code: u.storage_filename.clone(),
})
.unwrap(),
),
Ok(Err(size)) => u.notify_error_and_cleanup(Error::TooBig(size), ctx),
Err(e) => u.notify_error_and_cleanup(Error::from(e), ctx),
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)
}
}),
);
}
@ -332,17 +369,20 @@ impl Uploader {
"Cleaning up after failed upload of {}",
self.storage_filename
);
let data = self.app_data.clone();
let state = self.app_state.clone();
let filename = self.storage_filename.clone();
ctx.wait(
actix::fut::wrap_future(async move {
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),
);
if let Err(e) = std::fs::remove_file(storage_dir().join(&self.storage_filename)) {
error!("Failed to remove file {}: {}", self.storage_filename, e);
}
}
}

View file

@ -11,55 +11,73 @@
<script src="transbeam.js"></script>
<title>transbeam</title>
</head>
<body class="no_files selecting">
<body class="noscript landing">
<div id="header">
<img src="images/site-icons/transbeam.svg" height="128">
<h1>transbeam</h1>
</div>
<div id="message"></div>
<div id="upload_controls">
<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">
<div id="download" class="section">
<h3 class="section_heading">Download</h3>
<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>
<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">
<h5>(c) 2022 xenofem, MIT licensed</h5>
<h5><a target="_blank" href="https://git.xeno.science/xenofem/transbeam">source</a></h5>

View file

@ -1,6 +1,7 @@
/**
* 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
@ -8,6 +9,14 @@
* 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;
@ -20,9 +29,14 @@ body.error #message {
border-color: #f24;
}
#upload_controls { display: none; }
body.selecting #upload_controls { display: revert; }
body.no_files #upload_controls { display: none; }
#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; }
body.selecting #download_code_container { display: none; }
@ -37,6 +51,3 @@ 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; }

View file

@ -153,17 +153,13 @@ button:disabled, input:disabled + .fake_button, input[type="submit"]:disabled {
margin-top: 10px;
}
#download {
margin-top: 40px;
}
#download_code_input {
input[type="text"], input[type="password"] {
font-size: 18px;
margin-bottom: 10px;
}
#footer {
margin-top: 30px;
.section {
margin: 30px auto;
}
#footer h5 {

View file

@ -11,6 +11,8 @@ let totalBytes = 0;
let maxSize = null;
let uploadPassword;
let messageBox;
let fileInput;
let fileList;
@ -21,15 +23,37 @@ 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');
uploadButton = document.getElementById('upload_button');
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,8 +95,6 @@ document.addEventListener('DOMContentLoaded', () => {
}, 0);
}
});
updateFiles();
});
function updateFiles() {
@ -162,7 +184,11 @@ function sendManifest() {
size: file.size,
modtime: file.lastModified,
}));
socket.send(JSON.stringify({ lifetime, files: fileMetadata }));
socket.send(JSON.stringify({
files: fileMetadata,
lifetime,
password: uploadPassword,
}));
}
function handleMessage(msg) {
@ -202,6 +228,9 @@ 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);
}

View file

@ -1,7 +1,7 @@
const UNITS = [
{ name: 'GB', size: Math.pow(2, 30) },
{ name: 'MB', size: Math.pow(2, 20) },
{ name: 'KB', size: Math.pow(2, 10) },
{ name: 'GB', size: Math.pow(10, 9) },
{ name: 'MB', size: Math.pow(10, 6) },
{ name: 'KB', size: Math.pow(10, 3) },
];
function displaySize(bytes) {