mirror of
https://github.com/Forceu/Gokapi.git
synced 2026-05-05 05:50:51 -05:00
Retry on status 429 for 60 seconds, refactoring and formatting
This commit is contained in:
@@ -77,6 +77,12 @@ func getPaths() []converter {
|
||||
Type: "text/javascript",
|
||||
Name: "Public functions JS",
|
||||
})
|
||||
result = append(result, converter{
|
||||
InputPath: pathPrefix + "js/public_upload.js",
|
||||
OutputPath: pathPrefix + "js/min/public_upload.min.js",
|
||||
Type: "text/javascript",
|
||||
Name: "Public upload JS",
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ a:hover {
|
||||
}
|
||||
|
||||
.toastdeprecation {
|
||||
background-color: #8b0000;
|
||||
background-color: #8b0000;
|
||||
}
|
||||
|
||||
.toastnotification.show {
|
||||
@@ -243,9 +243,17 @@ a:hover {
|
||||
}
|
||||
|
||||
@keyframes perm-pulse {
|
||||
0% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.perm-nochange:hover {
|
||||
@@ -257,9 +265,20 @@ a:hover {
|
||||
}
|
||||
|
||||
@keyframes perm-nowgranted-pulse {
|
||||
0% { transform: scale(1.15); color: #4dff4d; }
|
||||
50% { transform: scale(1.3); color: #008800; }
|
||||
100% { transform: scale(1.15); color: #0edf00; }
|
||||
0% {
|
||||
transform: scale(1.15);
|
||||
color: #4dff4d;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
color: #008800;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1.15);
|
||||
color: #0edf00;
|
||||
}
|
||||
}
|
||||
|
||||
.perm-nownotgranted {
|
||||
@@ -267,15 +286,29 @@ a:hover {
|
||||
}
|
||||
|
||||
@keyframes perm-nownotgranted-pulse {
|
||||
0% { transform: scale(1.15); color: #ff4d4d; }
|
||||
50% { transform: scale(1.3); color: #ff0000; }
|
||||
100% { transform: scale(1.15); color: ##9f9999; }
|
||||
0% {
|
||||
transform: scale(1.15);
|
||||
color: #ff4d4d;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.3);
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1.15);
|
||||
color: ##9f9999;
|
||||
}
|
||||
}
|
||||
|
||||
.prevent-select {
|
||||
-webkit-user-select: none; /* Safari */
|
||||
-ms-user-select: none; /* IE 10 and IE 11 */
|
||||
user-select: none; /* Standard syntax */
|
||||
-webkit-user-select: none;
|
||||
/* Safari */
|
||||
-ms-user-select: none;
|
||||
/* IE 10 and IE 11 */
|
||||
user-select: none;
|
||||
/* Standard syntax */
|
||||
}
|
||||
|
||||
|
||||
@@ -287,161 +320,234 @@ a:hover {
|
||||
|
||||
/* Define a subtle animation */
|
||||
@keyframes subtleHighlight {
|
||||
0% {
|
||||
background-color: #444950; /* Light gray for dark background */
|
||||
}
|
||||
100% {
|
||||
background-color: transparent; /* Original background */
|
||||
}
|
||||
0% {
|
||||
background-color: #444950;
|
||||
/* Light gray for dark background */
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: transparent;
|
||||
/* Original background */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes subtleHighlightNewJson {
|
||||
0% {
|
||||
background-color: green; /* Pale green for new items */
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
0% {
|
||||
background-color: green;
|
||||
/* Pale green for new items */
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Apply the animation to the updated table cells */
|
||||
.updatedDownloadCount {
|
||||
animation: subtleHighlight 0.5s ease-out;
|
||||
animation: subtleHighlight 0.5s ease-out;
|
||||
}
|
||||
|
||||
|
||||
.newFileRequest {
|
||||
animation: subtleHighlightNewJson 0.7s ease-out;
|
||||
animation: subtleHighlightNewJson 0.7s ease-out;
|
||||
}
|
||||
|
||||
.newApiKey {
|
||||
animation: subtleHighlightNewJson 0.7s ease-out;
|
||||
animation: subtleHighlightNewJson 0.7s ease-out;
|
||||
}
|
||||
|
||||
.newUser {
|
||||
animation: subtleHighlightNewJson 0.7s ease-out;
|
||||
animation: subtleHighlightNewJson 0.7s ease-out;
|
||||
}
|
||||
|
||||
.newItem {
|
||||
animation: subtleHighlightNewJson 1.5s ease-out;
|
||||
animation: subtleHighlightNewJson 1.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.rowDeleting {
|
||||
animation: fadeOut 0.3s ease-out forwards;
|
||||
animation: fadeOut 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
|
||||
.highlighted-password {
|
||||
background-color: #444; /* Dark gray background for subtle contrast */
|
||||
color: #ddd; /* Light gray text */
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
display: inline-block; /* Keeps the styling inline but ensures proper padding */
|
||||
margin-left: 8px; /* Adds space between the label and the password */
|
||||
border: 1px solid #555; /* Slight border to define the element */
|
||||
background-color: #444;
|
||||
/* Dark gray background for subtle contrast */
|
||||
color: #ddd;
|
||||
/* Light gray text */
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
display: inline-block;
|
||||
/* Keeps the styling inline but ensures proper padding */
|
||||
margin-left: 8px;
|
||||
/* Adds space between the label and the password */
|
||||
border: 1px solid #555;
|
||||
/* Slight border to define the element */
|
||||
}
|
||||
|
||||
/* Slightly lighter than table-dark */
|
||||
.filelist-item {
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
background-color: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.filelist-item:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
|
||||
tr.no-bottom-border td {
|
||||
border-bottom: none
|
||||
border-bottom: none
|
||||
}
|
||||
|
||||
.filerequest-item:hover > td {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
.filerequest-item:hover>td {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.filerequest-item > td {
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
|
||||
.filerequest-item>td {
|
||||
transition: background-color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.collapse-toggle i {
|
||||
display: inline-block;
|
||||
transition: transform 0.2s ease;
|
||||
display: inline-block;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.collapse-toggle[aria-expanded="true"] i {
|
||||
transform: rotate(180deg);
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.collapse-toggle:hover {
|
||||
opacity: 0.8;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.collapse-toggle {
|
||||
padding: 0.25rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.remove-entry-btn:hover {
|
||||
opacity: 0.8;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
|
||||
.upload-box {
|
||||
.upload-box {
|
||||
border: 2px dashed #6c757d;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-box:hover {
|
||||
background-color: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
|
||||
.info-box {
|
||||
background-color: rgba(255,255,255,0.05);
|
||||
.upload-box:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
|
||||
.info-box {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.info-box h6 {
|
||||
.info-box h6 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.info-box ul {
|
||||
.info-box ul {
|
||||
margin-bottom: 0;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
.callout {
|
||||
padding: 20px;
|
||||
margin: 10px 20px;
|
||||
border: 1px solid #eee;
|
||||
border-left-width: 5px;
|
||||
border-radius: 3px;
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
code {
|
||||
border-radius: 3px;
|
||||
}
|
||||
& + .bs-callout {
|
||||
margin-top: -5px;
|
||||
}
|
||||
}
|
||||
|
||||
.callout {
|
||||
padding: 20px;
|
||||
margin: 10px 20px;
|
||||
border: 1px solid #eee;
|
||||
border-left-width: 5px;
|
||||
border-radius: 3px;
|
||||
|
||||
h4 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
&+.bs-callout {
|
||||
margin-top: -5px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-box {
|
||||
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||
padding: 2rem;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upload-box.highlight {
|
||||
border-color: #0d6efd;
|
||||
background-color: rgba(13, 110, 253, 0.05);
|
||||
}
|
||||
|
||||
|
||||
.pu-file-list {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.pu-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.pu-file-item .file-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.pu-file-item .upload-status {
|
||||
width: 350px;
|
||||
text-align: right;
|
||||
margin-right: 1rem;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.75;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pu-file-item .file-size {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,523 @@
|
||||
function createUploadBox() {
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
Array.from(fileInput.files).forEach(file => {
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
document.getElementById('span-modal-error').innerText =
|
||||
`The file "${file.name}" exceeds the maximum allowed size of ${formatSize(MAX_FILE_SIZE)}.`;
|
||||
errorModal.show();
|
||||
return;
|
||||
}
|
||||
document.getElementById('uploadbutton').disabled = false;
|
||||
const uuid = getUuid();
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'pu-file-item';
|
||||
item.dataset.uuid = uuid;
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.textContent = file.name;
|
||||
name.className = 'file-name';
|
||||
|
||||
const progressText = document.createElement('span');
|
||||
progressText.className = 'upload-status';
|
||||
progressText.textContent = 'Ready';
|
||||
|
||||
const progressBar = document.createElement('progress');
|
||||
progressBar.className = 'upload-progress';
|
||||
|
||||
if (file.size == 0) {
|
||||
progressBar.max = 1;
|
||||
} else {
|
||||
progressBar.max = file.size;
|
||||
}
|
||||
progressBar.value = 0;
|
||||
|
||||
const size = document.createElement('span');
|
||||
size.className = 'file-size';
|
||||
size.textContent = formatSize(file.size);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.title = 'Remove';
|
||||
removeBtn.className = 'btn btn-sm btn-link text-light p-0';
|
||||
removeBtn.innerHTML = '<i class="bi bi-x-circle"></i>';
|
||||
removeBtn.onclick = async () => {
|
||||
const entry = filesMap.get(uuid);
|
||||
|
||||
// 1. If currently uploading, abort it
|
||||
if (entry.controller) {
|
||||
entry.controller.abort();
|
||||
}
|
||||
|
||||
// 2. If it has a server reservation, clean it up
|
||||
if (entry.serverUuid) {
|
||||
await unreserve(entry.serverUuid);
|
||||
}
|
||||
|
||||
entry.removed = true;
|
||||
item.remove();
|
||||
updateUploadButtonState();
|
||||
};
|
||||
|
||||
item.append(name, progressText, progressBar, size, removeBtn);
|
||||
fileList.appendChild(item);
|
||||
|
||||
filesMap.set(uuid, {
|
||||
uuid,
|
||||
file,
|
||||
removed: false,
|
||||
controller: new AbortController(),
|
||||
lastSpeed: "",
|
||||
elements: {
|
||||
progressBar,
|
||||
progressText,
|
||||
removeBtn,
|
||||
item
|
||||
}
|
||||
});
|
||||
});
|
||||
// Allow re-selecting same files
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
|
||||
|
||||
// --- Drag and Drop Functionality ---
|
||||
|
||||
// Prevent default behaviors for drag events
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
uploadBox.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
// Highlight box when dragging over
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
uploadBox.addEventListener(eventName, () => uploadBox.classList.add('highlight'), false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
uploadBox.addEventListener(eventName, () => uploadBox.classList.remove('highlight'), false);
|
||||
});
|
||||
|
||||
// Handle dropped files
|
||||
uploadBox.addEventListener('drop', (e) => {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
handleFiles(files);
|
||||
});
|
||||
|
||||
// --- Paste Functionality ---
|
||||
|
||||
window.addEventListener('paste', (e) => {
|
||||
const items = e.clipboardData.items;
|
||||
const files = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
// Handle Files (Images, etc)
|
||||
if (items[i].kind === 'file') {
|
||||
files.push(items[i].getAsFile());
|
||||
}
|
||||
// Handle Text pastes (converts text to a .txt file)
|
||||
else if (items[i].kind === 'string' && items[i].type === 'text/plain') {
|
||||
items[i].getAsString((text) => {
|
||||
const blob = new Blob([text], {
|
||||
type: 'text/plain'
|
||||
});
|
||||
const file = new File([blob], "pasted-text.txt", {
|
||||
type: 'text/plain'
|
||||
});
|
||||
handleFiles([file]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
handleFiles(files);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
function setUnload() {
|
||||
|
||||
// Confirm before closing tab
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
const uploading = Array.from(filesMap.values()).some(f => !f.removed);
|
||||
if (uploading) {
|
||||
// Standard way to trigger a "Are you sure?" browser dialog
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Attempt unreserve on actual exit
|
||||
window.addEventListener('unload', () => {
|
||||
for (const entry of filesMap.values()) {
|
||||
if (!entry.removed && entry.serverUuid) {
|
||||
unreserve(entry.serverUuid);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleFiles(files) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
Array.from(files).forEach(file => dataTransfer.items.add(file));
|
||||
fileInput.files = dataTransfer.files;
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
function updateUploadButtonState() {
|
||||
const btn = document.getElementById("uploadbutton");
|
||||
const pendingFiles = Array.from(filesMap.values()).filter(entry =>
|
||||
!entry.removed && entry.elements.progressText.textContent !== "Completed"
|
||||
);
|
||||
btn.disabled = pendingFiles.length === 0;
|
||||
}
|
||||
|
||||
|
||||
function showModal(modalCode) {
|
||||
let message = "";
|
||||
switch (modalCode) {
|
||||
|
||||
case "alluploaded":
|
||||
new bootstrap.Modal(document.getElementById('allUploadedModal'), {
|
||||
keyboard: false,
|
||||
backdrop: "static"
|
||||
}).show();
|
||||
return;
|
||||
|
||||
case "maxfiles":
|
||||
if (maxFilesRemaining == 1) {
|
||||
message = "Too many files are selected for upload. Please only select 1 file.";
|
||||
} else {
|
||||
message = "Too many files are selected for upload. Please only select " + maxFilesRemaining + " files or fewer.";
|
||||
}
|
||||
break;
|
||||
|
||||
case "maxfilesdynamic":
|
||||
message = "Some files could not be uploaded because the server rejected the request. This likely occurred because another user was uploading files at the same time and the maximum file limit was reached.";
|
||||
break;
|
||||
|
||||
case "expired":
|
||||
message = "The upload request exceeded the permitted time limit, and uploading additional files is no longer possible.";
|
||||
break;
|
||||
}
|
||||
document.getElementById('span-modal-error').innerText = message;
|
||||
errorModal.show();
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
while (bytes >= 1024 && i < units.length - 1) {
|
||||
bytes /= 1024;
|
||||
i++;
|
||||
}
|
||||
return bytes.toFixed(1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
|
||||
async function withRetry(fn, {
|
||||
retries = 3,
|
||||
retryDelay = 5000,
|
||||
onRetry,
|
||||
onWait, // New callback for 429s
|
||||
signal
|
||||
} = {}) {
|
||||
let lastError;
|
||||
let attempt = 1;
|
||||
const startTime = Date.now();
|
||||
const MAX_WAIT_TIME = 60000; // 60 seconds
|
||||
|
||||
while (attempt <= retries) {
|
||||
if (signal && signal.aborted) throw new Error("Cancelled");
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
|
||||
if (err.message === "Cancelled" || (signal && signal.aborted)) throw err;
|
||||
|
||||
// Handle Rate Limiting (429)
|
||||
if (err.status === 429) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
if (elapsed < MAX_WAIT_TIME) {
|
||||
if (onWait) onWait();
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
continue; // "continue" doesn't increment 'attempt', so it retries indefinitely for 60s
|
||||
}
|
||||
}
|
||||
|
||||
// Standard Retry Logic
|
||||
if (onRetry && attempt < retries) {
|
||||
onRetry(attempt, err);
|
||||
}
|
||||
|
||||
if (err.status === 400 || err.status === 401) throw err;
|
||||
|
||||
if (attempt < retries) {
|
||||
attempt++;
|
||||
await new Promise(r => setTimeout(r, retryDelay));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function getQueuedFileCount() {
|
||||
let count = 0;
|
||||
for (const entry of filesMap.values()) {
|
||||
if (!entry.removed) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function initUpload() {
|
||||
const btn = document.getElementById("uploadbutton");
|
||||
btn.disabled = true;
|
||||
startUpload().catch(console.error).finally(() => {
|
||||
updateUploadButtonState();
|
||||
});
|
||||
}
|
||||
|
||||
async function startUpload() {
|
||||
if (!IS_UNLIMITED_FILES && getQueuedFileCount() > maxFilesRemaining) {
|
||||
showModal("maxfiles");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of filesMap.values()) {
|
||||
if (entry.removed) continue;
|
||||
const {
|
||||
file,
|
||||
uuid,
|
||||
elements
|
||||
} = entry;
|
||||
|
||||
// Reset UI state for (re)attempt
|
||||
elements.progressBar.style.display = "";
|
||||
elements.progressText.style.color = "";
|
||||
let lastSpeedText = "";
|
||||
|
||||
try {
|
||||
elements.progressText.textContent = "Reserving...";
|
||||
const serverUuid = await reserveChunk(elements);
|
||||
entry.serverUuid = serverUuid;
|
||||
|
||||
elements.removeBtn.innerHTML = '<i class="bi bi-stop-circle text-danger"></i>';
|
||||
elements.removeBtn.title = "Cancel Upload";
|
||||
|
||||
let offset = 0;
|
||||
// do-while so that add chunk is run for 0byte files as well
|
||||
do {
|
||||
if (entry.controller.signal.aborted) return;
|
||||
const chunk = file.slice(offset, offset + CHUNK_SIZE);
|
||||
|
||||
await withRetry(async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", chunk);
|
||||
formData.append("uuid", serverUuid);
|
||||
formData.append("filesize", file.size);
|
||||
formData.append("offset", offset);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
entry.xhr = xhr;
|
||||
xhr.open("POST", UPLOAD_URL);
|
||||
xhr.setRequestHeader("apikey", API_KEY);
|
||||
xhr.setRequestHeader("fileRequestId", FILE_REQUEST_ID);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Listen for the cancel signal
|
||||
const abortHandler = () => {
|
||||
xhr.abort();
|
||||
reject(new Error("Cancelled"));
|
||||
};
|
||||
entry.controller.signal.addEventListener('abort', abortHandler);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const chunkOffset = offset + event.loaded;
|
||||
const totalSize = file.size === 0 ? 1 : file.size;
|
||||
const percent = Math.floor((chunkOffset / totalSize) * 100);
|
||||
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
if (duration > 0) {
|
||||
// Update the persistent lastSpeedText
|
||||
lastSpeedText = ` (${formatSize(event.loaded / duration)}/s)`;
|
||||
}
|
||||
|
||||
elements.progressBar.value = chunkOffset;
|
||||
elements.progressText.textContent = percent + "%" + lastSpeedText;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = async () => {
|
||||
entry.controller.signal.removeEventListener('abort', abortHandler);
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(await parseXhrError(xhr));
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
const err = new Error(`Server Error`);
|
||||
err.status = xhr.status;
|
||||
reject(err);
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}, {
|
||||
signal: entry.controller.signal,
|
||||
onWait: () => {
|
||||
elements.progressText.textContent = "Waiting for upload slot...";
|
||||
},
|
||||
onRetry: (a, e) => {
|
||||
elements.progressText.textContent = `Retry ${a}/3: ${e.message}${lastSpeedText}`;
|
||||
}
|
||||
});
|
||||
|
||||
offset += chunk.size;
|
||||
} while (offset < file.size);
|
||||
|
||||
await finaliseUpload(file, serverUuid, elements);
|
||||
|
||||
elements.progressText.textContent = "Completed";
|
||||
elements.item.style.opacity = "0.6";
|
||||
elements.removeBtn.remove(); // Remove button only on success
|
||||
|
||||
filesMap.get(uuid).removed = true;
|
||||
maxFilesRemaining--;
|
||||
|
||||
if (maxFilesRemaining === 0) showModal("alluploaded");
|
||||
|
||||
} catch (err) {
|
||||
if (err.message === "Cancelled" || entry.controller.signal.aborted) return;
|
||||
|
||||
elements.progressText.textContent = err.message || "Upload failed";
|
||||
elements.progressText.style.color = "#ff6b6b";
|
||||
elements.progressBar.style.display = "none";
|
||||
|
||||
elements.removeBtn.innerHTML = '<i class="bi bi-trash"></i>';
|
||||
elements.removeBtn.title = "Remove from list";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function parseErrorResponse(response) {
|
||||
const text = await response.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
if (data && data.Result === "error") {
|
||||
let message;
|
||||
switch (data.ErrorCode) {
|
||||
case 9:
|
||||
message = "File size limit exceeded";
|
||||
break;
|
||||
case 14:
|
||||
message = "Upload request has expired";
|
||||
showModal("expired");
|
||||
break;
|
||||
case 15:
|
||||
message = "Maximum file count reached";
|
||||
showModal("maxfilesdynamic");
|
||||
break;
|
||||
case 16:
|
||||
message = "Too many requests, please try again later";
|
||||
break;
|
||||
default:
|
||||
message = data.ErrorMessage || "Unknown upload error";
|
||||
}
|
||||
const err = new Error(message);
|
||||
err.status = response.status;
|
||||
err.code = data.ErrorCode;
|
||||
err.raw = data;
|
||||
return err;
|
||||
}
|
||||
// Fallback: plain text / non-JSON error
|
||||
const err = new Error(text || `HTTP ${response.status}`);
|
||||
err.status = response.status;
|
||||
return err;
|
||||
}
|
||||
|
||||
async function reserveChunk(elements) {
|
||||
return withRetry(async () => {
|
||||
const response = await fetch(RESERVE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
id: FILE_REQUEST_ID,
|
||||
apikey: API_KEY
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await parseErrorResponse(response);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data.Uuid) throw new Error("Invalid reserve response");
|
||||
return data.Uuid;
|
||||
}, {
|
||||
onRetry: (a, e) => {
|
||||
elements.progressText.textContent = `Retry ${a}/3: ${e.message}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function finaliseUpload(file, uuid, elements) {
|
||||
await withRetry(async () => {
|
||||
const response = await fetch(COMPLETE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
uuid,
|
||||
fileRequestId: FILE_REQUEST_ID,
|
||||
filename: encodeFilename(file.name),
|
||||
filesize: file.size,
|
||||
nonblocking: true,
|
||||
contenttype: file.type || "application/octet-stream",
|
||||
apikey: API_KEY
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await parseErrorResponse(response);
|
||||
}
|
||||
}, {
|
||||
onRetry: (a, e) => {
|
||||
elements.progressText.textContent = `Retry ${a}/3: ${e.message}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function encodeFilename(name) {
|
||||
return "base64:" + Base64.encode(name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function unreserve(uuid) {
|
||||
if (!uuid) return;
|
||||
try {
|
||||
await fetch(UNRESERVE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
uuid: uuid,
|
||||
apikey: API_KEY,
|
||||
id: FILE_REQUEST_ID
|
||||
},
|
||||
keepalive: true // Crucial for calls during page unload
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Unreserve failed", e);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,5 @@
|
||||
{{define "publicUpload"}}{{template "header" .}}
|
||||
|
||||
|
||||
<style>
|
||||
|
||||
.upload-box {
|
||||
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||
padding: 2rem;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upload-box.highlight {
|
||||
border-color: #0d6efd;
|
||||
background-color: rgba(13, 110, 253, 0.05);
|
||||
}
|
||||
|
||||
|
||||
.pu-file-list {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.pu-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.pu-file-item .file-name {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.pu-file-item .upload-status {
|
||||
width: 350px;
|
||||
text-align: right;
|
||||
margin-right: 1rem;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.75;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pu-file-item .file-size {
|
||||
width: 80px;
|
||||
text-align: right;
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<main style="margin-top: 2rem">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
@@ -101,7 +41,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
{{ end }}
|
||||
<label for="fileInput" class="upload-box text-center w-100">
|
||||
<label for="fileInput" id="uploadBox" class="upload-box text-center w-100">
|
||||
<p class="mb-2 fs-5">Drag & drop files here</p>
|
||||
<p class="mb-0 opacity-75">or paste or click to select</p>
|
||||
<input type="file" id="fileInput" class="d-none" multiple>
|
||||
@@ -151,6 +91,7 @@
|
||||
</div>
|
||||
|
||||
|
||||
<script src="./js/min/public_upload.min.js"></script>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -158,181 +99,9 @@ const fileInput = document.getElementById('fileInput');
|
||||
const fileList = document.getElementById('fileList');
|
||||
const filesMap = new Map();
|
||||
const errorModal = new bootstrap.Modal(document.getElementById('errorModal'));
|
||||
const uploadBox = document.getElementById('uploadBox');
|
||||
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
Array.from(fileInput.files).forEach(file => {
|
||||
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
document.getElementById('span-modal-error').innerText =
|
||||
`The file "${file.name}" exceeds the maximum allowed size of ${formatSize(MAX_FILE_SIZE)}.`;
|
||||
errorModal.show();
|
||||
return;
|
||||
}
|
||||
document.getElementById('uploadbutton').disabled = false;
|
||||
const uuid = getUuid();
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'pu-file-item';
|
||||
item.dataset.uuid = uuid;
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.textContent = file.name;
|
||||
name.className = 'file-name';
|
||||
|
||||
const progressText = document.createElement('span');
|
||||
progressText.className = 'upload-status';
|
||||
progressText.textContent = 'Ready';
|
||||
|
||||
const progressBar = document.createElement('progress');
|
||||
progressBar.className = 'upload-progress';
|
||||
|
||||
if (file.size == 0) {
|
||||
progressBar.max = 1;
|
||||
} else {
|
||||
progressBar.max = file.size;
|
||||
}
|
||||
progressBar.value = 0;
|
||||
|
||||
const size = document.createElement('span');
|
||||
size.className = 'file-size';
|
||||
size.textContent = formatSize(file.size);
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.type = 'button';
|
||||
removeBtn.title = 'Remove';
|
||||
removeBtn.className = 'btn btn-sm btn-link text-light p-0';
|
||||
removeBtn.innerHTML = '<i class="bi bi-x-circle"></i>';
|
||||
removeBtn.onclick = () => {
|
||||
filesMap.get(uuid).removed = true;
|
||||
item.remove();
|
||||
};
|
||||
|
||||
item.append(name, progressText, progressBar, size, removeBtn);
|
||||
fileList.appendChild(item);
|
||||
|
||||
filesMap.set(uuid, {
|
||||
uuid,
|
||||
file,
|
||||
removed: false,
|
||||
controller: new AbortController(),
|
||||
lastSpeed: "",
|
||||
elements: {
|
||||
progressBar,
|
||||
progressText,
|
||||
removeBtn,
|
||||
item
|
||||
}
|
||||
});
|
||||
});
|
||||
// Allow re-selecting same files
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
|
||||
const uploadBox = document.querySelector('.upload-box');
|
||||
|
||||
// --- Drag and Drop Functionality ---
|
||||
|
||||
// Prevent default behaviors for drag events
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
uploadBox.addEventListener(eventName, (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, false);
|
||||
});
|
||||
|
||||
// Highlight box when dragging over
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
uploadBox.addEventListener(eventName, () => uploadBox.classList.add('highlight'), false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
uploadBox.addEventListener(eventName, () => uploadBox.classList.remove('highlight'), false);
|
||||
});
|
||||
|
||||
// Handle dropped files
|
||||
uploadBox.addEventListener('drop', (e) => {
|
||||
const dt = e.dataTransfer;
|
||||
const files = dt.files;
|
||||
handleFiles(files);
|
||||
});
|
||||
|
||||
// --- Paste Functionality ---
|
||||
|
||||
window.addEventListener('paste', (e) => {
|
||||
const items = e.clipboardData.items;
|
||||
const files = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
// Handle Files (Images, etc)
|
||||
if (items[i].kind === 'file') {
|
||||
files.push(items[i].getAsFile());
|
||||
}
|
||||
// Handle Text pastes (converts text to a .txt file)
|
||||
else if (items[i].kind === 'string' && items[i].type === 'text/plain') {
|
||||
items[i].getAsString((text) => {
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const file = new File([blob], "pasted-text.txt", { type: 'text/plain' });
|
||||
handleFiles([file]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
handleFiles(files);
|
||||
}
|
||||
});
|
||||
|
||||
function handleFiles(files) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
Array.from(files).forEach(file => dataTransfer.items.add(file));
|
||||
fileInput.files = dataTransfer.files;
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
}
|
||||
|
||||
|
||||
function showModal(modalCode) {
|
||||
let message = "";
|
||||
switch (modalCode) {
|
||||
|
||||
case "alluploaded":
|
||||
new bootstrap.Modal(document.getElementById('allUploadedModal'), {
|
||||
keyboard: false,
|
||||
backdrop: "static"
|
||||
}).show();
|
||||
return;
|
||||
|
||||
case "maxfiles":
|
||||
if (maxFilesRemaining == 1) {
|
||||
message = "Too many files are selected for upload. Please only select 1 file.";
|
||||
} else {
|
||||
message = "Too many files are selected for upload. Please only select " + maxFilesRemaining + " files or fewer.";
|
||||
}
|
||||
break;
|
||||
|
||||
case "maxfilesdynamic":
|
||||
message = "Some files could not be uploaded because the server rejected the request. This likely occurred because another user was uploading files at the same time and the maximum file limit was reached.";
|
||||
break;
|
||||
|
||||
case "expired":
|
||||
message = "The upload request exceeded the permitted time limit, and uploading additional files is no longer possible.";
|
||||
break;
|
||||
}
|
||||
document.getElementById('span-modal-error').innerText = message;
|
||||
errorModal.show();
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let i = 0;
|
||||
while (bytes >= 1024 && i < units.length - 1) {
|
||||
bytes /= 1024;
|
||||
i++;
|
||||
}
|
||||
return bytes.toFixed(1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = {{.ChunkSize}} * 1024 * 1024;
|
||||
const API_BASE = "./api/uploadrequest/chunk/";
|
||||
const RESERVE_URL = API_BASE + "reserve";
|
||||
@@ -346,314 +115,8 @@ const IS_UNLIMITED_FILES = {{ .FileRequest.IsUnlimitedFiles }};
|
||||
const IS_UNLIMITED_TIME = {{ .FileRequest.IsUnlimitedTime }};
|
||||
var maxFilesRemaining = {{.FileRequest.FilesRemaining}};
|
||||
|
||||
|
||||
async function withRetry(fn, {
|
||||
retries = 3,
|
||||
retryDelay = 5000,
|
||||
onRetry,
|
||||
signal} = {}) {
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= retries; attempt++) {
|
||||
// Exit if cancelled
|
||||
if (signal && signal.aborted) throw new Error("Cancelled");
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
if (err.message === "Cancelled" || (signal && signal.aborted)) throw err; // Don't retry cancellations
|
||||
|
||||
if (onRetry && attempt < retries) {
|
||||
onRetry(attempt, err);
|
||||
}
|
||||
if (err.status === 400 || err.status === 401) throw err;
|
||||
if (attempt < retries) {
|
||||
await new Promise(r => setTimeout(r, retryDelay));
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function getQueuedFileCount() {
|
||||
let count = 0;
|
||||
for (const entry of filesMap.values()) {
|
||||
if (!entry.removed) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function initUpload() {
|
||||
const btn = document.getElementById("uploadbutton");
|
||||
btn.disabled = true;
|
||||
startUpload().catch(console.error).finally(() => {
|
||||
// Check if there are still files pending (failed uploads)
|
||||
// If count > 0, re-enable button. If 0, keep it disabled.
|
||||
if (getQueuedFileCount() > 0) {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function startUpload() {
|
||||
if (!IS_UNLIMITED_FILES && getQueuedFileCount() > maxFilesRemaining) {
|
||||
showModal("maxfiles");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of filesMap.values()) {
|
||||
if (entry.removed) continue;
|
||||
const {
|
||||
file,
|
||||
uuid,
|
||||
elements
|
||||
} = entry;
|
||||
|
||||
// Reset UI state for (re)attempt
|
||||
elements.progressBar.style.display = "";
|
||||
elements.progressText.style.color = "";
|
||||
let lastSpeedText = "";
|
||||
|
||||
try {
|
||||
elements.progressText.textContent = "Reserving...";
|
||||
const serverUuid = await reserveChunk(elements);
|
||||
entry.serverUuid = serverUuid;
|
||||
|
||||
// Change button to "Cancel" once we have a reservation
|
||||
elements.removeBtn.innerHTML = '<i class="bi bi-stop-circle text-danger"></i>';
|
||||
elements.removeBtn.title = "Cancel Upload";
|
||||
elements.removeBtn.onclick = async () => {
|
||||
entry.controller.abort(); // Triggers the abort signal
|
||||
await unreserve(serverUuid);
|
||||
entry.removed = true;
|
||||
elements.item.remove();
|
||||
};
|
||||
|
||||
let offset = 0;
|
||||
// do-while so that add chunk is run for 0byte files as well
|
||||
do {
|
||||
if (entry.controller.signal.aborted) return;
|
||||
const chunk = file.slice(offset, offset + CHUNK_SIZE);
|
||||
|
||||
await withRetry(async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", chunk);
|
||||
formData.append("uuid", serverUuid);
|
||||
formData.append("filesize", file.size);
|
||||
formData.append("offset", offset);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
entry.xhr = xhr;
|
||||
xhr.open("POST", UPLOAD_URL);
|
||||
xhr.setRequestHeader("apikey", API_KEY);
|
||||
xhr.setRequestHeader("fileRequestId", FILE_REQUEST_ID);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Listen for the cancel signal
|
||||
const abortHandler = () => {
|
||||
xhr.abort();
|
||||
reject(new Error("Cancelled"));
|
||||
};
|
||||
entry.controller.signal.addEventListener('abort', abortHandler);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const chunkOffset = offset + event.loaded;
|
||||
const totalSize = file.size === 0 ? 1 : file.size;
|
||||
const percent = Math.floor((chunkOffset / totalSize) * 100);
|
||||
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
if (duration > 0) {
|
||||
// Update the persistent lastSpeedText
|
||||
lastSpeedText = ` (${formatSize(event.loaded / duration)}/s)`;
|
||||
}
|
||||
|
||||
elements.progressBar.value = chunkOffset;
|
||||
elements.progressText.textContent = percent + "%" + lastSpeedText;
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = async () => {
|
||||
entry.controller.signal.removeEventListener('abort', abortHandler);
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(await parseXhrError(xhr));
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
entry.controller.signal.removeEventListener('abort', abortHandler);
|
||||
reject(new Error("Network Error"));
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}, {
|
||||
signal: entry.controller.signal,
|
||||
onRetry: (a, e) => {
|
||||
// Keep the speed text visible during retries
|
||||
elements.progressText.textContent = `Retry ${a}/3: ${e.message}${lastSpeedText}`;
|
||||
}
|
||||
});
|
||||
|
||||
offset += chunk.size;
|
||||
} while (offset < file.size);
|
||||
|
||||
await finaliseUpload(file, serverUuid, elements);
|
||||
|
||||
elements.progressText.textContent = "Completed";
|
||||
elements.item.style.opacity = "0.6";
|
||||
elements.removeBtn.remove(); // Remove button only on success
|
||||
|
||||
filesMap.get(uuid).removed = true;
|
||||
maxFilesRemaining--;
|
||||
|
||||
if (maxFilesRemaining === 0) showModal("alluploaded");
|
||||
|
||||
} catch (err) {
|
||||
if (err.message === "Cancelled" || entry.controller.signal.aborted) return;
|
||||
|
||||
elements.progressText.textContent = err.message || "Upload failed";
|
||||
elements.progressText.style.color = "#ff6b6b";
|
||||
elements.progressBar.style.display = "none";
|
||||
|
||||
// Change button to "Trash" for failed uploads to allow removal
|
||||
elements.removeBtn.innerHTML = '<i class="bi bi-trash"></i>';
|
||||
elements.removeBtn.title = "Remove from list";
|
||||
elements.removeBtn.onclick = async () => {
|
||||
if (entry.serverUuid) await unreserve(entry.serverUuid);
|
||||
entry.removed = true;
|
||||
elements.item.remove();
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function parseErrorResponse(response) {
|
||||
const text = await response.text();
|
||||
let data = null;
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
/* not JSON */
|
||||
}
|
||||
if (data && data.Result === "error") {
|
||||
let message;
|
||||
switch (data.ErrorCode) {
|
||||
case 9:
|
||||
message = "File size limit exceeded";
|
||||
break;
|
||||
case 14:
|
||||
message = "Upload request has expired";
|
||||
showModal("expired");
|
||||
break;
|
||||
case 15:
|
||||
message = "Maximum file count reached";
|
||||
showModal("maxfilesdynamic");
|
||||
break;
|
||||
case 16:
|
||||
message = "Too many requests, please try again later";
|
||||
break;
|
||||
default:
|
||||
message = data.ErrorMessage || "Unknown upload error";
|
||||
}
|
||||
const err = new Error(message);
|
||||
err.status = response.status;
|
||||
err.code = data.ErrorCode;
|
||||
err.raw = data;
|
||||
return err;
|
||||
}
|
||||
// Fallback: plain text / non-JSON error
|
||||
const err = new Error(text || `HTTP ${response.status}`);
|
||||
err.status = response.status;
|
||||
return err;
|
||||
}
|
||||
|
||||
async function reserveChunk(elements) {
|
||||
return withRetry(async () => {
|
||||
const response = await fetch(RESERVE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
id: FILE_REQUEST_ID,
|
||||
apikey: API_KEY
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await parseErrorResponse(response);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!data.Uuid) throw new Error("Invalid reserve response");
|
||||
return data.Uuid;
|
||||
}, {
|
||||
onRetry: (a, e) => {
|
||||
elements.progressText.textContent = `Retry ${a}/3: ${e.message}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function finaliseUpload(file, uuid, elements) {
|
||||
await withRetry(async () => {
|
||||
const response = await fetch(COMPLETE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
uuid,
|
||||
fileRequestId: FILE_REQUEST_ID,
|
||||
filename: encodeFilename(file.name),
|
||||
filesize: file.size,
|
||||
nonblocking: true,
|
||||
contenttype: file.type || "application/octet-stream",
|
||||
apikey: API_KEY
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw await parseErrorResponse(response);
|
||||
}
|
||||
}, {
|
||||
onRetry: (a, e) => {
|
||||
elements.progressText.textContent = `Retry ${a}/3: ${e.message}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function encodeFilename(name) {
|
||||
return "base64:" + Base64.encode(name);
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function unreserve(uuid) {
|
||||
if (!uuid) return;
|
||||
try {
|
||||
await fetch(UNRESERVE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
uuid: uuid,
|
||||
apikey: API_KEY,
|
||||
id: FILE_REQUEST_ID
|
||||
},
|
||||
keepalive: true // Crucial for calls during page unload
|
||||
});
|
||||
} catch (e) { console.error("Unreserve failed", e); }
|
||||
}
|
||||
|
||||
// Confirm before closing tab
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
const uploading = Array.from(filesMap.values()).some(f => !f.removed);
|
||||
if (uploading) {
|
||||
// Standard way to trigger a "Are you sure?" browser dialog
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Attempt unreserve on actual exit
|
||||
window.addEventListener('unload', () => {
|
||||
for (const entry of filesMap.values()) {
|
||||
if (!entry.removed && entry.serverUuid) {
|
||||
unreserve(entry.serverUuid);
|
||||
}
|
||||
}
|
||||
});
|
||||
createUploadBox();
|
||||
setUnload();
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user