Files
beam/index.html.tmpl
T

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> &middot; 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>