Compare commits

..

2 commits

5 changed files with 306 additions and 171 deletions

View file

@ -22,8 +22,6 @@ enum Error {
Parse(#[from] serde_json::Error),
#[error("Error writing to stored file")]
Storage(#[from] std::io::Error),
#[error("Time formatting error")]
TimeFormat(#[from] time::error::Format),
#[error("Duplicate filename could not be deduplicated")]
DuplicateFilename,
#[error("This message type was not expected at this stage")]
@ -45,8 +43,7 @@ enum Error {
impl Error {
fn close_code(&self) -> CloseCode {
match self {
Self::Storage(_)
| Self::TimeFormat(_) => CloseCode::Error,
Self::Storage(_) => CloseCode::Error,
Self::Parse(_)
| Self::UnexpectedMessageType
| Self::ClosedEarly(_)
@ -245,7 +242,7 @@ impl Uploader {
let zip_writer = super::zip::ZipGenerator::new(files, writer);
let size = zip_writer.total_size();
let download_filename =
super::APP_NAME.to_owned() + &now.format(FILENAME_DATE_FORMAT)? + ".zip";
super::APP_NAME.to_owned() + &now.format(FILENAME_DATE_FORMAT).unwrap() + ".zip";
(
Box::new(zip_writer),
download_filename,

View file

@ -4,21 +4,27 @@
<meta charset="utf-8"/>
<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="states.css"/>
<link rel="apple-touch-icon" href="images/site-icons/transbeam-apple.png"/>
<link rel="manifest" href="manifest.json"/>
<script src="util.js"></script>
<script src="transbeam.js"></script>
<title>transbeam</title>
</head>
<body>
<div id="header">
<img src="images/site-icons/transbeam.svg" height="128">
<h1>transbeam</h1>
<body class="no_files selecting">
<div id="header">
<img src="images/site-icons/transbeam.svg" height="128">
<h1>transbeam</h1>
</div>
<noscript>This page requires Javascript :(</noscript>
<div id="message"></div>
<div id="upload_controls">
<div>
<button id="upload">Upload</button>
</div>
<noscript>This page requires Javascript :(</noscript>
<button id="upload">Upload</button>
<div id="lifetime_container" style="display: none;">
<div id="lifetime_container">
<label>
Keep files for:
<select id="lifetime">
@ -29,30 +35,26 @@
</select>
</label>
</div>
<div id="download_link_container" style="display: none;">
<div id="download_link_main">
<div>Download link: <span id="download_link"></span></div><div class="copy_button"></div>
</div>
<div id="copied_message" style="display: none;">Copied!</div>
</div>
<div id="download_link_container">
<div id="download_link_main">
<div>Download link: <span id="download_link"></span></div><div class="copy_button"></div>
</div>
<div id="progress_container" style="display: none;">
<div id="progress"></div>
<div id="progress_bar"></div>
</div>
<div id="file_list_container" style="display: none;">
<table id="file_list">
</table>
</div>
<div id="file_input_container">
<label>
<input type="file" multiple id="file_input"/>
<span class="fake_button" id="file_input_message">Select files to upload...</span>
</label>
</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>
<script src="transbeam.js"></script>
<div id="copied_message">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="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>

39
static/states.css Normal file
View file

@ -0,0 +1,39 @@
/**
* List of classes the body can have:
*
* no_files: no files are selected
* selecting: upload hasn't started yet
* uploading: upload is in progress
* completed: upload is done
* error: an error has occurred
*/
#message { display: none; }
body.completed #message {
display: revert;
color: #060;
border-color: #0a0;
}
body.error #message {
display: revert;
color: #d00;
border-color: #f24;
}
#upload_controls { display: none; }
body.selecting #upload_controls { display: revert; }
body.no_files #upload_controls { display: none; }
body.selecting #download_link_container { display: none; }
#progress_container { display: none; }
body.uploading #progress_container { display: revert; }
body.no_files #file_list { display: none; }
body.completed #file_list { background-color: #7af; }
.delete_button { display: none; }
body.selecting .delete_button { display: revert; }
#file_input_container { display: none; }
body.selecting #file_input_container { display: revert; }

View file

@ -10,6 +10,14 @@ body {
margin-top: 5px;
}
#message {
border: 1px solid;
border-radius: 4px;
padding: 10px;
width: fit-content;
margin: 10px auto;
}
#progress_container {
margin: 10px auto;
}
@ -64,7 +72,15 @@ body {
bottom: 0;
margin: auto;
height: fit-content;
display: none;
}
#download_link_container.copied #copied_message {
display: revert;
}
#download_link_container.copied #download_link_main {
visibility: hidden;
}
table {
border-collapse: collapse;
@ -85,7 +101,7 @@ td {
padding: 10px;
}
td.file_delete {
.delete_button {
background-color: #888;
mask-image: url("images/feather-icons/x.svg");
mask-size: contain;
@ -96,7 +112,7 @@ td.file_delete {
cursor: pointer;
}
td.file_delete:hover {
.delete_button:hover {
background-color: #f00;
}
@ -127,7 +143,7 @@ button:hover, .fake_button:hover {
}
button:disabled, input:disabled + .fake_button {
color: #aaa;
color: #666;
background-color: #eee;
border-color: #ddd;
cursor: not-allowed;

View file

@ -1,36 +1,135 @@
const FILE_CHUNK_SIZE = 16384;
const MAX_FILES = 256;
const fileInputContainer = document.getElementById('file_input_container');
const fileInput = document.getElementById('file_input');
const fileInputMessage = document.getElementById('file_input_message');
const fileListContainer = document.getElementById('file_list_container');
const fileList = document.getElementById('file_list');
const lifetimeContainer = document.getElementById('lifetime_container');
const lifetimeInput = document.getElementById('lifetime');
const uploadButton = document.getElementById('upload');
const downloadLinkContainer = document.getElementById('download_link_container');
const downloadLinkMain = document.getElementById('download_link_main');
const downloadLink = document.getElementById('download_link');
const copiedMessage = document.getElementById('copied_message');
const progressContainer = document.getElementById('progress_container');
const progress = document.getElementById('progress');
const progressBar = document.getElementById('progress_bar');
let files = [];
let socket = null;
let socket;
let fileIndex = 0;
let byteIndex = 0;
let bytesSent = 0;
let totalBytes = 0;
function sendManifest(lifetime) {
let maxSize = null;
let messageBox;
let fileInput;
let fileList;
let uploadButton;
let lifetimeInput;
let downloadLink;
let progress;
let progressBar;
document.addEventListener('DOMContentLoaded', () => {
messageBox = document.getElementById('message');
fileInput = document.getElementById('file_input');
fileList = document.getElementById('file_list');
uploadButton = document.getElementById('upload');
lifetimeInput = document.getElementById('lifetime');
downloadLink = document.getElementById('download_link');
progress = document.getElementById('progress');
progressBar = document.getElementById('progress_bar');
fileInput.addEventListener('input', () => {
for (const file of fileInput.files) { addFile(file); }
updateFiles();
fileInput.value = '';
});
uploadButton.addEventListener('click', beginUpload);
const downloadLinkContainer = document.getElementById('download_link_container');
downloadLinkContainer.addEventListener('click', () => {
navigator.clipboard.writeText(downloadLink.textContent);
downloadLinkContainer.className = 'copied';
});
downloadLinkContainer.addEventListener('mouseleave', () => {
downloadLinkContainer.className = '';
});
updateFiles();
});
function updateFiles() {
const fileInputMessage = document.getElementById('file_input_message');
totalBytes = files.reduce((acc, file) => acc + file.size, 0);
fileInput.disabled = (files.length >= MAX_FILES || (maxSize !== null && totalBytes >= maxSize));
let extraClasses = '';
if (maxSize !== null && totalBytes > maxSize) {
uploadButton.disabled = true;
displayError(`The maximum size for uploads is ${displaySize(maxSize)}`);
extraClasses = ' error';
} else {
uploadButton.disabled = false;
}
if (files.length === 0) {
fileInputMessage.textContent = 'Select files to upload...';
document.body.className = 'no_files selecting' + extraClasses;
} else {
fileInputMessage.textContent = 'Select more files to upload...';
uploadButton.textContent = `Upload ${files.length} file${files.length > 1 ? 's' : ''} (${displaySize(totalBytes)})`;
document.body.className = 'selecting' + extraClasses;
}
}
function addFile(newFile) {
if (files.length >= MAX_FILES) { return; }
if (files.some((oldFile) => newFile.name === oldFile.name)) { return; }
files.push(newFile);
addListEntry(newFile);
}
function addListEntry(file) {
const listEntry = document.createElement('tr');
const deleteButtonCell = document.createElement('td');
deleteButtonCell.className = 'delete_button';
deleteButtonCell.addEventListener('click', () => {
removeFile(file.name);
listEntry.remove();
updateFiles();
});
const sizeCell = document.createElement('td');
sizeCell.className = 'file_size';
sizeCell.textContent = displaySize(file.size);
const nameCell = document.createElement('td');
nameCell.className = 'file_name';
nameCell.textContent = file.name;
listEntry.appendChild(deleteButtonCell);
listEntry.appendChild(sizeCell);
listEntry.appendChild(nameCell);
fileList.appendChild(listEntry);
}
function removeFile(name) {
files = files.filter((file) => file.name !== name);
}
function beginUpload() {
if (files.length === 0 || files.length > MAX_FILES) { return; }
fileIndex = 0;
byteIndex = 0;
bytesSent = 0;
socket = new WebSocket(`${window.location.protocol === 'http:' ? 'ws' : 'wss'}://${window.location.host}/upload`);
socket.addEventListener('open', sendManifest);
socket.addEventListener('message', handleMessage);
socket.addEventListener('close', handleClose);
}
function sendManifest() {
const lifetime = parseInt(lifetimeInput.value);
const fileMetadata = files.map((file) => ({
name: file.name,
size: file.size,
@ -39,14 +138,55 @@ function sendManifest(lifetime) {
socket.send(JSON.stringify({ lifetime, files: fileMetadata }));
}
function finishSending() {
if (socket.bufferedAmount > 0) {
window.setTimeout(finishSending, 1000);
return;
function handleMessage(msg) {
if (bytesSent === 0) {
let reply;
try {
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') {
downloadLink.textContent = `${window.location.origin}/download/${reply.code}`;
updateProgress();
document.body.className = 'uploading';
sendData();
return;
}
// we're going to display a more useful error message
socket.removeEventListener('close', handleClose);
socket.close();
if (reply.type === 'too_big') {
maxSize = reply.max_size;
updateFiles();
} else if (reply.type === 'too_long') {
let options = Array.from(lifetimeInput.options);
options.reverse();
for (const option of options) {
if (option.value > reply.max_days) {
option.disabled = true;
} else {
option.selected = true;
break;
}
}
displayError(`The maximum retention time for uploads is ${reply.max_days} days`);
} else if (reply.type === 'error') {
displayError(reply.details);
}
} else {
if (msg.data === 'ack') {
sendData();
} else {
console.error('Received unexpected message from server instead of ack', msg.data);
displayError();
socket.close();
}
}
socket.close();
progressContainer.textContent = "Upload complete!";
fileList.style.backgroundColor = "#7af";
}
function sendData() {
@ -91,108 +231,49 @@ function updateProgress() {
}
}
function updateFiles() {
totalBytes = files.reduce((acc, file) => acc + file.size, 0);
if (files.length === 0) {
fileInputMessage.textContent = 'Select files to upload...';
fileListContainer.style.display = 'none';
uploadButton.style.display = 'none';
lifetimeContainer.style.display = 'none';
function handleClose(e) {
console.log('Websocket closed', e);
if (fileIndex >= files.length) {
displayCompletion();
} else {
fileInputMessage.textContent = 'Select more files to upload...';
fileListContainer.style.display = '';
uploadButton.textContent = `Upload ${files.length} file${files.length > 1 ? 's' : ''} (${displaySize(totalBytes)})`;
uploadButton.style.display = '';
lifetimeContainer.style.display = '';
}
fileInput.disabled = (files.length >= MAX_FILES);
}
updateFiles();
downloadLinkContainer.style.display = 'none';
progressContainer.style.display = 'none';
function addFile(newFile) {
if (files.length >= MAX_FILES) { return; }
if (files.some((oldFile) => newFile.name === oldFile.name)) { return; }
files.push(newFile);
addListEntry(newFile);
}
function addListEntry(file) {
const listEntry = document.createElement('tr');
const deleteButtonCell = document.createElement('td');
deleteButtonCell.className = 'file_delete';
deleteButtonCell.addEventListener('click', () => {
removeFile(file.name);
listEntry.remove();
updateFiles();
});
const sizeCell = document.createElement('td');
sizeCell.className = 'file_size';
sizeCell.textContent = displaySize(file.size);
const nameCell = document.createElement('td');
nameCell.className = 'file_name';
nameCell.textContent = file.name;
listEntry.appendChild(deleteButtonCell);
listEntry.appendChild(sizeCell);
listEntry.appendChild(nameCell);
fileList.appendChild(listEntry);
}
function removeFile(name) {
files = files.filter((file) => file.name !== name);
}
fileInput.addEventListener('input', (e) => {
for (const file of e.target.files) { addFile(file); }
updateFiles();
e.target.value = '';
});
uploadButton.addEventListener('click', (e) => {
if (files.length === 0) { return; }
const lifetime = parseInt(lifetimeInput.value);
lifetimeContainer.remove();
fileInputContainer.remove();
for (const button of Array.from(document.getElementsByTagName('button')).concat(...document.getElementsByClassName('file_delete'))) {
button.remove();
}
socket = new WebSocket(`${window.location.protocol === 'http:' ? 'ws' : 'wss'}://${window.location.host}/upload`);
socket.addEventListener('open', () => sendManifest(lifetime));
socket.addEventListener('message', (msg) => {
if (bytesSent === 0) {
const reply = JSON.parse(msg.data);
if (reply.type === 'ready' && reply.code.match(/^[A-Za-z0-9]+$/)) {
downloadLink.textContent = `${window.location.origin}/download/${reply.code}`;
downloadLinkContainer.style.display = '';
updateProgress();
progressContainer.style.display = '';
sendData();
let error;
if (e.code === 1011) {
if (e.reason) {
error = `Server error: ${e.reason}`;
} else {
error = "A server error has occurred."
}
} else if (msg.data === 'ack') {
sendData();
} else if (e.reason) {
error = e.reason;
}
});
})
displayError(error);
}
}
downloadLinkContainer.addEventListener('click', (e) => {
navigator.clipboard.writeText(downloadLink.textContent);
downloadLinkMain.style.visibility = 'hidden';
copiedMessage.style.display = '';
})
function finishSending() {
if (socket.bufferedAmount > 0) {
window.setTimeout(finishSending, 1000);
return;
}
socket.close();
displayCompletion();
}
downloadLinkContainer.addEventListener('mouseleave', (e) => {
copiedMessage.style.display = 'none';
downloadLinkMain.style.visibility = 'visible';
});
function displayCompletion() {
messageBox.textContent = 'Upload complete!';
document.body.className = 'completed';
removePerFileProgressBars(); // completed file backgrounds are handled in CSS
}
function displayError(error) {
messageBox.textContent = error || 'An error has occurred.';
document.body.className = 'selecting error';
removePerFileProgressBars();
}
function removePerFileProgressBars() {
const fileEntries = Array.from(fileList.children);
for (entry of fileEntries) {
entry.style.backgroundSize = "0%";
}
}