Add chunked uploads and raise storage rate to GHS 0.30/GB/month.
Deploy Ladill Transfer / deploy (push) Successful in 39s
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="rounded-2xl border border-indigo-100 bg-indigo-50/60 p-6">
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-indigo-600">Wallet balance</p>
|
||||
<p class="mt-1.5 text-2xl font-semibold text-slate-900">{{ $fmt($balanceMinor) }}</p>
|
||||
<p class="mt-2 text-sm text-slate-600">Storage is billed at GHS {{ number_format((float) config('transfer.price_per_gb_month', 0.15), 2) }} per GB per month from your Ladill wallet.</p>
|
||||
<p class="mt-2 text-sm text-slate-600">Storage is billed at GHS {{ number_format((float) config('transfer.price_per_gb_month', 0.30), 2) }} per GB per month from your Ladill wallet.</p>
|
||||
<div class="mt-4 space-y-3">
|
||||
<a href="{{ route('transfer.transfers.create') }}" class="flex items-center justify-between rounded-xl border border-white bg-white px-4 py-3 text-sm hover:bg-indigo-50/50">
|
||||
<span class="font-medium text-slate-700">New transfer</span>
|
||||
|
||||
@@ -11,7 +11,15 @@
|
||||
<div class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
<form method="post" action="{{ route('transfer.transfers.store') }}" enctype="multipart/form-data" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<form method="post" action="{{ route('transfer.transfers.store') }}" enctype="multipart/form-data"
|
||||
x-data="transferCreateForm(@js([
|
||||
'initUrl' => route('transfer.transfers.upload.init'),
|
||||
'chunkUrl' => route('transfer.transfers.upload.chunk'),
|
||||
'finalizeUrl' => route('transfer.transfers.upload.finalize'),
|
||||
'csrfToken' => csrf_token(),
|
||||
]))"
|
||||
@submit.prevent="submit"
|
||||
class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-slate-700">Title</label>
|
||||
@@ -28,9 +36,17 @@
|
||||
|
||||
<div>
|
||||
<label for="files" class="block text-sm font-medium text-slate-700">Files</label>
|
||||
<input type="file" name="files[]" id="files" multiple required
|
||||
<input type="file" id="files" multiple required @change="onFilesChange"
|
||||
class="mt-1 block w-full text-sm text-slate-600 file:mr-4 file:rounded-lg file:border-0 file:bg-indigo-50 file:px-4 file:py-2 file:text-sm file:font-semibold file:text-indigo-700 hover:file:bg-indigo-100">
|
||||
<p class="mt-1 text-xs text-slate-500">Up to {{ $maxFiles }} files. Max 500 MB each.</p>
|
||||
<p class="mt-1 text-xs text-slate-500">Up to {{ $maxFiles }} files. Large files upload in small chunks — no practical size limit.</p>
|
||||
<template x-if="uploading">
|
||||
<div class="mt-2">
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div class="h-full rounded-full bg-indigo-600 transition-all" :style="`width: ${uploadProgress}%`"></div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500" x-text="uploadLabel"></p>
|
||||
</div>
|
||||
</template>
|
||||
@error('files')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||
@error('files.*')<p class="mt-1 text-xs text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
@@ -52,7 +68,11 @@
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<a href="{{ route('transfer.transfers.index') }}" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50">Cancel</a>
|
||||
<button type="submit" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700">Create transfer</button>
|
||||
<button type="submit" class="rounded-xl bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
|
||||
:disabled="uploading || submitting">
|
||||
<span x-show="!uploading && !submitting">Create transfer</span>
|
||||
<span x-show="uploading || submitting" x-cloak>Uploading…</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user