Compare commits

..

No commits in common. "bfe7fcde9948f16afbdb1b456b0ff27dd0c2addc" and "86bdac20af2d11b6cece36368971fb92e535bd7d" have entirely different histories.

7 changed files with 84 additions and 158 deletions

View file

@ -10,7 +10,6 @@ use actix_web::{
}; };
use actix_web_actors::ws; use actix_web_actors::ws;
use log::error; use log::error;
use serde::Deserialize;
use store::FileStore; use store::FileStore;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@ -18,25 +17,20 @@ const APP_NAME: &str = "transbeam";
type AppData = web::Data<RwLock<FileStore>>; type AppData = web::Data<RwLock<FileStore>>;
#[derive(Deserialize)] #[get("/download/{file_code}")]
struct DownloadRequest {
code: String,
}
#[get("/download")]
async fn handle_download( async fn handle_download(
req: HttpRequest, req: HttpRequest,
download: web::Query<DownloadRequest>, path: web::Path<String>,
data: AppData, data: AppData,
) -> actix_web::Result<HttpResponse> { ) -> actix_web::Result<HttpResponse> {
let code = &download.code; let file_code = path.into_inner();
if !store::is_valid_storage_code(code) { if !store::is_valid_storage_code(&file_code) {
return Ok(HttpResponse::NotFound().finish()); return Ok(HttpResponse::NotFound().finish());
} }
let data = data.read().await; let data = data.read().await;
let info = data.lookup_file(code); let info = data.lookup_file(&file_code);
if let Some(info) = info { if let Some(info) = info {
let storage_path = store::storage_dir().join(code); let storage_path = store::storage_dir().join(file_code);
let file = File::open(&storage_path)?; let file = File::open(&storage_path)?;
Ok(download::DownloadingFile { Ok(download::DownloadingFile {
file, file,
@ -61,9 +55,8 @@ async fn main() -> std::io::Result<()> {
let data: AppData = web::Data::new(RwLock::new(FileStore::load().await?)); let data: AppData = web::Data::new(RwLock::new(FileStore::load().await?));
start_reaper(data.clone()); start_reaper(data.clone());
let static_dir = PathBuf::from( let static_dir =
std::env::var("TRANSBEAM_STATIC_DIR").unwrap_or_else(|_| String::from("static")), PathBuf::from(std::env::var("TRANSBEAM_STATIC_DIR").unwrap_or_else(|_| String::from("static")));
);
let port = std::env::var("TRANSBEAM_PORT") let port = std::env::var("TRANSBEAM_PORT")
.ok() .ok()
.and_then(|p| p.parse::<u16>().ok()) .and_then(|p| p.parse::<u16>().ok())

View file

@ -1,10 +1,7 @@
use std::{collections::HashMap, io::ErrorKind, path::PathBuf, str::FromStr}; use std::{collections::HashMap, io::ErrorKind, path::PathBuf, str::FromStr};
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rand::{ use rand::{distributions::{Alphanumeric, DistString}, thread_rng, Rng};
distributions::{Alphanumeric, DistString},
thread_rng, Rng,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use time::OffsetDateTime; use time::OffsetDateTime;
use tokio::{ use tokio::{
@ -15,9 +12,9 @@ use tokio::{
const STATE_FILE_NAME: &str = "files.json"; const STATE_FILE_NAME: &str = "files.json";
const DEFAULT_STORAGE_DIR: &str = "storage"; const DEFAULT_STORAGE_DIR: &str = "storage";
const DEFAULT_MAX_LIFETIME: u32 = 30; const DEFAULT_MAX_LIFETIME: u32 = 30;
const GIGA: u64 = 1024 * 1024 * 1024; const GIGA: u64 = 1024*1024*1024;
const DEFAULT_MAX_UPLOAD_SIZE: u64 = 16 * GIGA; const DEFAULT_MAX_UPLOAD_SIZE: u64 = 16*GIGA;
const DEFAULT_MAX_STORAGE_SIZE: u64 = 64 * GIGA; const DEFAULT_MAX_STORAGE_SIZE: u64 = 64*GIGA;
pub fn gen_storage_code() -> String { pub fn gen_storage_code() -> String {
if std::env::var("TRANSBEAM_MNEMONIC_CODES").as_deref() == Ok("false") { if std::env::var("TRANSBEAM_MNEMONIC_CODES").as_deref() == Ok("false") {
@ -28,23 +25,15 @@ pub fn gen_storage_code() -> String {
} }
pub fn is_valid_storage_code(s: &str) -> bool { pub fn is_valid_storage_code(s: &str) -> bool {
s.as_bytes() s.as_bytes().iter().all(|c| c.is_ascii_alphanumeric() || c == &b'-')
.iter()
.all(|c| c.is_ascii_alphanumeric() || c == &b'-')
} }
pub(crate) fn storage_dir() -> PathBuf { pub(crate) fn storage_dir() -> PathBuf {
PathBuf::from( PathBuf::from(std::env::var("TRANSBEAM_STORAGE_DIR").unwrap_or_else(|_| String::from(DEFAULT_STORAGE_DIR)))
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 { fn parse_env_var<T: FromStr>(var: &str, default: T) -> T {
std::env::var(var) std::env::var(var).ok().and_then(|val| val.parse::<T>().ok()).unwrap_or(default)
.ok()
.and_then(|val| val.parse::<T>().ok())
.unwrap_or(default)
} }
pub(crate) fn max_lifetime() -> u32 { pub(crate) fn max_lifetime() -> u32 {
@ -205,9 +194,7 @@ impl FileStore {
) -> std::io::Result<Result<(), u64>> { ) -> std::io::Result<Result<(), u64>> {
let remaining_size = max_total_size().saturating_sub(self.total_size()); let remaining_size = max_total_size().saturating_sub(self.total_size());
let allowed_size = std::cmp::min(remaining_size, max_single_size()); let allowed_size = std::cmp::min(remaining_size, max_single_size());
if file.size > allowed_size { if file.size > allowed_size { return Ok(Err(allowed_size)); }
return Ok(Err(allowed_size));
}
self.0.insert(key, file); self.0.insert(key, file);
self.save().await.map(Ok) self.save().await.map(Ok)
} }
@ -225,7 +212,7 @@ impl FileStore {
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::replace(&mut self.0, HashMap::new()).into_iter() {
if file.expiry > now { if file.expiry > now {
self.0.insert(key, file); self.0.insert(key, file);
} else { } else {

View file

@ -9,7 +9,7 @@ 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::store::{storage_dir, StoredFile, self};
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] =
@ -132,15 +132,9 @@ enum ServerMessage {
impl From<&Error> for ServerMessage { impl From<&Error> for ServerMessage {
fn from(e: &Error) -> Self { fn from(e: &Error) -> Self {
match e { match e {
Error::TooBig(max_size) => ServerMessage::TooBig { Error::TooBig(max_size) => ServerMessage::TooBig { max_size: *max_size },
max_size: *max_size, Error::TooLong => ServerMessage::TooLong { max_days: store::max_lifetime() },
}, _ => ServerMessage::Error { details: e.to_string() },
Error::TooLong => ServerMessage::TooLong {
max_days: store::max_lifetime(),
},
_ => ServerMessage::Error {
details: e.to_string(),
},
} }
} }
} }
@ -197,17 +191,18 @@ impl Uploader {
self.cleanup_after_error(ctx); self.cleanup_after_error(ctx);
} }
fn handle_message(&mut self, msg: ws::Message, ctx: &mut Context) -> Result<bool, Error> { fn handle_message(
&mut self,
msg: ws::Message,
ctx: &mut Context,
) -> Result<bool, Error> {
trace!("Websocket message: {:?}", msg); trace!("Websocket message: {:?}", msg);
match msg { match msg {
ws::Message::Text(text) => { ws::Message::Text(text) => {
if self.writer.is_some() { if self.writer.is_some() {
return Err(Error::UnexpectedMessageType); return Err(Error::UnexpectedMessageType);
} }
let UploadManifest { let UploadManifest { files: raw_files, lifetime, } = serde_json::from_slice(text.as_bytes())?;
files: raw_files,
lifetime,
} = serde_json::from_slice(text.as_bytes())?;
if lifetime > store::max_lifetime() { if lifetime > store::max_lifetime() {
return Err(Error::TooLong); return Err(Error::TooLong);
} }
@ -240,15 +235,19 @@ impl Uploader {
.write(true) .write(true)
.create_new(true) .create_new(true)
.open(&storage_path)?; .open(&storage_path)?;
let (writer, name, size, modtime): (Box<dyn Write>, _, _, _) = if files.len() > 1 { let (writer, name, size, modtime): (Box<dyn Write>,_,_,_) = if files.len() > 1 {
info!("Wrapping in zipfile generator"); info!("Wrapping in zipfile generator");
let now = OffsetDateTime::now_utc(); let now = OffsetDateTime::now_utc();
let zip_writer = super::zip::ZipGenerator::new(files, writer); let zip_writer = super::zip::ZipGenerator::new(files, writer);
let size = zip_writer.total_size(); let size = zip_writer.total_size();
let download_filename = super::APP_NAME.to_owned() let download_filename =
+ &now.format(FILENAME_DATE_FORMAT).unwrap() super::APP_NAME.to_owned() + &now.format(FILENAME_DATE_FORMAT).unwrap() + ".zip";
+ ".zip"; (
(Box::new(zip_writer), download_filename, size, now) Box::new(zip_writer),
download_filename,
size,
now,
)
} else { } else {
( (
Box::new(writer), Box::new(writer),
@ -262,29 +261,23 @@ impl Uploader {
name, name,
size, size,
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 data = self.app_data.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() data.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| {
.map(|res, u: &mut Self, ctx: &mut Context| match res { match res {
Ok(Ok(())) => ctx.text( Ok(Ok(())) => ctx.text(serde_json::to_string(&ServerMessage::Ready { code: u.storage_filename.clone() }).unwrap()),
serde_json::to_string(&ServerMessage::Ready {
code: u.storage_filename.clone(),
})
.unwrap(),
),
Ok(Err(size)) => u.notify_error_and_cleanup(Error::TooBig(size), ctx), Ok(Err(size)) => u.notify_error_and_cleanup(Error::TooBig(size), ctx),
Err(e) => u.notify_error_and_cleanup(Error::from(e), ctx), Err(e) => u.notify_error_and_cleanup(Error::from(e), ctx)
}), }
); }));
} }
ws::Message::Binary(data) | ws::Message::Continuation(Item::Last(data)) => { ws::Message::Binary(data) | ws::Message::Continuation(Item::Last(data)) => {
let result = self.handle_data(data)?; let result = self.handle_data(data)?;

View file

@ -16,6 +16,9 @@
<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>
<noscript>This page requires Javascript :(</noscript>
<div id="message"></div> <div id="message"></div>
<div id="upload_controls"> <div id="upload_controls">
<div> <div>
@ -33,11 +36,11 @@
</label> </label>
</div> </div>
</div> </div>
<div id="download_code_container"> <div id="download_link_container">
<div id="download_code_main"> <div id="download_link_main">
<div>Download code: <span id="download_code"></span></div><div class="copy_button"></div> <div>Download link: <span id="download_link"></span></div><div class="copy_button"></div>
</div> </div>
<div id="copied_message">Link copied!</div> <div id="copied_message">Copied!</div>
</div> </div>
<div id="progress_container"> <div id="progress_container">
<div id="progress"></div> <div id="progress"></div>
@ -49,17 +52,6 @@
<input type="file" multiple id="file_input"/> <input type="file" multiple id="file_input"/>
<span class="fake_button" id="file_input_message">Select files to upload...</span> <span class="fake_button" id="file_input_message">Select files to upload...</span>
</label> </label>
<div id="download">
<form id="download_form" action="download" method="get">
<div>
<label>
<h4>Or enter a code to download files:</h4>
<input type="text" id="download_code_input" name="code" placeholder="Download code"/>
</label>
</div>
<input id="download_button" type="submit" value="Download"/>
</form>
</div>
<div id="footer"> <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>

View file

@ -24,7 +24,7 @@ body.error #message {
body.selecting #upload_controls { display: revert; } body.selecting #upload_controls { display: revert; }
body.no_files #upload_controls { display: none; } body.no_files #upload_controls { display: none; }
body.selecting #download_code_container { display: none; } body.selecting #download_link_container { display: none; }
#progress_container { display: none; } #progress_container { display: none; }
body.uploading #progress_container { display: revert; } body.uploading #progress_container { display: revert; }
@ -37,6 +37,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

@ -31,7 +31,7 @@ body {
background-size: 0; background-size: 0;
} }
#download_code_container { #download_link_container {
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
padding: 10px; padding: 10px;
@ -41,11 +41,11 @@ body {
position: relative; position: relative;
} }
#download_code_container:hover { #download_link_container:hover {
border-color: #777; border-color: #777;
} }
#download_code_main { #download_link_main {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
@ -60,7 +60,7 @@ body {
mask-repeat: no-repeat; mask-repeat: no-repeat;
} }
#download_code_container:hover .copy_button { #download_link_container:hover .copy_button {
background-color: #000; background-color: #000;
} }
@ -74,10 +74,10 @@ body {
height: fit-content; height: fit-content;
display: none; display: none;
} }
#download_code_container.copied #copied_message { #download_link_container.copied #copied_message {
display: revert; display: revert;
} }
#download_code_container.copied #download_code_main { #download_link_container.copied #download_link_main {
visibility: hidden; visibility: hidden;
} }
@ -128,7 +128,7 @@ input[type="file"] {
display: none; display: none;
} }
button, .fake_button, input[type="submit"] { button, .fake_button {
font-size: 18px; font-size: 18px;
color: #000; color: #000;
background-color: #ccc; background-color: #ccc;
@ -138,11 +138,11 @@ button, .fake_button, input[type="submit"] {
cursor: pointer; cursor: pointer;
} }
button:hover, .fake_button:hover, input[type="submit"]:hover { button:hover, .fake_button:hover {
background-color: #aaa; background-color: #aaa;
} }
button:disabled, input:disabled + .fake_button, input[type="submit"]:disabled { button:disabled, input:disabled + .fake_button {
color: #666; color: #666;
background-color: #eee; background-color: #eee;
border-color: #ddd; border-color: #ddd;
@ -153,15 +153,6 @@ button:disabled, input:disabled + .fake_button, input[type="submit"]:disabled {
margin-top: 10px; margin-top: 10px;
} }
#download {
margin-top: 40px;
}
#download_code_input {
font-size: 18px;
margin-bottom: 10px;
}
#footer { #footer {
margin-top: 30px; margin-top: 30px;
} }

View file

@ -16,7 +16,7 @@ let fileInput;
let fileList; let fileList;
let uploadButton; let uploadButton;
let lifetimeInput; let lifetimeInput;
let downloadCode; let downloadLink;
let progress; let progress;
let progressBar; let progressBar;
@ -26,7 +26,7 @@ document.addEventListener('DOMContentLoaded', () => {
fileList = document.getElementById('file_list'); fileList = document.getElementById('file_list');
uploadButton = document.getElementById('upload'); uploadButton = document.getElementById('upload');
lifetimeInput = document.getElementById('lifetime'); lifetimeInput = document.getElementById('lifetime');
downloadCode = document.getElementById('download_code'); downloadLink = document.getElementById('download_link');
progress = document.getElementById('progress'); progress = document.getElementById('progress');
progressBar = document.getElementById('progress_bar'); progressBar = document.getElementById('progress_bar');
@ -38,38 +38,13 @@ document.addEventListener('DOMContentLoaded', () => {
uploadButton.addEventListener('click', beginUpload); uploadButton.addEventListener('click', beginUpload);
const downloadCodeContainer = document.getElementById('download_code_container'); const downloadLinkContainer = document.getElementById('download_link_container');
downloadCodeContainer.addEventListener('click', () => { downloadLinkContainer.addEventListener('click', () => {
const downloadUrl = new URL(`download?code=${downloadCode.textContent}`, window.location); navigator.clipboard.writeText(downloadLink.textContent);
navigator.clipboard.writeText(downloadUrl.href); downloadLinkContainer.className = 'copied';
downloadCodeContainer.className = 'copied';
}); });
downloadCodeContainer.addEventListener('mouseleave', () => { downloadLinkContainer.addEventListener('mouseleave', () => {
downloadCodeContainer.className = ''; downloadLinkContainer.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(); updateFiles();
@ -147,9 +122,7 @@ function beginUpload() {
byteIndex = 0; byteIndex = 0;
bytesSent = 0; bytesSent = 0;
let websocketUrl = new URL('upload', window.location); socket = new WebSocket(`${window.location.protocol === 'http:' ? 'ws' : 'wss'}://${window.location.host}/upload`);
websocketUrl.protocol = (window.location.protocol === 'http:') ? 'ws:' : 'wss:';
socket = new WebSocket(websocketUrl);
socket.addEventListener('open', sendManifest); socket.addEventListener('open', sendManifest);
socket.addEventListener('message', handleMessage); socket.addEventListener('message', handleMessage);
socket.addEventListener('close', handleClose); socket.addEventListener('close', handleClose);
@ -177,7 +150,7 @@ function handleMessage(msg) {
return; return;
} }
if (reply.type === 'ready') { if (reply.type === 'ready') {
downloadCode.textContent = reply.code; downloadLink.textContent = `${window.location.origin}/download/${reply.code}`;
updateProgress(); updateProgress();
document.body.className = 'uploading'; document.body.className = 'uploading';
sendData(); sendData();
@ -279,7 +252,7 @@ function handleClose(e) {
function finishSending() { function finishSending() {
if (socket.bufferedAmount > 0) { if (socket.bufferedAmount > 0) {
setTimeout(finishSending, 1000); window.setTimeout(finishSending, 1000);
return; return;
} }
socket.close(); socket.close();