- Add backend services to create, list, and delete API tokens. - Implement Bearer token authentication to resolve tokens to users. - Register HTTP routes for managing user tokens under `/account/tokens`. - Add tests to verify that uploads with valid Bearer tokens associate the upload with the correct user, while invalid tokens fall back to anonymous uploads.
637 lines
20 KiB
JavaScript
637 lines
20 KiB
JavaScript
(function () {
|
|
const form = document.querySelector("#upload-form");
|
|
const dropZone = document.querySelector(".drop-zone");
|
|
const fileInput = document.querySelector("#file-input");
|
|
const fileSummary = document.querySelector("#file-summary");
|
|
const progress = document.querySelector("#upload-progress");
|
|
const uploadStatus = document.querySelector("#upload-status");
|
|
const result = document.querySelector("#upload-result");
|
|
const resultMeta = document.querySelector("#result-meta");
|
|
const resultList = document.querySelector("#result-list");
|
|
const uploadQueue = document.querySelector("#upload-queue");
|
|
const totalProgressBar = document.querySelector("#total-progress-bar");
|
|
const copyURL = document.querySelector("#copy-url");
|
|
const openBox = document.querySelector("#open-box");
|
|
const manageLink = document.querySelector("#manage-link");
|
|
const fileBrowser = document.querySelector("[data-file-browser]");
|
|
const viewButtons = document.querySelectorAll("[data-view-button]");
|
|
const previewImages = document.querySelector("[data-preview-images]");
|
|
const previewActions = document.querySelectorAll("[data-preview-action]");
|
|
const fileContextMenu = document.querySelector("[data-file-context-menu]");
|
|
const storageProviderSelects = document.querySelectorAll("[data-storage-provider]");
|
|
let ctrlCopyMode = false;
|
|
let contextFile = null;
|
|
const contextMenuCloseDistance = 80;
|
|
|
|
if (fileBrowser) {
|
|
viewButtons.forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const view = button.getAttribute("data-view-button");
|
|
fileBrowser.classList.toggle("is-list", view === "list");
|
|
fileBrowser.classList.toggle("is-thumbs", view === "thumbs");
|
|
viewButtons.forEach((item) => item.classList.toggle("is-active", item === button));
|
|
});
|
|
});
|
|
|
|
if (previewImages) {
|
|
previewImages.addEventListener("click", () => {
|
|
fileBrowser.classList.toggle("images-only");
|
|
previewImages.classList.toggle("is-active");
|
|
});
|
|
}
|
|
}
|
|
|
|
if (fileBrowser && fileContextMenu) {
|
|
fileBrowser.addEventListener("contextmenu", (event) => {
|
|
const card = event.target.closest("[data-file-context]");
|
|
if (!card) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
contextFile = {
|
|
previewURL: card.dataset.previewUrl,
|
|
viewURL: card.dataset.viewUrl,
|
|
downloadURL: card.dataset.downloadUrl,
|
|
fileName: card.dataset.fileName,
|
|
};
|
|
showContextMenu(event.clientX, event.clientY);
|
|
});
|
|
|
|
fileContextMenu.addEventListener("click", async (event) => {
|
|
const button = event.target.closest("[data-context-action]");
|
|
if (!button || !contextFile) {
|
|
return;
|
|
}
|
|
|
|
const shouldHide = await runContextAction(button.dataset.contextAction, contextFile);
|
|
if (shouldHide !== false) {
|
|
hideContextMenu();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("click", (event) => {
|
|
if (!fileContextMenu.contains(event.target)) {
|
|
hideContextMenu();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
hideContextMenu();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("mousemove", (event) => {
|
|
if (fileContextMenu.hidden || isPointerNearContextMenu(event.clientX, event.clientY)) {
|
|
return;
|
|
}
|
|
hideContextMenu();
|
|
});
|
|
|
|
window.addEventListener("resize", hideContextMenu);
|
|
window.addEventListener("scroll", hideContextMenu, true);
|
|
}
|
|
|
|
if (previewActions.length > 0) {
|
|
previewActions.forEach((button) => {
|
|
button.addEventListener("click", async (event) => {
|
|
if (!event.ctrlKey && !ctrlCopyMode) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
await copyPreviewLink(button);
|
|
});
|
|
});
|
|
|
|
window.addEventListener("keydown", (event) => {
|
|
if (event.key === "Control") {
|
|
setPreviewCopyMode(true);
|
|
}
|
|
});
|
|
|
|
window.addEventListener("keyup", (event) => {
|
|
if (event.key === "Control") {
|
|
setPreviewCopyMode(false);
|
|
}
|
|
});
|
|
|
|
window.addEventListener("blur", () => {
|
|
setPreviewCopyMode(false);
|
|
});
|
|
}
|
|
|
|
function syncStorageProvider(select) {
|
|
const formScope = select.closest("form");
|
|
if (!formScope) {
|
|
return;
|
|
}
|
|
const provider = select.value;
|
|
const isContabo = provider === "contabo";
|
|
formScope.querySelectorAll("[data-provider-fields]").forEach((group) => {
|
|
const providers = (group.getAttribute("data-provider-fields") || "").split(/\s+/);
|
|
const active = providers.includes(provider);
|
|
group.hidden = !active;
|
|
group.querySelectorAll("input, select, textarea").forEach((input) => {
|
|
input.disabled = !active;
|
|
});
|
|
});
|
|
const tls = formScope.querySelector('input[name="use_ssl"]');
|
|
const pathStyle = formScope.querySelector('input[name="path_style"]');
|
|
if (tls) {
|
|
tls.checked = isContabo || tls.checked;
|
|
tls.disabled = isContabo;
|
|
}
|
|
if (pathStyle) {
|
|
pathStyle.checked = isContabo || pathStyle.checked;
|
|
pathStyle.disabled = isContabo;
|
|
}
|
|
}
|
|
|
|
if (storageProviderSelects.length > 0) {
|
|
storageProviderSelects.forEach((select) => {
|
|
select.addEventListener("change", () => syncStorageProvider(select));
|
|
syncStorageProvider(select);
|
|
});
|
|
}
|
|
|
|
/* Storage card edit / cancel toggles */
|
|
document.querySelectorAll(".storage-edit-trigger").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const card = btn.closest(".storage-card");
|
|
if (!card) return;
|
|
card.classList.add("is-editing");
|
|
const providerSelect = card.querySelector("[data-storage-provider]");
|
|
if (providerSelect) {
|
|
syncStorageProvider(providerSelect);
|
|
}
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll(".storage-cancel-trigger").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const card = btn.closest(".storage-card");
|
|
if (!card) return;
|
|
const form = card.querySelector("form");
|
|
if (form) form.reset();
|
|
card.classList.remove("is-editing");
|
|
});
|
|
});
|
|
|
|
/* Add storage: type picker */
|
|
const storageAddTrigger = document.querySelector(".storage-add-trigger");
|
|
const storageTypePicker = document.querySelector(".storage-type-picker");
|
|
const storageNewCard = document.querySelector(".storage-new-card");
|
|
|
|
const providerLabels = {
|
|
s3: "S3 bucket",
|
|
contabo: "Contabo Object Storage",
|
|
sftp: "SFTP",
|
|
smb: "Samba",
|
|
webdav: "WebDAV",
|
|
};
|
|
|
|
const providerIconSVGs = {
|
|
s3: storageNewCard && storageNewCard.querySelector(".storage-new-icon") ? storageNewCard.querySelector(".storage-new-icon").innerHTML : "",
|
|
contabo: "",
|
|
sftp: "",
|
|
smb: "",
|
|
webdav: "",
|
|
};
|
|
|
|
if (storageAddTrigger && storageTypePicker) {
|
|
storageAddTrigger.addEventListener("click", () => {
|
|
storageTypePicker.hidden = !storageTypePicker.hidden;
|
|
if (storageNewCard && !storageTypePicker.hidden) {
|
|
storageNewCard.hidden = true;
|
|
}
|
|
});
|
|
|
|
storageTypePicker.querySelectorAll(".storage-type-option").forEach((opt) => {
|
|
opt.addEventListener("click", () => {
|
|
const provider = opt.dataset.provider;
|
|
if (!storageNewCard) return;
|
|
|
|
const providerSelect = storageNewCard.querySelector("[data-storage-provider]");
|
|
if (providerSelect) {
|
|
providerSelect.value = provider;
|
|
syncStorageProvider(providerSelect);
|
|
}
|
|
|
|
const typeBadge = storageNewCard.querySelector(".storage-new-type-badge");
|
|
if (typeBadge) typeBadge.textContent = providerLabels[provider] || provider;
|
|
|
|
const iconEl = storageNewCard.querySelector(".storage-new-icon");
|
|
const optIcon = opt.querySelector("svg");
|
|
if (iconEl && optIcon) {
|
|
iconEl.innerHTML = optIcon.outerHTML;
|
|
}
|
|
|
|
storageTypePicker.hidden = true;
|
|
storageNewCard.hidden = false;
|
|
});
|
|
});
|
|
}
|
|
|
|
if (storageNewCard) {
|
|
const cancelBtn = storageNewCard.querySelector(".storage-new-cancel");
|
|
if (cancelBtn) {
|
|
cancelBtn.addEventListener("click", () => {
|
|
storageNewCard.hidden = true;
|
|
if (storageTypePicker) storageTypePicker.hidden = true;
|
|
});
|
|
}
|
|
}
|
|
|
|
/* Access token: copy one-time secret */
|
|
const tokenCopyBtn = document.querySelector("[data-token-copy]");
|
|
if (tokenCopyBtn) {
|
|
tokenCopyBtn.addEventListener("click", () => {
|
|
const valueEl = document.querySelector("[data-token-value]");
|
|
if (!valueEl) return;
|
|
copyText(valueEl.textContent.trim(), tokenCopyBtn, "Copied");
|
|
});
|
|
}
|
|
|
|
if (!form || !dropZone || !fileInput) {
|
|
return;
|
|
}
|
|
|
|
let latestBoxURL = "";
|
|
let selectedFiles = [];
|
|
|
|
["dragenter", "dragover"].forEach((eventName) => {
|
|
dropZone.addEventListener(eventName, (event) => {
|
|
event.preventDefault();
|
|
dropZone.classList.add("is-dragging");
|
|
});
|
|
});
|
|
|
|
["dragleave", "drop"].forEach((eventName) => {
|
|
dropZone.addEventListener(eventName, (event) => {
|
|
event.preventDefault();
|
|
dropZone.classList.remove("is-dragging");
|
|
});
|
|
});
|
|
|
|
dropZone.addEventListener("drop", (event) => {
|
|
if (event.dataTransfer && event.dataTransfer.files.length > 0) {
|
|
fileInput.files = event.dataTransfer.files;
|
|
updateSelectedState(event.dataTransfer.files);
|
|
}
|
|
});
|
|
|
|
fileInput.addEventListener("change", () => {
|
|
updateSelectedState(fileInput.files);
|
|
});
|
|
|
|
form.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
if (!fileInput.files || fileInput.files.length === 0) {
|
|
updateStatus("Choose at least one file first.");
|
|
return;
|
|
}
|
|
|
|
const submit = form.querySelector("button[type='submit']");
|
|
const formData = new FormData(form);
|
|
selectedFiles = Array.from(fileInput.files);
|
|
renderQueue(selectedFiles, "queued");
|
|
setLoading(true, submit);
|
|
|
|
try {
|
|
const payload = await uploadWithProgress(form.action, formData, selectedFiles);
|
|
renderResult(payload);
|
|
form.reset();
|
|
updateSelectedState([]);
|
|
} catch (error) {
|
|
updateStatus(error.message || "Upload failed");
|
|
} finally {
|
|
setLoading(false, submit);
|
|
}
|
|
});
|
|
|
|
if (copyURL) {
|
|
copyURL.addEventListener("click", () => {
|
|
copyText(latestBoxURL, copyURL, "Copied");
|
|
});
|
|
}
|
|
|
|
function updateSelectedState(files) {
|
|
selectedFiles = Array.from(files || []);
|
|
const count = selectedFiles.length || 0;
|
|
const title = dropZone.querySelector(".drop-title");
|
|
if (title) {
|
|
title.textContent = count === 0 ? "Drop files to upload" : count === 1 ? "1 file selected" : `${count} files selected`;
|
|
}
|
|
if (fileSummary) {
|
|
fileSummary.textContent = count === 0 ? "Choose one or more files to begin." : `${count} file${count === 1 ? "" : "s"} ready.`;
|
|
}
|
|
if (count > 0) {
|
|
renderQueue(selectedFiles, "queued");
|
|
} else if (uploadQueue) {
|
|
uploadQueue.hidden = true;
|
|
uploadQueue.replaceChildren();
|
|
}
|
|
}
|
|
|
|
function setLoading(isLoading, submit) {
|
|
if (progress) {
|
|
progress.hidden = !isLoading;
|
|
}
|
|
if (submit) {
|
|
submit.disabled = isLoading;
|
|
submit.textContent = isLoading ? "Uploading..." : "Upload files";
|
|
}
|
|
updateStatus(isLoading ? "Transferring files..." : "");
|
|
setTotalProgress(isLoading ? 0 : 100);
|
|
}
|
|
|
|
function updateStatus(message) {
|
|
if (uploadStatus) {
|
|
uploadStatus.textContent = message;
|
|
}
|
|
}
|
|
|
|
function renderResult(payload) {
|
|
if (!result || !resultList || !resultMeta || !openBox) {
|
|
return;
|
|
}
|
|
|
|
latestBoxURL = payload.boxUrl;
|
|
result.hidden = false;
|
|
openBox.href = payload.boxUrl;
|
|
resultMeta.textContent = `${payload.files.length} file${payload.files.length === 1 ? "" : "s"} · expires ${formatDate(payload.expiresAt)}`;
|
|
if (manageLink) {
|
|
const anchor = manageLink.querySelector("a");
|
|
manageLink.hidden = !payload.manageUrl;
|
|
if (anchor && payload.manageUrl) {
|
|
anchor.href = payload.manageUrl;
|
|
}
|
|
}
|
|
|
|
resultList.replaceChildren();
|
|
payload.files.forEach((file) => {
|
|
resultList.append(createFileRow({
|
|
name: file.name,
|
|
meta: `${file.size} · ${file.url}`,
|
|
progress: 100,
|
|
status: "complete",
|
|
}));
|
|
});
|
|
}
|
|
|
|
function uploadWithProgress(url, formData, files) {
|
|
return new Promise((resolve, reject) => {
|
|
const request = new XMLHttpRequest();
|
|
request.open("POST", url);
|
|
request.setRequestHeader("Accept", "application/json");
|
|
|
|
request.upload.addEventListener("progress", (event) => {
|
|
if (!event.lengthComputable) {
|
|
updateStatus("Uploading...");
|
|
return;
|
|
}
|
|
const percent = Math.round((event.loaded / event.total) * 100);
|
|
updateStatus(`${percent}%`);
|
|
setTotalProgress(percent);
|
|
setFileProgress(files, percent);
|
|
});
|
|
|
|
request.addEventListener("load", () => {
|
|
let payload = {};
|
|
try {
|
|
payload = JSON.parse(request.responseText || "{}");
|
|
} catch (error) {
|
|
reject(new Error("Upload response could not be read"));
|
|
return;
|
|
}
|
|
if (request.status < 200 || request.status >= 300) {
|
|
reject(new Error(payload.error || "Upload failed"));
|
|
return;
|
|
}
|
|
setTotalProgress(100);
|
|
setFileProgress(files, 100);
|
|
resolve(payload);
|
|
});
|
|
|
|
request.addEventListener("error", () => reject(new Error("Network error during upload")));
|
|
request.addEventListener("abort", () => reject(new Error("Upload aborted")));
|
|
request.send(formData);
|
|
});
|
|
}
|
|
|
|
function renderQueue(files, status) {
|
|
if (!uploadQueue) {
|
|
return;
|
|
}
|
|
uploadQueue.hidden = files.length === 0;
|
|
uploadQueue.replaceChildren();
|
|
files.forEach((file) => {
|
|
uploadQueue.append(createFileRow({
|
|
name: file.name,
|
|
meta: formatBytes(file.size),
|
|
progress: status === "queued" ? 0 : 100,
|
|
status,
|
|
}));
|
|
});
|
|
}
|
|
|
|
function createFileRow(file) {
|
|
const row = document.createElement("div");
|
|
row.className = "result-item upload-file-row";
|
|
row.dataset.fileName = file.name;
|
|
|
|
const body = document.createElement("span");
|
|
const name = document.createElement("strong");
|
|
name.className = "file-name";
|
|
name.textContent = file.name;
|
|
name.title = file.name;
|
|
const meta = document.createElement("code");
|
|
meta.textContent = file.meta;
|
|
body.append(name, meta);
|
|
|
|
const side = document.createElement("div");
|
|
side.className = "file-progress-side";
|
|
const percent = document.createElement("span");
|
|
percent.className = "file-progress-percent";
|
|
percent.textContent = `${file.progress}%`;
|
|
const bar = document.createElement("div");
|
|
bar.className = "progress file-progress";
|
|
const fill = document.createElement("span");
|
|
fill.style.transform = `scaleX(${file.progress / 100})`;
|
|
bar.append(fill);
|
|
side.append(percent, bar);
|
|
|
|
row.append(body, side);
|
|
return row;
|
|
}
|
|
|
|
function setTotalProgress(percent) {
|
|
if (totalProgressBar) {
|
|
totalProgressBar.style.transform = `scaleX(${Math.max(0, Math.min(100, percent)) / 100})`;
|
|
}
|
|
}
|
|
|
|
function setFileProgress(files, totalPercent) {
|
|
if (!uploadQueue) {
|
|
return;
|
|
}
|
|
const count = files.length || 1;
|
|
const completedFloat = (Math.max(0, Math.min(100, totalPercent)) / 100) * count;
|
|
uploadQueue.querySelectorAll(".upload-file-row").forEach((row, index) => {
|
|
const progress = Math.max(0, Math.min(100, Math.round((completedFloat - index) * 100)));
|
|
const percent = row.querySelector(".file-progress-percent");
|
|
const fill = row.querySelector(".file-progress span");
|
|
if (percent) {
|
|
percent.textContent = `${progress}%`;
|
|
}
|
|
if (fill) {
|
|
fill.style.transform = `scaleX(${progress / 100})`;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function copyText(text, button, copiedLabel) {
|
|
if (!text) {
|
|
return;
|
|
}
|
|
await writeClipboard(text);
|
|
const previous = button.textContent;
|
|
button.textContent = copiedLabel;
|
|
setTimeout(() => {
|
|
button.textContent = previous;
|
|
}, 1400);
|
|
}
|
|
|
|
async function copyPreviewLink(button) {
|
|
await writeClipboard(button.href);
|
|
const label = button.querySelector("[data-preview-label]");
|
|
if (!label) {
|
|
return;
|
|
}
|
|
|
|
label.textContent = "Copied";
|
|
setTimeout(() => {
|
|
label.textContent = ctrlCopyMode ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
|
}, 1200);
|
|
}
|
|
|
|
function setPreviewCopyMode(enabled) {
|
|
ctrlCopyMode = enabled;
|
|
previewActions.forEach((button) => {
|
|
const label = button.querySelector("[data-preview-label]");
|
|
const viewIcon = button.querySelector("[data-preview-view-icon]");
|
|
const copyIcon = button.querySelector("[data-preview-copy-icon]");
|
|
if (label) {
|
|
label.textContent = enabled ? button.dataset.copyLabel || "Copy link" : button.dataset.viewLabel || "View";
|
|
}
|
|
if (viewIcon) {
|
|
viewIcon.hidden = enabled;
|
|
}
|
|
if (copyIcon) {
|
|
copyIcon.hidden = !enabled;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function runContextAction(action, file) {
|
|
if (action === "preview") {
|
|
openInNewTab(file.previewURL);
|
|
return true;
|
|
}
|
|
if (action === "view") {
|
|
openInNewTab(file.viewURL);
|
|
return true;
|
|
}
|
|
if (action === "copy-preview") {
|
|
await writeClipboard(file.previewURL);
|
|
return true;
|
|
}
|
|
if (action === "copy-download") {
|
|
await writeClipboard(file.downloadURL);
|
|
return true;
|
|
}
|
|
if (action === "download") {
|
|
openInNewTab(file.downloadURL);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function showContextMenu(x, y) {
|
|
fileContextMenu.hidden = false;
|
|
fileContextMenu.style.left = "0px";
|
|
fileContextMenu.style.top = "0px";
|
|
|
|
const rect = fileContextMenu.getBoundingClientRect();
|
|
const margin = 8;
|
|
const left = Math.min(x, window.innerWidth - rect.width - margin);
|
|
const top = Math.min(y, window.innerHeight - rect.height - margin);
|
|
fileContextMenu.style.left = `${Math.max(margin, left)}px`;
|
|
fileContextMenu.style.top = `${Math.max(margin, top)}px`;
|
|
}
|
|
|
|
function hideContextMenu() {
|
|
if (!fileContextMenu || fileContextMenu.hidden) {
|
|
return;
|
|
}
|
|
fileContextMenu.hidden = true;
|
|
contextFile = null;
|
|
}
|
|
|
|
function isPointerNearContextMenu(x, y) {
|
|
const rect = fileContextMenu.getBoundingClientRect();
|
|
return x >= rect.left - contextMenuCloseDistance &&
|
|
x <= rect.right + contextMenuCloseDistance &&
|
|
y >= rect.top - contextMenuCloseDistance &&
|
|
y <= rect.bottom + contextMenuCloseDistance;
|
|
}
|
|
|
|
function openInNewTab(url) {
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
}
|
|
|
|
async function writeClipboard(text) {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
await navigator.clipboard.writeText(text);
|
|
return;
|
|
}
|
|
|
|
const textarea = document.createElement("textarea");
|
|
textarea.value = text;
|
|
textarea.setAttribute("readonly", "");
|
|
textarea.style.position = "fixed";
|
|
textarea.style.opacity = "0";
|
|
document.body.append(textarea);
|
|
textarea.select();
|
|
document.execCommand("copy");
|
|
textarea.remove();
|
|
}
|
|
|
|
function formatDate(value) {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return value;
|
|
}
|
|
return date.toLocaleDateString(undefined, {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes < 1024) {
|
|
return `${bytes} B`;
|
|
}
|
|
const units = ["KiB", "MiB", "GiB", "TiB"];
|
|
let value = bytes / 1024;
|
|
let unit = 0;
|
|
while (value >= 1024 && unit < units.length - 1) {
|
|
value /= 1024;
|
|
unit += 1;
|
|
}
|
|
return `${value.toFixed(1)} ${units[unit]}`;
|
|
}
|
|
})();
|