Compare commits

...

5 commits

15 changed files with 542 additions and 270 deletions

3
.gitignore vendored
View file

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

53
API.md
View file

@ -1,53 +0,0 @@
# 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
View file

@ -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",

View file

@ -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"

View file

@ -17,22 +17,33 @@
file for receivers, with zero compression so extraction is quick file for receivers, with zero compression so extraction is quick
- Sanitises filenames, using sensible non-obnoxious defaults that - Sanitises filenames, using sensible non-obnoxious defaults that
should be safe across platforms should be safe across platforms
- Rudimentary password authentication for uploading files
- Fires a laser beam that turns you trans - Fires a laser beam that turns you trans
## 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
@ -57,8 +68,3 @@ git clone https://git.xeno.science/xenofem/transbeam
cd transbeam cd transbeam
cargo run --release cargo run --release
``` ```
## todo
- uploader auth
- downloader auth

View file

@ -3,20 +3,51 @@ 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_files::NamedFile;
use actix_web::{ use actix_web::{
get, middleware::Logger, web, App, HttpRequest, HttpResponse, HttpServer, Responder, get, http::StatusCode, 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,
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);
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct DownloadRequest { struct DownloadRequest {
@ -27,16 +58,15 @@ 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 code = &download.code; let code = &download.code;
if !store::is_valid_storage_code(code) { if !store::is_valid_storage_code(code) {
return Ok(HttpResponse::NotFound().finish()); return download_not_found(req, data);
} }
let data = data.read().await; let info = data.file_store.read().await.lookup_file(code);
let info = data.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,164 @@ async fn handle_download(
} }
.into_response(&req)) .into_response(&req))
} else { } else {
Ok(HttpResponse::NotFound().finish()) download_not_found(req, data)
} }
} }
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")] #[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,
static_dir: static_dir.clone(),
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);
} }
} }

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::{ 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

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::{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);
}
} }
} }

38
static/404.html Normal file
View file

@ -0,0 +1,38 @@
<!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>&lt; 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>

View file

@ -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; }

View file

@ -8,6 +8,11 @@ body {
#header h1 { #header h1 {
margin-top: 5px; margin-top: 5px;
color: black;
}
#header a {
text-decoration: none;
} }
#message { #message {
@ -153,17 +158,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 {

View file

@ -3,63 +3,82 @@
<head> <head>
<meta charset="utf-8"/> <meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" type="text/css" href="transbeam.css"/> <link rel="stylesheet" type="text/css" href="css/transbeam.css"/>
<link rel="stylesheet" type="text/css" href="states.css"/> <link rel="stylesheet" type="text/css" href="css/states.css"/>
<link rel="apple-touch-icon" href="images/site-icons/transbeam-apple.png"/> <link rel="apple-touch-icon" href="images/site-icons/transbeam-apple.png"/>
<link rel="manifest" href="manifest.json"/> <link rel="manifest" href="manifest.json"/>
<script src="util.js"></script> <script src="js/util.js"></script>
<script src="transbeam.js"></script> <script src="js/download.js"></script>
<script src="js/upload.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>

26
static/js/download.js Normal file
View file

@ -0,0 +1,26 @@
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();
});

View file

@ -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();
@ -47,32 +71,6 @@ document.addEventListener('DOMContentLoaded', () => {
downloadCodeContainer.addEventListener('mouseleave', () => { downloadCodeContainer.addEventListener('mouseleave', () => {
downloadCodeContainer.className = ''; 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() { function updateFiles() {
@ -162,7 +160,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 +204,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);
} }

View file

@ -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) {