520 lines
18 KiB
Cheetah
520 lines
18 KiB
Cheetah
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Beam - File Upload Service</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
html { color-scheme: light; }
|
|
body {
|
|
font-family: monospace;
|
|
max-width: 800px;
|
|
margin: 40px auto;
|
|
padding: 20px;
|
|
line-height: 1.6;
|
|
background: #fff;
|
|
color: #222;
|
|
}
|
|
h1 { margin-bottom: 30px; font-size: 2em; }
|
|
h2 { margin: 30px 0 15px 0; font-size: 1.3em; }
|
|
h3 { margin: 0 0 10px 0; font-size: 1.05em; }
|
|
.section {
|
|
background: #f5f5f5;
|
|
padding: 20px;
|
|
margin: 20px 0;
|
|
border-radius: 4px;
|
|
}
|
|
code {
|
|
background: #333;
|
|
color: #0f0;
|
|
padding: 15px;
|
|
display: block;
|
|
margin: 10px 0;
|
|
border-radius: 4px;
|
|
overflow-x: auto;
|
|
}
|
|
.note { margin-top: 10px; }
|
|
.upload-area {
|
|
border: 3px dashed #ccc;
|
|
padding: 40px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
transition: border-color 0.3s, background 0.3s;
|
|
}
|
|
.upload-area.dragover {
|
|
border-color: #333;
|
|
background: #f0f0f0;
|
|
}
|
|
.upload-area:hover {
|
|
border-color: #666;
|
|
}
|
|
.upload-area.disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.6;
|
|
}
|
|
.upload-area.disabled:hover { border-color: #ccc; }
|
|
.hint { margin-top: 10px; font-size: 0.85em; color: #666; }
|
|
input[type="file"] { display: none; }
|
|
button {
|
|
background: #333;
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
cursor: pointer;
|
|
font-family: monospace;
|
|
font-size: 1em;
|
|
border-radius: 4px;
|
|
margin-top: 10px;
|
|
}
|
|
button:hover { background: #555; }
|
|
button:disabled { background: #999; cursor: not-allowed; }
|
|
:focus-visible {
|
|
outline: 3px solid #0066cc;
|
|
outline-offset: 2px;
|
|
}
|
|
.progress-container {
|
|
display: none;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin: 10px 0;
|
|
}
|
|
.progress-container.active { display: flex; }
|
|
.progress-track {
|
|
flex: 1;
|
|
background: #ddd;
|
|
border-radius: 4px;
|
|
height: 20px;
|
|
overflow: hidden;
|
|
}
|
|
.progress-bar {
|
|
height: 100%;
|
|
background: #333;
|
|
border-radius: 4px;
|
|
width: 0;
|
|
transition: width 0.3s;
|
|
}
|
|
.progress-text { font-size: 0.85em; min-width: 4ch; text-align: right; }
|
|
.results {
|
|
margin-top: 20px;
|
|
}
|
|
.result-item {
|
|
background: white;
|
|
padding: 10px;
|
|
margin: 5px 0;
|
|
border-radius: 4px;
|
|
word-break: break-all;
|
|
}
|
|
.result-item.error {
|
|
background: #fdecea;
|
|
color: #8a1f11;
|
|
border-left: 4px solid #c0392b;
|
|
}
|
|
.result-name {
|
|
font-weight: bold;
|
|
margin-bottom: 4px;
|
|
}
|
|
.result-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
}
|
|
.result-row a {
|
|
color: #0066cc;
|
|
flex: 1;
|
|
word-break: break-all;
|
|
}
|
|
.copy-button {
|
|
margin-top: 0;
|
|
padding: 4px 12px;
|
|
font-size: 0.85em;
|
|
flex-shrink: 0;
|
|
}
|
|
.version {
|
|
position: fixed;
|
|
left: 8px;
|
|
bottom: 6px;
|
|
color: #767676;
|
|
font-size: 0.75em;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
* { transition: none !important; }
|
|
}
|
|
|
|
@media (max-height: 800px) {
|
|
body { margin: 15px auto; padding: 15px; }
|
|
h1 { margin-bottom: 15px; font-size: 1.6em; }
|
|
h2 { margin: 15px 0 10px 0; }
|
|
.section { padding: 12px; margin: 12px 0; }
|
|
code { padding: 10px; margin: 8px 0; }
|
|
.upload-area { padding: 20px; }
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
body { padding: 12px; margin: 10px auto; }
|
|
h1 { font-size: 1.5em; margin-bottom: 15px; }
|
|
h2 { font-size: 1.15em; margin: 15px 0 10px 0; }
|
|
.section { padding: 12px; margin: 12px 0; }
|
|
code { padding: 10px; font-size: 0.85em; }
|
|
.upload-area { padding: 20px 15px; }
|
|
button { padding: 12px 20px; }
|
|
.copy-button { padding: 8px 12px; }
|
|
.result-row { flex-direction: column; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1><span aria-hidden="true">🚀</span> Beam - File Upload Service</h1>
|
|
|
|
<div class="section">
|
|
<h2>Upload with curl</h2>
|
|
<code>curl --upload-file myfile.txt {{ .Proto }}://{{ .Host }}/myfile.txt</code>
|
|
<p class="note">The server responds with the download URL.</p>
|
|
<p class="note">Maximum upload size: <strong id="maxUploadSizeLabel" data-max-bytes="{{ .MaxUploadSize }}">{{ .MaxUploadSize }} bytes</strong>.
|
|
Uploaded files are deleted after <strong>{{ .Expiry }}</strong>.</p>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>Download a file</h2>
|
|
<code>curl {{ .Proto }}://{{ .Host }}/x123abcx/myfile.txt -o myfile.txt</code>
|
|
<p class="note">Or simply open the URL in your browser.</p>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>Web Upload</h2>
|
|
<div class="upload-area" id="uploadArea" role="button" tabindex="0" aria-label="Select files to upload, or drag and drop them here">
|
|
<p><span aria-hidden="true">📁</span> Click to select files or drag and drop here</p>
|
|
<p class="hint">Maximum total size: <span id="maxUploadSizeText"></span> · deleted after {{ .Expiry }}</p>
|
|
<input type="file" id="fileInput" multiple>
|
|
<button type="button" id="selectButton">Select Files</button>
|
|
<button type="button" id="cancelButton" hidden>Cancel</button>
|
|
</div>
|
|
<div class="progress-container" id="progressContainer">
|
|
<div class="progress-track">
|
|
<div class="progress-bar" id="progressBar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Upload progress"></div>
|
|
</div>
|
|
<span class="progress-text" id="progressText">0%</span>
|
|
</div>
|
|
<div class="results" id="results" role="status" aria-live="polite"></div>
|
|
</div>
|
|
|
|
<div class="version">v{{ .Version }}</div>
|
|
|
|
<script>
|
|
const uploadArea = document.getElementById('uploadArea');
|
|
const fileInput = document.getElementById('fileInput');
|
|
const selectButton = document.getElementById('selectButton');
|
|
const cancelButton = document.getElementById('cancelButton');
|
|
const progressContainer = document.getElementById('progressContainer');
|
|
const progressBar = document.getElementById('progressBar');
|
|
const progressText = document.getElementById('progressText');
|
|
const results = document.getElementById('results');
|
|
const maxUploadSize = parseInt(document.getElementById('maxUploadSizeLabel').dataset.maxBytes, 10) || 0;
|
|
|
|
let currentXhr = null;
|
|
let uploading = false;
|
|
let dragDepth = 0;
|
|
|
|
function formatBytes(bytes) {
|
|
if (!bytes) return 'unknown';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let value = bytes;
|
|
let unitIndex = 0;
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
value /= 1024;
|
|
unitIndex++;
|
|
}
|
|
return value.toFixed(unitIndex === 0 ? 0 : 1) + ' ' + units[unitIndex];
|
|
}
|
|
|
|
document.getElementById('maxUploadSizeLabel').textContent = formatBytes(maxUploadSize);
|
|
document.getElementById('maxUploadSizeText').textContent = formatBytes(maxUploadSize);
|
|
|
|
function setProgress(percent) {
|
|
const rounded = Math.round(percent);
|
|
progressBar.style.width = percent + '%';
|
|
progressBar.setAttribute('aria-valuenow', rounded);
|
|
progressText.textContent = rounded + '%';
|
|
}
|
|
|
|
function showProgress() {
|
|
progressContainer.classList.add('active');
|
|
setProgress(0);
|
|
}
|
|
|
|
// Hide rather than leave a stale bar behind: a failed upload must not
|
|
// display a full, complete-looking bar.
|
|
function hideProgress() {
|
|
progressContainer.classList.remove('active');
|
|
setProgress(0);
|
|
}
|
|
|
|
function setUploading(active) {
|
|
uploading = active;
|
|
selectButton.disabled = active;
|
|
cancelButton.hidden = !active;
|
|
uploadArea.classList.toggle('disabled', active);
|
|
uploadArea.setAttribute('aria-disabled', String(active));
|
|
}
|
|
|
|
function showMessage(message, isError) {
|
|
const div = document.createElement('div');
|
|
div.className = isError ? 'result-item error' : 'result-item';
|
|
div.textContent = message;
|
|
results.replaceChildren(div);
|
|
}
|
|
|
|
function filenameFromURL(url) {
|
|
try {
|
|
const path = new URL(url, window.location.href).pathname;
|
|
return decodeURIComponent(path.substring(path.lastIndexOf('/') + 1));
|
|
} catch (e) {
|
|
return url;
|
|
}
|
|
}
|
|
|
|
async function copyToClipboard(text) {
|
|
// The async Clipboard API requires a secure context, so it is
|
|
// unavailable when beam is reached over plain HTTP.
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
return true;
|
|
} catch (e) { /* fall through to the legacy path */ }
|
|
}
|
|
|
|
const scratch = document.createElement('textarea');
|
|
scratch.value = text;
|
|
scratch.setAttribute('readonly', '');
|
|
scratch.style.position = 'fixed';
|
|
scratch.style.opacity = '0';
|
|
document.body.appendChild(scratch);
|
|
scratch.select();
|
|
let copied = false;
|
|
try {
|
|
copied = document.execCommand('copy');
|
|
} catch (e) {
|
|
copied = false;
|
|
}
|
|
document.body.removeChild(scratch);
|
|
return copied;
|
|
}
|
|
|
|
function resultRow(url) {
|
|
const div = document.createElement('div');
|
|
div.className = 'result-item';
|
|
|
|
const name = document.createElement('div');
|
|
name.className = 'result-name';
|
|
name.textContent = filenameFromURL(url);
|
|
div.appendChild(name);
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'result-row';
|
|
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.target = '_blank';
|
|
link.rel = 'noopener noreferrer';
|
|
link.textContent = url;
|
|
row.appendChild(link);
|
|
|
|
const copyButton = document.createElement('button');
|
|
copyButton.type = 'button';
|
|
copyButton.className = 'copy-button';
|
|
copyButton.textContent = 'Copy';
|
|
copyButton.setAttribute('aria-label', 'Copy download URL for ' + filenameFromURL(url));
|
|
copyButton.addEventListener('click', async () => {
|
|
const copied = await copyToClipboard(url);
|
|
copyButton.textContent = copied ? 'Copied!' : 'Press Ctrl+C';
|
|
if (!copied) {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(link);
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
setTimeout(() => { copyButton.textContent = 'Copy'; }, 1500);
|
|
});
|
|
row.appendChild(copyButton);
|
|
|
|
div.appendChild(row);
|
|
return div;
|
|
}
|
|
|
|
function displayResults(urls, failed) {
|
|
if (!Array.isArray(urls)) {
|
|
showMessage('Upload failed: Unexpected response from server', true);
|
|
return;
|
|
}
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
const heading = document.createElement('h3');
|
|
heading.textContent = urls.length === 1 ? '1 file uploaded' : urls.length + ' files uploaded';
|
|
fragment.appendChild(heading);
|
|
|
|
urls.forEach(url => fragment.appendChild(resultRow(url)));
|
|
|
|
(failed || []).forEach(name => {
|
|
const div = document.createElement('div');
|
|
div.className = 'result-item error';
|
|
div.textContent = 'Failed to upload: ' + name;
|
|
fragment.appendChild(div);
|
|
});
|
|
|
|
results.replaceChildren(fragment);
|
|
}
|
|
|
|
selectButton.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
fileInput.click();
|
|
});
|
|
|
|
cancelButton.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
if (currentXhr) currentXhr.abort();
|
|
});
|
|
|
|
uploadArea.addEventListener('click', (e) => {
|
|
if (e.target.closest('button')) return;
|
|
if (uploading) return;
|
|
fileInput.click();
|
|
});
|
|
|
|
uploadArea.addEventListener('keydown', (e) => {
|
|
if (e.target.closest('button')) return;
|
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
e.preventDefault();
|
|
if (!uploading) fileInput.click();
|
|
});
|
|
|
|
// Track enter/leave depth: without it, dragging over the child
|
|
// elements fires dragleave and the highlight flickers.
|
|
uploadArea.addEventListener('dragenter', (e) => {
|
|
e.preventDefault();
|
|
dragDepth++;
|
|
if (!uploading) uploadArea.classList.add('dragover');
|
|
});
|
|
|
|
uploadArea.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
});
|
|
|
|
uploadArea.addEventListener('dragleave', () => {
|
|
dragDepth = Math.max(0, dragDepth - 1);
|
|
if (dragDepth === 0) uploadArea.classList.remove('dragover');
|
|
});
|
|
|
|
uploadArea.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
dragDepth = 0;
|
|
uploadArea.classList.remove('dragover');
|
|
uploadFiles(e.dataTransfer.files);
|
|
});
|
|
|
|
fileInput.addEventListener('change', (e) => {
|
|
uploadFiles(e.target.files);
|
|
// Reset so re-selecting the same file fires change again.
|
|
fileInput.value = '';
|
|
});
|
|
|
|
function uploadFiles(files) {
|
|
if (uploading) {
|
|
showMessage('An upload is already in progress.', true);
|
|
return;
|
|
}
|
|
if (files.length === 0) return;
|
|
|
|
const oversizedFiles = [];
|
|
let totalSize = 0;
|
|
for (let file of files) {
|
|
totalSize += file.size;
|
|
if (maxUploadSize && file.size > maxUploadSize) {
|
|
oversizedFiles.push(file.name);
|
|
}
|
|
}
|
|
|
|
if (oversizedFiles.length > 0) {
|
|
showMessage('Upload failed: the following file(s) exceed the maximum size of ' +
|
|
formatBytes(maxUploadSize) + ': ' + oversizedFiles.join(', '), true);
|
|
return;
|
|
}
|
|
|
|
// The server limits the whole request body, so the total matters
|
|
// even when each individual file fits.
|
|
if (maxUploadSize && totalSize > maxUploadSize) {
|
|
showMessage('Upload failed: total upload size (' + formatBytes(totalSize) +
|
|
') exceeds the maximum of ' + formatBytes(maxUploadSize), true);
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
for (let file of files) {
|
|
formData.append('files', file);
|
|
}
|
|
|
|
showProgress();
|
|
results.replaceChildren();
|
|
setUploading(true);
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
currentXhr = xhr;
|
|
|
|
xhr.upload.addEventListener('progress', (e) => {
|
|
if (e.lengthComputable) {
|
|
setProgress((e.loaded / e.total) * 100);
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('load', () => {
|
|
if (xhr.status === 200) {
|
|
let response;
|
|
try {
|
|
response = JSON.parse(xhr.responseText);
|
|
} catch (e) {
|
|
showMessage('Upload failed: Unexpected response from server', true);
|
|
return;
|
|
}
|
|
displayResults(response.urls, response.failed);
|
|
} else {
|
|
let message = xhr.statusText;
|
|
try {
|
|
const response = JSON.parse(xhr.responseText);
|
|
if (response.error) {
|
|
message = response.error;
|
|
}
|
|
} catch (e) {
|
|
if (xhr.responseText) {
|
|
message = xhr.responseText;
|
|
}
|
|
}
|
|
showMessage('Upload failed: ' + message, true);
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('error', () => {
|
|
showMessage('Upload failed: Network error', true);
|
|
});
|
|
|
|
xhr.addEventListener('abort', () => {
|
|
showMessage('Upload cancelled.', true);
|
|
});
|
|
|
|
// Runs after load/error/abort, so every outcome clears the bar.
|
|
// No xhr.timeout: it caps the whole request and would kill
|
|
// legitimate multi-gigabyte uploads.
|
|
xhr.addEventListener('loadend', () => {
|
|
currentXhr = null;
|
|
setUploading(false);
|
|
hideProgress();
|
|
});
|
|
|
|
xhr.open('POST', '/upload');
|
|
xhr.send(formData);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|