Add chunked uploads and raise storage rate to GHS 0.30/GB/month.
Deploy Ladill Transfer / deploy (push) Successful in 39s

Large files upload in 5 MB chunks (hosting file manager pattern) for the
transfer UI and Ladill Mail S2S API, with no app-level file size cap when
TRANSFER_MAX_FILE_BYTES=0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-08 12:07:27 +00:00
co-authored by Cursor
parent fb131cd7fa
commit 65b634bb0b
23 changed files with 797 additions and 27 deletions
+85
View File
@@ -1,6 +1,7 @@
import './bootstrap';
import { registerLadillConfirmStore, registerLadillModalHelpers } from './ladill-modals';
import { shouldUseChunkedUpload, uploadFileChunked } from './chunked-upload';
registerLadillModalHelpers();
@@ -300,6 +301,90 @@ Alpine.data('topbarSearch', (config = {}) => ({
},
}));
Alpine.data('transferCreateForm', (config = {}) => ({
selectedFiles: [],
uploading: false,
submitting: false,
uploadProgress: 0,
uploadLabel: '',
prepared: false,
onFilesChange(event) {
this.selectedFiles = Array.from(event.target.files || []);
this.prepared = false;
},
async submit(event) {
if (this.prepared) {
return;
}
event.preventDefault();
const form = event.target;
const fileInput = form.querySelector('#files');
const files = this.selectedFiles.length
? this.selectedFiles
: Array.from(fileInput?.files || []);
if (!files.length) {
window.alert('Add at least one file to share.');
return;
}
this.uploading = true;
this.uploadProgress = 0;
const smallFiles = [];
try {
form.querySelectorAll('input[name="upload_ids[]"]').forEach((node) => node.remove());
for (let index = 0; index < files.length; index++) {
const file = files[index];
this.uploadLabel = `Uploading ${file.name} (${index + 1}/${files.length})…`;
if (shouldUseChunkedUpload(file.size)) {
const result = await uploadFileChunked(file, {
initUrl: config.initUrl,
chunkUrl: config.chunkUrl,
finalizeUrl: config.finalizeUrl,
csrfToken: config.csrfToken,
onProgress: (percent) => {
this.uploadProgress = percent;
},
});
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'upload_ids[]';
hidden.value = result.uploadId;
form.appendChild(hidden);
} else {
smallFiles.push(file);
}
}
if (fileInput && smallFiles.length) {
const dataTransfer = new DataTransfer();
smallFiles.forEach((file) => dataTransfer.items.add(file));
fileInput.files = dataTransfer.files;
} else if (fileInput) {
fileInput.removeAttribute('required');
fileInput.value = '';
}
this.uploading = false;
this.submitting = true;
this.prepared = true;
form.submit();
} catch (error) {
this.uploading = false;
this.submitting = false;
this.prepared = false;
window.alert(error?.message || 'Upload failed. Please try again.');
}
},
}));
window.Alpine = Alpine;
registerLadillConfirmStore(Alpine);
+121
View File
@@ -0,0 +1,121 @@
const DEFAULT_CHUNK_SIZE = 5 * 1024 * 1024;
const CHUNKED_THRESHOLD = 5 * 1024 * 1024;
/**
* @param {File} file
* @param {{
* chunkSize?: number,
* initUrl: string,
* chunkUrl: string,
* finalizeUrl: string,
* csrfToken?: string,
* extraInit?: Record<string, unknown>,
* extraChunk?: Record<string, unknown>,
* extraFinalize?: Record<string, unknown>,
* onProgress?: (percent: number) => void,
* }} options
*/
export async function uploadFileChunked(file, options) {
const chunkSize = options.chunkSize || DEFAULT_CHUNK_SIZE;
const totalChunks = Math.ceil(file.size / chunkSize);
const maxRetries = 3;
const headers = { Accept: 'application/json' };
if (options.csrfToken) {
headers['X-CSRF-TOKEN'] = options.csrfToken;
}
const initRes = await fetch(options.initUrl, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: file.name,
filesize: file.size,
...(options.extraInit || {}),
}),
});
const initData = await initRes.json();
if (!initData.success) {
throw new Error(initData.error || initData.message || 'Failed to initialize upload.');
}
const uploadId = initData.upload_id;
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const chunk = file.slice(start, end);
const expectedSize = end - start;
let success = false;
let lastError = null;
for (let attempt = 0; attempt < maxRetries && !success; attempt++) {
try {
const formData = new FormData();
formData.append('upload_id', uploadId);
formData.append('chunk_index', String(i));
formData.append('chunk_size', String(expectedSize));
formData.append('chunk', chunk, `chunk_${i}`);
if (options.extraChunk) {
Object.entries(options.extraChunk).forEach(([key, value]) => {
formData.append(key, String(value));
});
}
const chunkRes = await fetch(options.chunkUrl, {
method: 'POST',
headers,
body: formData,
});
if (!chunkRes.ok) {
throw new Error(`HTTP ${chunkRes.status}`);
}
const chunkData = await chunkRes.json();
if (!chunkData.success) {
throw new Error(chunkData.error || chunkData.message || 'Chunk upload failed.');
}
success = true;
} catch (error) {
lastError = error;
if (attempt < maxRetries - 1) {
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
}
}
}
if (!success) {
throw new Error(
`Failed to upload chunk ${i + 1}/${totalChunks}: ${lastError?.message || 'unknown error'}`,
);
}
if (typeof options.onProgress === 'function') {
options.onProgress(Math.round(((i + 1) / totalChunks) * 100));
}
}
const finalRes = await fetch(options.finalizeUrl, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
upload_id: uploadId,
total_chunks: totalChunks,
...(options.extraFinalize || {}),
}),
});
const finalData = await finalRes.json();
if (!finalData.success && !finalData.data?.public_url) {
throw new Error(finalData.error || finalData.message || 'Failed to finalize upload.');
}
return { uploadId, response: finalData };
}
export function shouldUseChunkedUpload(fileSize, threshold = CHUNKED_THRESHOLD) {
return fileSize > threshold;
}
export { CHUNKED_THRESHOLD, DEFAULT_CHUNK_SIZE };