# File Uploads

**Portal:** Manage · **Routes:** `manage.uploads.*` → `/manage/uploads` · **Permissions:** `view-uploads` (page), `manage-uploads` (upload / delete) · **Nav:** no sidebar entry — a utility page reached by URL, with a `PageHeader` back link

## What it does
Drag, click or **paste (Ctrl/Cmd + V)** a file and get a **permanent, shareable link** — `/file/{uuid}` (`main.upload-file`). Files up to **500 MB**. Accepted types are images, PDF, Office docs, CSV, zip/gz and mp4/mov/webm, checked by content (`StoreRequest::ACCEPTED_TYPES`). The page lists **only your own** uploads (`created_by`), newest 60, and only the uploader can delete one.

## How it works
- **Every file is sent in 8 MiB pieces** (since 2026-09-17), whatever its size. One request through Cloudflare can carry at most 100 MB — over that the edge answers a bare `413` that never reaches PHP. The page calls `utils/chunkedUpload.js`:
  1. `POST /manage/uploads/chunked` with `{name, size}` → `Chunked\StartRequest` checks the extension and the 500 MB ceiling → `201 {id, chunk_bytes, received}`.
  2. `POST /manage/uploads/chunked/{id}/chunks` with `offset` + `chunk`, repeated. `Chunked\ChunkRequest` sniffs the **first** piece's type (`mimes:`). `ChunkedUploadService::append()` writes it under a per-upload lock.
  3. The last piece stores the file through `MediaService::storeFromPath()` into the `upload` collection. The reply is `{complete: true, upload: {uuid, name, url}}`, a "File uploaded." flash is queued, and the page reloads.
- **Retry contract** (controller ⇄ `chunkedUpload.js`):

  | Code | Meaning | Client does |
  |---|---|---|
  | `409` | a piece arrived for the wrong offset, or the previous piece is still being saved | resend from the reply's `received` |
  | `503` | a write or bucket failure | back off (1 s → 30 s) and resend, up to 6 in a row |
  | `404` / `422` | upload expired or not yours / refused | stop and show the message |

  A failed final store keeps the assembled file, so resending the last piece finishes the upload. A retry after success returns the same file — never a second one.
- **Progress + leaving.** The dropzone shows a progress bar and asks before the tab closes mid-upload. A reload loses an in-flight upload — there is no cross-page resume.
- **Serving.** `Main\UploadFileController` streams the object from the private bucket. It uses the **sniffed** MIME recorded on the Media row, never the browser's claim.
- **Legacy single-request path.** `POST /manage/uploads` (`StoreRequest`, 90 MB) stays only for a browser still running the pre-2026-09-17 bundle. Retire it a deploy later.
- **Service-level detail** — locking, temp storage, why not browser → GCS: [Media handbook → Reference usage — files larger than one request](/docs/modules_handbook/shared/media/readMe.md).

## Related files
**Backend**
- [app/Http/Controllers/Manage/Uploads/UploadsController.php](/app/Http/Controllers/Manage/Uploads/UploadsController.php) — `index`, legacy `store`, `destroy`.
- [app/Http/Controllers/Manage/Uploads/ChunkedUploadsController.php](/app/Http/Controllers/Manage/Uploads/ChunkedUploadsController.php) — `store` (open) and `chunk` (piece); JSON with the status-code contract above.
- [app/Http/Requests/Manage/Uploads/StoreRequest.php](/app/Http/Requests/Manage/Uploads/StoreRequest.php) — `ACCEPTED_TYPES`, `TYPES_MESSAGE`, legacy 90 MB rule.
- [app/Http/Requests/Manage/Uploads/Chunked/StartRequest.php](/app/Http/Requests/Manage/Uploads/Chunked/StartRequest.php) — name extension + `MAX_BYTES` (500 MB).
- [app/Http/Requests/Manage/Uploads/Chunked/ChunkRequest.php](/app/Http/Requests/Manage/Uploads/Chunked/ChunkRequest.php) — offset, piece size, content type on piece 0.
- [src/Common/Services/ChunkedUploadService.php](/src/Common/Services/ChunkedUploadService.php) — reassembly, locking, finish via `MediaService`.
- [app/Http/Controllers/Main/UploadFileController.php](/app/Http/Controllers/Main/UploadFileController.php) — the public `/file/{uuid}` stream.

**Frontend**
- [resources/js/Pages/Manage/Uploads/Index.vue](/resources/js/Pages/Manage/Uploads/Index.vue) — dropzone, paste, progress, list, delete.
- [resources/js/utils/chunkedUpload.js](/resources/js/utils/chunkedUpload.js) — the piece loop and retry contract.

**Routes** — [routes/web.php](/routes/web.php) (`manage.uploads.*`), [routes/main.php](/routes/main.php) (`main.upload-file`).

**Tests**
- [tests/Feature/Manage/Uploads/ChunkedUploadTest.php](/tests/Feature/Manage/Uploads/ChunkedUploadTest.php)
- [resources/js/utils/chunkedUpload.test.js](/resources/js/utils/chunkedUpload.test.js)
