Compare commits
2 commits
1b38a2ee30
...
8b001911f7
Author | SHA1 | Date | |
---|---|---|---|
xenofem | 8b001911f7 | ||
xenofem | 2e29825a3d |
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -1778,7 +1778,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "transbeam"
|
name = "transbeam"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"actix",
|
"actix",
|
||||||
"actix-files",
|
"actix-files",
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "transbeam"
|
name = "transbeam"
|
||||||
version = "0.3.0"
|
version = "0.4.0"
|
||||||
authors = ["xenofem <xenofem@xeno.science>"]
|
authors = ["xenofem <xenofem@xeno.science>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
|
@ -51,10 +51,6 @@ async def send(paths, host, password, lifetime, collection_name=None):
|
||||||
loader = file_loader([(paths[i], fileMetadata[i]["size"]) for i in range(len(paths))])
|
loader = file_loader([(paths[i], fileMetadata[i]["size"]) for i in range(len(paths))])
|
||||||
for data in loader:
|
for data in loader:
|
||||||
await ws.send(data)
|
await ws.send(data)
|
||||||
resp = await ws.recv()
|
|
||||||
if resp != "ack":
|
|
||||||
tqdm.write("unexpected response: {}".format(resp))
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="Upload files to transbeam")
|
parser = argparse.ArgumentParser(description="Upload files to transbeam")
|
||||||
parser.add_argument("-l", "--lifetime", type=int, default=7, help="Lifetime in days for files (default 7)")
|
parser.add_argument("-l", "--lifetime", type=int, default=7, help="Lifetime in days for files (default 7)")
|
||||||
|
|
58
src/main.rs
58
src/main.rs
|
@ -4,13 +4,13 @@ mod store;
|
||||||
mod upload;
|
mod upload;
|
||||||
mod zip;
|
mod zip;
|
||||||
|
|
||||||
use std::{fmt::Debug, path::PathBuf, str::FromStr, ops::Deref};
|
use std::{fmt::Debug, ops::Deref, path::PathBuf, str::FromStr};
|
||||||
|
|
||||||
use actix_http::StatusCode;
|
use actix_http::StatusCode;
|
||||||
use actix_session::{SessionMiddleware, storage::CookieSessionStore, Session};
|
use actix_session::{storage::CookieSessionStore, Session, SessionMiddleware};
|
||||||
use actix_web::{
|
use actix_web::{
|
||||||
error::InternalError, get, middleware::Logger, post, web, App, HttpRequest, HttpResponse,
|
cookie, error::InternalError, get, middleware::Logger, post, web, App, HttpRequest,
|
||||||
HttpServer, Responder, cookie,
|
HttpResponse, HttpServer, Responder,
|
||||||
};
|
};
|
||||||
use actix_web_actors::ws;
|
use actix_web_actors::ws;
|
||||||
use argon2::{Argon2, PasswordVerifier};
|
use argon2::{Argon2, PasswordVerifier};
|
||||||
|
@ -19,7 +19,7 @@ use bytesize::ByteSize;
|
||||||
use log::{error, warn};
|
use log::{error, warn};
|
||||||
use password_hash::PasswordHashString;
|
use password_hash::PasswordHashString;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use state::{StateDb, prelude::SizedFile};
|
use state::{prelude::SizedFile, StateDb};
|
||||||
use store::{StoredFile, StoredFiles};
|
use store::{StoredFile, StoredFiles};
|
||||||
use tokio::fs::File;
|
use tokio::fs::File;
|
||||||
|
|
||||||
|
@ -93,19 +93,24 @@ struct AdminPage<'a> {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/admin")]
|
#[get("/admin")]
|
||||||
async fn admin_panel(data: web::Data<AppData>, session: Session) -> actix_web::Result<HttpResponse> {
|
async fn admin_panel(
|
||||||
|
data: web::Data<AppData>,
|
||||||
|
session: Session,
|
||||||
|
) -> actix_web::Result<HttpResponse> {
|
||||||
if let Some(true) = session.get::<bool>("admin")? {
|
if let Some(true) = session.get::<bool>("admin")? {
|
||||||
Ok(AdminPage {
|
Ok(AdminPage {
|
||||||
cachebuster: data.config.cachebuster.clone(),
|
cachebuster: data.config.cachebuster.clone(),
|
||||||
base_url: data.config.base_url.clone(),
|
base_url: data.config.base_url.clone(),
|
||||||
stored_files: data.state.read().await.deref(),
|
stored_files: data.state.read().await.deref(),
|
||||||
}.to_response())
|
}
|
||||||
|
.to_response())
|
||||||
} else {
|
} else {
|
||||||
Ok(SignedOutAdminPage {
|
Ok(SignedOutAdminPage {
|
||||||
cachebuster: data.config.cachebuster.clone(),
|
cachebuster: data.config.cachebuster.clone(),
|
||||||
base_url: data.config.base_url.clone(),
|
base_url: data.config.base_url.clone(),
|
||||||
incorrect_password: false,
|
incorrect_password: false,
|
||||||
}.to_response())
|
}
|
||||||
|
.to_response())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,14 +120,26 @@ struct AdminPanelSignin {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/admin")]
|
#[post("/admin")]
|
||||||
async fn admin_signin(req: HttpRequest, data: web::Data<AppData>, form: web::Form<AdminPanelSignin>, session: Session) -> actix_web::Result<HttpResponse> {
|
async fn admin_signin(
|
||||||
if Argon2::default().verify_password(form.password.as_bytes(), &data.config.admin_password_hash.password_hash()).is_ok() {
|
req: HttpRequest,
|
||||||
|
data: web::Data<AppData>,
|
||||||
|
form: web::Form<AdminPanelSignin>,
|
||||||
|
session: Session,
|
||||||
|
) -> actix_web::Result<HttpResponse> {
|
||||||
|
if Argon2::default()
|
||||||
|
.verify_password(
|
||||||
|
form.password.as_bytes(),
|
||||||
|
&data.config.admin_password_hash.password_hash(),
|
||||||
|
)
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
session.insert("admin", true)?;
|
session.insert("admin", true)?;
|
||||||
Ok(AdminPage {
|
Ok(AdminPage {
|
||||||
cachebuster: data.config.cachebuster.clone(),
|
cachebuster: data.config.cachebuster.clone(),
|
||||||
base_url: data.config.base_url.clone(),
|
base_url: data.config.base_url.clone(),
|
||||||
stored_files: data.state.read().await.deref(),
|
stored_files: data.state.read().await.deref(),
|
||||||
}.to_response())
|
}
|
||||||
|
.to_response())
|
||||||
} else {
|
} else {
|
||||||
let ip_addr = get_ip_addr(&req, data.config.reverse_proxy);
|
let ip_addr = get_ip_addr(&req, data.config.reverse_proxy);
|
||||||
log_auth_failure(&ip_addr);
|
log_auth_failure(&ip_addr);
|
||||||
|
@ -130,7 +147,8 @@ async fn admin_signin(req: HttpRequest, data: web::Data<AppData>, form: web::For
|
||||||
cachebuster: data.config.cachebuster.clone(),
|
cachebuster: data.config.cachebuster.clone(),
|
||||||
base_url: data.config.base_url.clone(),
|
base_url: data.config.base_url.clone(),
|
||||||
incorrect_password: true,
|
incorrect_password: true,
|
||||||
}.to_response();
|
}
|
||||||
|
.to_response();
|
||||||
*resp.status_mut() = StatusCode::FORBIDDEN;
|
*resp.status_mut() = StatusCode::FORBIDDEN;
|
||||||
|
|
||||||
Err(InternalError::from_response("Incorrect password", resp).into())
|
Err(InternalError::from_response("Incorrect password", resp).into())
|
||||||
|
@ -375,12 +393,13 @@ async fn main() -> std::io::Result<()> {
|
||||||
let admin_password_hash: PasswordHashString = env_or_panic("TRANSBEAM_ADMIN_PASSWORD_HASH");
|
let admin_password_hash: PasswordHashString = env_or_panic("TRANSBEAM_ADMIN_PASSWORD_HASH");
|
||||||
|
|
||||||
let cookie_secret_base64: String = env_or_panic("TRANSBEAM_COOKIE_SECRET");
|
let cookie_secret_base64: String = env_or_panic("TRANSBEAM_COOKIE_SECRET");
|
||||||
let cookie_key = cookie::Key::from(
|
let cookie_key =
|
||||||
&base64::decode(&cookie_secret_base64)
|
cookie::Key::from(&base64::decode(&cookie_secret_base64).unwrap_or_else(|_| {
|
||||||
.unwrap_or_else(
|
panic!(
|
||||||
|_| panic!("Value {} for TRANSBEAM_COOKIE_SECRET is not valid base64", cookie_secret_base64)
|
"Value {} for TRANSBEAM_COOKIE_SECRET is not valid base64",
|
||||||
|
cookie_secret_base64
|
||||||
)
|
)
|
||||||
);
|
}));
|
||||||
|
|
||||||
let state_file: PathBuf = match std::env::var("TRANSBEAM_STATE_FILE") {
|
let state_file: PathBuf = match std::env::var("TRANSBEAM_STATE_FILE") {
|
||||||
Ok(v) => v
|
Ok(v) => v
|
||||||
|
@ -424,7 +443,10 @@ async fn main() -> std::io::Result<()> {
|
||||||
} else {
|
} else {
|
||||||
Logger::default()
|
Logger::default()
|
||||||
})
|
})
|
||||||
.wrap(SessionMiddleware::new(CookieSessionStore::default(), cookie_key.clone()))
|
.wrap(SessionMiddleware::new(
|
||||||
|
CookieSessionStore::default(),
|
||||||
|
cookie_key.clone(),
|
||||||
|
))
|
||||||
.service(index)
|
.service(index)
|
||||||
.service(handle_download)
|
.service(handle_download)
|
||||||
.service(download_info)
|
.service(download_info)
|
||||||
|
|
11
src/state.rs
11
src/state.rs
|
@ -13,12 +13,11 @@ pub mod prelude {
|
||||||
fn size(&self) -> u64;
|
fn size(&self) -> u64;
|
||||||
|
|
||||||
fn formatted_size(&self) -> String {
|
fn formatted_size(&self) -> String {
|
||||||
bytesize::to_string(self.size(), false).replace(" ", "")
|
bytesize::to_string(self.size(), false).replace(' ', "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
mod v0;
|
mod v0;
|
||||||
|
|
||||||
pub mod v1 {
|
pub mod v1 {
|
||||||
|
@ -52,7 +51,9 @@ pub mod v1 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
impl SizedFile for UploadedFile {
|
impl SizedFile for UploadedFile {
|
||||||
fn size(&self) -> u64 { self.size }
|
fn size(&self) -> u64 {
|
||||||
|
self.size
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
@ -83,7 +84,9 @@ pub mod v1 {
|
||||||
pub contents: Option<FileSet>,
|
pub contents: Option<FileSet>,
|
||||||
}
|
}
|
||||||
impl SizedFile for StoredFile {
|
impl SizedFile for StoredFile {
|
||||||
fn size(&self) -> u64 { self.size }
|
fn size(&self) -> u64 {
|
||||||
|
self.size
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
|
|
@ -36,7 +36,7 @@ async fn is_valid_entry(key: &str, info: &StoredFile, storage_dir: &Path) -> boo
|
||||||
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!(
|
||||||
|
@ -108,7 +108,11 @@ impl crate::AppData {
|
||||||
/// 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.
|
||||||
pub async fn add_file(&self, key: String, entry: StoredFileWithPassword) -> Result<(), FileAddError> {
|
pub async fn add_file(
|
||||||
|
&self,
|
||||||
|
key: String,
|
||||||
|
entry: StoredFileWithPassword,
|
||||||
|
) -> Result<(), FileAddError> {
|
||||||
let mut store = self.state.write().await;
|
let mut store = self.state.write().await;
|
||||||
if store.full(self.config.max_storage_size) {
|
if store.full(self.config.max_storage_size) {
|
||||||
return Err(FileAddError::Full);
|
return Err(FileAddError::Full);
|
||||||
|
|
|
@ -211,10 +211,6 @@ impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for Uploader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ack(ctx: &mut Context) {
|
|
||||||
ctx.text("ack");
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
||||||
|
@ -350,13 +346,10 @@ impl Uploader {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ws::Message::Binary(data) | ws::Message::Continuation(Item::Last(data)) => {
|
ws::Message::Binary(data)
|
||||||
let result = self.handle_data(data)?;
|
| ws::Message::Continuation(Item::FirstBinary(data))
|
||||||
ack(ctx);
|
| ws::Message::Continuation(Item::Continue(data))
|
||||||
return Ok(result);
|
| ws::Message::Continuation(Item::Last(data)) => {
|
||||||
}
|
|
||||||
ws::Message::Continuation(Item::FirstBinary(data))
|
|
||||||
| ws::Message::Continuation(Item::Continue(data)) => {
|
|
||||||
return self.handle_data(data);
|
return self.handle_data(data);
|
||||||
}
|
}
|
||||||
ws::Message::Close(reason) => {
|
ws::Message::Close(reason) => {
|
||||||
|
|
|
@ -2,6 +2,8 @@ const FILE_CHUNK_SIZE = 16384;
|
||||||
const MAX_FILES = 256;
|
const MAX_FILES = 256;
|
||||||
const SAMPLE_WINDOW = 100;
|
const SAMPLE_WINDOW = 100;
|
||||||
const STALL_THRESHOLD = 1000;
|
const STALL_THRESHOLD = 1000;
|
||||||
|
const MAX_WS_BUFFER = 1048576;
|
||||||
|
const WS_BUFFER_DELAY = 10;
|
||||||
|
|
||||||
let files = [];
|
let files = [];
|
||||||
|
|
||||||
|
@ -204,47 +206,42 @@ function sendManifest() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(msg) {
|
function handleMessage(msg) {
|
||||||
if (bytesSent === 0) {
|
if (bytesSent > 0) {
|
||||||
let reply;
|
console.warn('Received unexpected message from server during upload', msg.data);
|
||||||
try {
|
return;
|
||||||
reply = JSON.parse(msg.data);
|
}
|
||||||
} catch (error) {
|
|
||||||
socket.close();
|
|
||||||
displayError('Received an invalid response from the server');
|
|
||||||
console.error(error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (reply.type === 'ready') {
|
|
||||||
downloadCode.textContent = reply.code;
|
|
||||||
updateProgress();
|
|
||||||
document.body.className = 'uploading';
|
|
||||||
sendData();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// we're going to display a more useful error message
|
let reply;
|
||||||
socket.removeEventListener('close', handleClose);
|
try {
|
||||||
|
reply = JSON.parse(msg.data);
|
||||||
|
} catch (error) {
|
||||||
socket.close();
|
socket.close();
|
||||||
if (reply.type === 'too_big') {
|
displayError('Received an invalid response from the server');
|
||||||
maxSize = reply.max_size;
|
console.error(error);
|
||||||
updateFiles();
|
return;
|
||||||
} else if (reply.type === 'too_long') {
|
}
|
||||||
updateMaxLifetime(reply.max_lifetime);
|
if (reply.type === 'ready') {
|
||||||
displayError(`The maximum retention time for uploads is ${reply.max_lifetime} days`);
|
downloadCode.textContent = reply.code;
|
||||||
} else if (reply.type === 'incorrect_password') {
|
updateProgress();
|
||||||
messageBox.textContent = ('Incorrect password');
|
document.body.className = 'uploading';
|
||||||
document.body.className = 'error landing';
|
sendData();
|
||||||
} else if (reply.type === 'error') {
|
return;
|
||||||
displayError(reply.details);
|
}
|
||||||
}
|
|
||||||
} else {
|
// we're going to display a more useful error message
|
||||||
if (msg.data === 'ack') {
|
socket.removeEventListener('close', handleClose);
|
||||||
sendData();
|
socket.close();
|
||||||
} else {
|
if (reply.type === 'too_big') {
|
||||||
console.error('Received unexpected message from server instead of ack', msg.data);
|
maxSize = reply.max_size;
|
||||||
displayError();
|
updateFiles();
|
||||||
socket.close();
|
} else if (reply.type === 'too_long') {
|
||||||
}
|
updateMaxLifetime(reply.max_lifetime);
|
||||||
|
displayError(`The maximum retention time for uploads is ${reply.max_lifetime} days`);
|
||||||
|
} else if (reply.type === 'incorrect_password') {
|
||||||
|
messageBox.textContent = ('Incorrect password');
|
||||||
|
document.body.className = 'error landing';
|
||||||
|
} else if (reply.type === 'error') {
|
||||||
|
displayError(reply.details);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -262,32 +259,43 @@ function updateMaxLifetime(lifetime) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendData() {
|
function sendData() {
|
||||||
if (fileIndex >= files.length) {
|
if (socket.readyState !== 1) {
|
||||||
finishSending();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const currentFile = files[fileIndex];
|
|
||||||
if (byteIndex < currentFile.size) {
|
|
||||||
const endpoint = Math.min(byteIndex+FILE_CHUNK_SIZE, currentFile.size);
|
|
||||||
const data = currentFile.slice(byteIndex, endpoint);
|
|
||||||
socket.send(data);
|
|
||||||
byteIndex = endpoint;
|
|
||||||
bytesSent += data.size;
|
|
||||||
|
|
||||||
// It's ok if the monotonically increasing fields like
|
while (true) {
|
||||||
// percentage are updating super quickly, but it's awkward for
|
if (fileIndex >= files.length) {
|
||||||
// rate and ETA
|
finishSending();
|
||||||
const now = Date.now() / 1000;
|
return;
|
||||||
if (timestamps.length === 0 || now - timestamps.at(-1)[0] > 1) {
|
|
||||||
timestamps.push([now, bytesSent]);
|
|
||||||
if (timestamps.length > SAMPLE_WINDOW) { timestamps.shift(); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateProgress();
|
if (socket.bufferedAmount >= MAX_WS_BUFFER) {
|
||||||
} else {
|
setTimeout(sendData, WS_BUFFER_DELAY);
|
||||||
fileIndex += 1;
|
return;
|
||||||
byteIndex = 0;
|
}
|
||||||
sendData();
|
|
||||||
|
const currentFile = files[fileIndex];
|
||||||
|
if (byteIndex < currentFile.size) {
|
||||||
|
const endpoint = Math.min(byteIndex+FILE_CHUNK_SIZE, currentFile.size);
|
||||||
|
const data = currentFile.slice(byteIndex, endpoint);
|
||||||
|
socket.send(data);
|
||||||
|
byteIndex = endpoint;
|
||||||
|
bytesSent += data.size;
|
||||||
|
|
||||||
|
// It's ok if the monotonically increasing fields like
|
||||||
|
// percentage are updating super quickly, but it's awkward for
|
||||||
|
// rate and ETA
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
if (timestamps.length === 0 || now - timestamps.at(-1)[0] > 1) {
|
||||||
|
timestamps.push([now, bytesSent]);
|
||||||
|
if (timestamps.length > SAMPLE_WINDOW) { timestamps.shift(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgress();
|
||||||
|
} else {
|
||||||
|
fileIndex += 1;
|
||||||
|
byteIndex = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue