Table of Contents
- Overview
- Authentication
- Base URL
- File Types & The
FileModel - Laravel Filesystem Configuration
- Disks (
config/filesystems.php) - Local / Public Disk
- Cloudflare R2 Disk
.envSettings — Local Storage.envSettings — R2 Storage- Storage Stats
.envSettings
- Disks (
- Public Web Root & Apache (
public/.htaccess)- Directory listing & content negotiation
- Authorization header forwarding
- Front-controller routing
- How it relates to file serving
- CORS Configuration
- Laravel API CORS (
config/cors.php) - R2 Bucket CORS (Cloudflare Dashboard)
- Local Static-File CORS (Plesk / nginx) — serving
/storage/
- Laravel API CORS (
- Endpoints
- Local Storage (
FilesController)- 1. Upload File
- 2. Get File as Base64
- 3. Delete File
- 4. Bulk Upload to Local Storage
- R2 Cloud Storage (
FilesRController)- 5. Upload File to R2
- 6. Get R2 File as Base64
- 7. Delete R2 File (Hard Delete)
- 8. R2 Bulk Upload
- 9. R2 Storage Stats
- 10. Local Storage Stats
- 11. List All Backend Files
- 12. List R2 Folders
- 13. Test R2 Controller
- Local Storage (
- Postman Setup
- Error Handling
- Frontend Integration — Flutter Web (CanvasKit) Image CORS
- Why CanvasKit needs CORS on images
- Why the backend’s “fully open” CORS doesn’t cover
/storage/images - Per-host CORS responsibility (quick decision guide)
- The two
.htaccessfiles — keep them separate
Overview
The File Management API handles uploading, retrieving, listing, and deleting files across two storage backends:- Local storage — files stored on the server disk under
storage/app/public/files/, served through thepublicsymbolic link as/storage/files/.... Handled byFilesController(app/Http/Controllers/FilesController.php). - Cloudflare R2 — an S3-compatible object store, accessed via the
r2disk usingleague/flysystem-aws-s3-v3. Handled byFilesRController(app/Http/Controllers/FilesRController.php).
files table through the File model (app/Models/File.php), regardless of where the bytes are physically stored. External URLs (type 2) are recorded with no physical file.
The API also exposes storage-monitoring endpoints (capacity, growth projection, file-type breakdown) for both local and R2 storage.
Multi-tenancy note: every upload requires a validcompany_id, and all file endpoints sit behindcompany_policy_middleware, which forces the requestedcompany_idto match the authenticated user’s company (super-admins bypass).
Authentication
All file endpoints are protected and require a Bearer Token via Laravel Sanctum:POST /api/auth/login.
The full middleware stack applied to every file route is:
- Authenticated (valid Sanctum token)
- From a non-blocked user
- Scoped to the user’s own
company_id(super-admins excepted)
GET /api/test_files_r2 requires authentication.
Base URL
File Types & The File Model
The File model defines three storage types as string constants:
files table columns (fillable):
The model uses the
Archivable trait + SoftDeletes. Timestamps serialize as Y-m-d H:i:s.
File model deleting hook: when aFileis deleted, abooted()observer attempts to remove the matching file frompublic/files/{file_name}and logs anarchiveaction in the history table. This hook only touches the local disk — it does not delete R2 objects. R2 objects must be removed explicitly by the caller before deleting theFilerow:FilesRControllerdoes this on hard delete (/r2_delete_file/{id}), andUserBackgroundItemObserverdoes it for instructor background-item logos (type = 3) on replace/delete (see the Instructor API doc, §20.5).
Laravel Filesystem Configuration
Disks (config/filesystems.php)
The default disk is driven by the FILESYSTEM_DISK env variable (defaults to local):
local, public, s3, and r2. The file API only uses public (local uploads) and r2 (cloud uploads).
Local / Public Disk
- Local uploads are written under
storage/app/public/files/.... - They are served publicly through the symbolic link
public/storage → storage/app/public(created viaphp artisan storage:link). - Public URLs look like:
{APP_URL}/storage/files/{file_name}.
Important: runphp artisan storage:linkonce on each environment, otherwise/storage/...URLs will 404.
Cloudflare R2 Disk
R2 is configured as an S3-compatible disk:- All R2 uploads are stored under the
files/prefix in the bucket. - Some features write into their own sub-folder under
files/so objects stay grouped (visible via List R2 Folders, endpoint #12). Known sub-folders:files/all-lessons/{training-slug}/— lesson images and PDFs from the content editor ({lessonId}_prefix for a saved lesson), with…/bulk/for lessons imported from images (POST /lessons/import_to_r2).files/all-classrooms/{classroom-slug}/— classroom content images and PDFs;files/all-quizzes/{quiz-slug}/— question images.files/all-chatrooms/{chatroom-slug}/— chatroom message images;files/backups/conversations/{chatroom-slug}/— chatroom backup CSVs.files/instructor_bg_items/— instructor background-item logos (Background Items API, Instructor API doc §20). Object keys are{userId}_{time}_{originalName}.- Every key is built by
app/Services/R2StoragePathService.phpsince 01/11/2026; the full map, with the keys written before that date (older objects keep them:files/flat,chatrooms/{slug}/,files/conversations/,files/instructor_background_items/), is on R2 Storage Paths.
- The public URL is built from
R2_URL(the public*.r2.devURL) or, when set,R2_CUSTOM_DOMAIN. - The long HTTP timeouts (
connect_timeout60s,timeout500s) support large uploads.
.env Settings — Local Storage
IfLOCAL_STORAGE_LIMIT_GBis not set, thelocal_storage_statsendpoint returnsnullformax_gb,remaining_gb, andpercentage_used, but still reports partition-wide and files-area usage.
.env Settings — R2 Storage
R2_CUSTOM_DOMAINis optional. When present, single uploads (/r2_upload_file) additionally return acustom_domain_urlfield built from this domain.
Storage Stats .env Settings
The R2 stats endpoint also accepts a?threshold=query override (0–1) and a?prefix=scope.
Public Web Root & Apache (public/.htaccess)
When the API is served by Apache (with mod_rewrite), the web root is the Laravel public/ directory, and public/.htaccess controls how incoming HTTP requests are rewritten before they reach public/index.php. This file is directly relevant to file management because local uploads are served as static files from inside public/ (via the public/storage symlink — see Local / Public Disk above), while every API call is funneled to the Laravel front controller.
The full file:
This is the standard Laravelpublic/.htaccess, unmodified. It only takes effect on Apache; Nginx deployments ignore it and must replicate the same behavior in the server block (try_files $uri $uri/ /index.php?$query_string;plus anfastcgi_pass ... HTTP_AUTHORIZATIONmapping).
Directory listing & content negotiation
-Indexes— disables auto directory listings. Requesting a folder underpublic/(e.g./storage/files/) does not return a browsable index; without an index file it returns403 Forbidden. This prevents enumeration of uploaded files inpublic/storage/files/....-MultiViews— disables Apache content negotiation so the rewrite rules (not Apache’s guesswork) decide which resource is served.
Authorization header forwarding
Authorization header before it reaches PHP. This rule re-exposes it as the HTTP_AUTHORIZATION environment variable so Laravel can read the Sanctum Bearer token. Without this, every authenticated file endpoint would see no token and return 401. This is why it matters for the file API specifically — all upload/list/delete/stats endpoints require the Bearer token.
Front-controller routing
- Trailing-slash redirect — a
301strips a trailing/from non-directory URLs to keep them canonical. - Front controller — the key conditions are
!-d(not an existing directory) and!-f(not an existing file). If the request maps to a real file or directory on disk, Apache serves it directly; otherwise the request is rewritten toindex.phpand handled by Laravel’s router (routes/api.php).
How it relates to file serving
- Local uploads (
type = 1) live understorage/app/public/files/...and are exposed through thepublic/storagesymlink. Because those resolve to real files (-fpasses), Apache serves them statically and they never hit Laravel — that is why/storage/files/{file_name}URLs are fast and unauthenticated, and whyphp artisan storage:linkis mandatory (no symlink → no real file → request falls through toindex.php→404). - API calls (
/api/upload_file,/api/r2_*, etc.) do not map to a real file, so they fall through toindex.phpand run through the Sanctum-protected middleware stack. - R2 files (
type = 3) are not served by this.htaccessat all — they are fetched directly from the Cloudflare R2 public URL (R2_URL/R2_CUSTOM_DOMAIN), governed by R2’s own CORS policy (see below).
CORS Configuration
CORS is configured inconfig/cors.php and is currently fully open (development posture):
⚠️ Production warning:The commented-out (intended) origin list in the file:allowed_origins: ['*']combined withsupports_credentials: trueis permissive and not recommended for production. The config file contains a commented-out list of intended production origins (e.g.https://elearning-prod.the-team.tn,*.the-team.tn,http://localhost:3000). Restrictallowed_originsbefore deploying.
Base64 endpoints exist partly because of CORS: fetching R2/external images client-side can hit browser CORS restrictions. Thebase64andr2_base64endpoints return the file content inline (base64) so the frontend can render it without cross-origin fetches.
R2 Bucket CORS (Cloudflare Dashboard)
The Laravelconfig/cors.php above only governs the application API (backend → frontend). R2 is a separate service: when the browser fetches files directly from the R2 public URL (R2_URL / R2_CUSTOM_DOMAIN), CORS is enforced by Cloudflare R2, not Laravel. This is configured in the Cloudflare Dashboard → R2 → your bucket → Settings → CORS Policy, not in this codebase.
Where: Cloudflare Dashboard → R2 → {bucket} → Settings → CORS Policy. The panel description reads: “Configure CORS headers that R2 will set when accessing this bucket’s contents from a browser.”
The CORS policy applied to the bucket:
⚠️ Production note: like the Laravelallowed_origins,AllowedOrigins: ["*"]is permissive. Tighten it to your real frontend origins (e.g.https://elearning-prod.the-team.tn) before going to production. This R2 CORS policy is also documented inapp/R2_ReadMe.md.
Local Static-File CORS (Plesk / nginx) — serving /storage/
There is a third CORS layer that the two configs above do not cover: local-disk files served directly by the web server. On the production/test box, the backend runs behind Plesk with nginx in front of Apache, and Plesk’s “Serve static files directly by nginx” is enabled for image extensions (png jpg jpeg gif svg webp …).
This means requests for GET /storage/files/*.png|jpg|svg|webp… (the type = 1 local uploads, exposed via the public/storage symlink) are answered by nginx straight from disk — they never reach Apache, public/.htaccess, or Laravel’s config/cors.php. Consequently none of the CORS configuration documented above runs on them, and the response carries no Access-Control-Allow-Origin header.
This is invisible to a normal browser tab (top-level navigation isn’t subject to CORS) but breaks cross-origin programmatic reads — most notably the Flutter Web (CanvasKit) frontend, which must fetch image bytes to decode them (see Frontend Integration at the bottom of this doc).
Header evidence (captured with curl -sI):
The fix (Plesk — backend backend-test.theteam.com.tn)
Plesk → Websites & Domains → backend-test.theteam.com.tn → Apache & nginx Settings.
Step 1 — remove the dead Apache directive (optional cleanup). In Additional Apache directives → Additional directives for HTTP, the old line
config/cors.php) for the /api routes Apache/PHP actually serves.
Step 2 — add the header at the nginx layer, scoped to /storage/. In nginx settings → Additional nginx directives, add:
The same box also holds the chatroom WebSocket proxy (
location ^~ /app/) and the client_max_body_size 25m; upload limit. The blocks do not conflict: each one handles its own path. The full box, with what each part is for and how to check it, is in Chatrooms server setup (step “Proxy the WebSocket through nginx”).
^~ /storage/is a prefix match that beats nginx’s regex static-file location, so this block handles every/storage/...request.- The block inherits the server’s
root, andtry_files $uri =404serves the exact same file as before — you are only adding a header, not changing how files resolve. alwayssends the header even on304 Not Modified(these files haveetag/last-modified, so browsers frequently get 304s).*is the right value here: CanvasKit fetches images withcrossOrigin="anonymous"(no cookies/credentials), so a literal*is valid. Do not widen this to a globaladd_headerat server scope —/apireturns a reflected origin together withaccess-control-allow-credentials: true, and*is illegal alongside credentials; a global*could break API CORS. Keep it scoped to/storage/only.
This also fixes production: elearning-*.theteam.com.tn → backend-*.theteam.com.tn is cross-origin too, so the same requirement applies there. Apply the identical nginx directive on each backend vhost.
R2 files (type = 3) need none of this — they are fetched from*.r2.dev/R2_CUSTOM_DOMAIN, where CORS is governed by the Cloudflare R2 bucket policy (already*, see above).
Endpoints
All endpoints below are registered in routes/api.php under the authenticated middleware group.
Local Storage (FilesController)
1. Upload File
Uploads a single file. Supports all three types: local (1), external URL (2), or R2 (3).
Form Data Parameters
Behavior by type
type=1— stored atstorage/app/public/files/{userId}{originalName}, URL ={APP_URL}/storage/files/{name}.type=2— no file stored;file_nameisnull,urlis the provided external URL.type=3— stored on ther2disk in the folder of the owner the request names (since 01/11/2026,app/Services/R2StoragePathService.php):files/all-lessons/{training-slug}/fortraining_id({lesson_id}_in front of the name whenlesson_idis sent),files/all-classrooms/{classroom-slug}/forclassroom_id,files/all-quizzes/{quiz-slug}/forquiz_id, and flatfiles/when no owner is named. The name is{userId}_{timestamp}_{random8}.{ext}, the URL the R2 public URL,file_namethe full key, and the answer carriespath.
One owner at most:
training_id, classroom_id and quiz_id together answer 422 on the second
one. A folder / folder_name string sent by a client is ignored: the backend never trusts a path from
a client. The whole layout, the owner ids the Flutter app sends and the function behind every key are on
R2 Storage Paths. A validation error of this endpoint answers 422
since 01/11/2026 (it used to answer 500).200 OK
3 upload, path is the R2 key:
422 Unprocessable Entity
500 Internal Server Error
2. Get File as Base64
Fetches the file’s bytes from its storedurl (via an HTTP GET) and returns them base64-encoded. Works for any file that has a publicly reachable url (local, external, or R2).
Example Request
200 OK
404 Not Found
3. Delete File
Deletes a local file from disk and removes its database record. Only removes files found underpublic/files/{file_name}.
Example Request
200 OK
200 OK
Note: this endpoint only deletes the DB record when the physical file is present underpublic/files/. For R2-stored files use endpoint #7 (/r2_delete_file/{id}) instead.
4. Bulk Upload to Local Storage
Uploads multiple files at once to local storage, optionally grouped into a named folder.
Form Data Parameters
- Files are stored under
storage/app/public/files/{folder}/on thepublicdisk. - Each stored file is renamed to
{d-m-Y-H-i}-{sanitized-name}.{ext}. - All records are saved with
type = 1(internal).
200 OK
500 Internal Server Error
R2 Cloud Storage (FilesRController)
5. Upload File to R2
Uploads a single file directly to Cloudflare R2 with public visibility. Requirestype = 3.
Form Data Parameters
- Stored under
files/{userId}_{timestamp}_{originalName}in the R2 bucket. - URL is built from
R2_URL; ifR2_CUSTOM_DOMAINis set, acustom_domain_urlis also returned.
200 OK
Validation Error —custom_domain_urlis only present whenR2_CUSTOM_DOMAINis configured.
422 Unprocessable Entity
500 Internal Server Error
6. Get R2 File as Base64
Reads a file’s content directly from the R2 disk (not via HTTP) and returns it base64-encoded. Only works for files withtype = 3.
Example Request
200 OK
400 Bad Request
500 Internal Server Error
7. Delete R2 File (Hard Delete)
Permanently deletes a file from the R2 bucket and removes its database record. This is a hard delete — there is no recovery.
Example Request
200 OK
400 Bad Request
500 Internal Server Error
If the file record exists but the object is already gone from R2, the DB record is still deleted and a 200 OK is returned.
8. R2 Bulk Upload
Uploads multiple files to R2 in a single request. Requirestype = 3.
Form Data Parameters
- Each file stored at
files/{userId}_{timestamp}_{originalName}in R2.
200 OK
500 Internal Server Error
9. R2 Storage Stats
Returns detailed R2 bucket usage statistics: total size, file counts, file-type breakdown, month-to-date growth, end-of-month projection, and capacity warnings.
Query Parameters
Example Request
200 OK
500 Internal Server Error
10. Local Storage Stats
Returns partition-level disk stats plus detailed statistics for the local files area (storage/app/public/files/). Registered under two routes that point to the same method.
This path is registered twice inExample Requestroutes/api.php(once inside theFilesRControllergroup and once standalone for testing) — both resolve toFilesRController@local_storage_stats.
200 OK
IfServer Error —LOCAL_STORAGE_LIMIT_GBis not set,max_gb,remaining_gb,percentage_used, andlocal_storage_remaining_storage_gbarenull, and all warnings arefalse.
500 Internal Server Error
11. List All Backend Files
Returns a paginated list of allfiles records, with optional filters by type and folder.
Query Parameters
Example Request
200 OK
500 Internal Server Error
12. List R2 Folders
Scans thefiles/ prefix in the R2 bucket and returns the immediate sub-folders with a count of files in each. Files directly under files/ are grouped under (root).
Example Request
200 OK
500 Internal Server Error
13. Test R2 Controller
A simple connectivity check confirming the R2 controller is reachable.
Example Request
200 OK
Postman Setup
Step 1 — Environment Variables
Create a Postman environment with:Step 2 — Login & Save Token
Create aPOST {{base_url}}/auth/login request:
Step 3 — Set Auth Header
For every file endpoint:- Go to Authorization
- Type: Bearer Token
- Token:
{{token}}
Step 4 — Uploading Files (form-data)
For upload endpoints:- Set method to
POST - Set body type to form-data
- Add
company_idas Text ={{company_id}} - Add
typeas Text (1,2, or3) - Add
fileas File (single upload) — or for bulk, add multiple rows keyedfiles[]set to File - For external URLs (
type=2), addurlas Text instead offile
Step 5 — Postman Collection Structure
Tips for Testing
- company_id is always required on uploads and must match your user’s company (super-admins excepted) — otherwise the
company_policy_middlewarereturns403. - Local vs R2: use
/delete_file/{id}fortype=1files,/r2_delete_file/{id}fortype=3files. - Base64 endpoints are useful to bypass browser CORS issues when rendering remote images/files in the frontend.
- External URLs (
type=2) store no bytes — only a URL reference; deleting them via/delete_filereturns “File does not exists.” because there is no local file. - Run
php artisan storage:linkbefore testing local uploads, or/storage/...URLs will 404. - Stats endpoints scan storage — on large buckets
/r2_storage_statsmay take a while (note the long R2 timeouts in the disk config).
Error Handling
All endpoints follow a consistent error format.
Standard error shape
errors object is only present for validation failures. Some R2/local endpoints add an error or exception_R2 field with the underlying exception message for debugging.
Frontend Integration — Flutter Web (CanvasKit) Image CORS
Context: the frontend is a Flutter Web app upgraded fromSymptom (Chrome console):3.27.x→3.44.x, now built with the CanvasKit renderer (the old--htmlrenderer is removed; do not pass--web-renderer). CanvasKit changes how images are loaded, which makes the/storage/CORS layer above mandatory for the app to render local-storage images. This section explains the symptom → cause → fix from the frontend’s point of view. The backend-side fix itself is the Plesk/nginx directive documented in Local Static-File CORS (Plesk / nginx) above.
200 (OK) next to ERR_FAILED is the tell: the bytes downloaded fine; the browser refused to hand them to the app because the cross-origin read wasn’t authorized. Affected widgets are anything that decodes a network image on web (CachedNetworkImage, UserAvatar.fromUser(...), DataGrid image cells, training cover images). Local (type=1) URLs fail; R2 (type=3) URLs do not.
Why CanvasKit needs CORS on images
- The deprecated
htmlrenderer painted a network image by dropping a real<img>element on the page — no pixel read, no CORS required. - CanvasKit rasterizes everything itself. To draw a network image it must
fetchthe pixel bytes (a cross-origin request withcrossOrigin="anonymous") and upload them into a GPU texture. That programmatic read requires the image response to carryAccess-Control-Allow-Origin. - Opening the same URL in a browser tab is top-level navigation, to which CORS does not apply — which is exactly why “open image in new tab” works while the app shows a broken image.
Why the backend’s “fully open” CORS doesn’t cover /storage/ images
config/cors.php is fully open (paths => ['*'], allowed_origins => ['*'], supports_credentials => true) — but that is Laravel middleware; it only runs for requests that enter PHP. On the Plesk box, nginx serves /storage/files/*.png|jpg|svg|webp… directly from disk, so Apache, public/.htaccess, and Laravel are all bypassed for those requests → no CORS header → CanvasKit can’t decode.
The fix is therefore not in this codebase’s PHP config — it’s the nginx add_header scoped to /storage/ documented above. R2 images already work because the Cloudflare R2 bucket CORS is *.
The backend also exposes base64 endpoints (/api/file/{id}/base64,/api/file_r2/{id}/base64) precisely to sidestep browser CORS by returning bytes inline through an authenticated API call — but for the Flutter app the nginx header is the better fix, since the app references images by URL viaCachedNetworkImage/UserAvatar.
Per-host CORS responsibility (quick decision guide)
The two .htaccess files — keep them separate
- Backend
public/.htaccess— the standard Laravel front controller (documented in Public Web Root & Apache above). Leave it stock. Do not add CORS headers here for the image problem: it’s Apache config, and the failing image requests never reach Apache (nginx serves them). - Frontend
web/.htaccess— only used if the built Flutter web bundle is served by Apache (SPA pushstate fallback). It is irrelevant to image CORS: CORS headers must come from the host that serves the resource (the backend), never from the page’s own origin. In local dev the app is served by theflutter rundev server, so it isn’t even consulted.
.htaccess is the right lever.
Running / building the Flutter web app (CanvasKit)
localhost:5000is the dev origin that must be allowed by the backend —/apialready allows it, and the/storage/nginx fix above allows it for images too (*covers any origin).- Turnstile errors on
localhost(Domain not allowed,Error: 110200) are expected and can be ignored — it’s disabled for localhost on the backend.