Skip to main content

Table of Contents

  • Overview
  • Authentication
  • Base URL
  • File Types & The File Model
  • Laravel Filesystem Configuration
    • Disks (config/filesystems.php)
    • Local / Public Disk
    • Cloudflare R2 Disk
    • .env Settings — Local Storage
    • .env Settings — R2 Storage
    • Storage Stats .env Settings
  • 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/
  • 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
  • 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 .htaccess files — 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 the public symbolic link as /storage/files/.... Handled by FilesController (app/Http/Controllers/FilesController.php).
  • Cloudflare R2 — an S3-compatible object store, accessed via the r2 disk using league/flysystem-aws-s3-v3. Handled by FilesRController (app/Http/Controllers/FilesRController.php).
Every uploaded file is also recorded as a row in the 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 valid company_id, and all file endpoints sit behind company_policy_middleware, which forces the requested company_id to match the authenticated user’s company (super-admins bypass).

Authentication

All file endpoints are protected and require a Bearer Token via Laravel Sanctum:
The token is obtained from POST /api/auth/login. The full middleware stack applied to every file route is:
This means a request must be:
  • Authenticated (valid Sanctum token)
  • From a non-blocked user
  • Scoped to the user’s own company_id (super-admins excepted)
There are no public file endpoints — even GET /api/test_files_r2 requires authentication.

Base URL

In the local development repo, the server typically runs on:

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 a File is deleted, a booted() observer attempts to remove the matching file from public/files/{file_name} and logs an archive action 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 the File row: FilesRController does this on hard delete (/r2_delete_file/{id}), and UserBackgroundItemObserver does 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):
Four disks are configured: 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 via php artisan storage:link).
  • Public URLs look like: {APP_URL}/storage/files/{file_name}.
Important: run php artisan storage:link once 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.php since 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.dev URL) or, when set, R2_CUSTOM_DOMAIN.
  • The long HTTP timeouts (connect_timeout 60s, timeout 500s) support large uploads.

.env Settings — Local Storage

If LOCAL_STORAGE_LIMIT_GB is not set, the local_storage_stats endpoint returns null for max_gb, remaining_gb, and percentage_used, but still reports partition-wide and files-area usage.

.env Settings — R2 Storage

R2_CUSTOM_DOMAIN is optional. When present, single uploads (/r2_upload_file) additionally return a custom_domain_url field 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 Laravel public/.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 an fastcgi_pass ... HTTP_AUTHORIZATION mapping).

Directory listing & content negotiation

  • -Indexes — disables auto directory listings. Requesting a folder under public/ (e.g. /storage/files/) does not return a browsable index; without an index file it returns 403 Forbidden. This prevents enumeration of uploaded files in public/storage/files/....
  • -MultiViews — disables Apache content negotiation so the rewrite rules (not Apache’s guesswork) decide which resource is served.

Authorization header forwarding

Some Apache/PHP setups strip the 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 301 strips 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 to index.php and handled by Laravel’s router (routes/api.php).

How it relates to file serving

  • Local uploads (type = 1) live under storage/app/public/files/... and are exposed through the public/storage symlink. Because those resolve to real files (-f passes), Apache serves them statically and they never hit Laravel — that is why /storage/files/{file_name} URLs are fast and unauthenticated, and why php artisan storage:link is mandatory (no symlink → no real file → request falls through to index.php → 404).
  • API calls (/api/upload_file, /api/r2_*, etc.) do not map to a real file, so they fall through to index.php and run through the Sanctum-protected middleware stack.
  • R2 files (type = 3) are not served by this .htaccess at 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 in config/cors.php and is currently fully open (development posture):
⚠️ Production warning: allowed_origins: ['*'] combined with supports_credentials: true is 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). Restrict allowed_origins before deploying.
The commented-out (intended) origin list in the file:
Base64 endpoints exist partly because of CORS: fetching R2/external images client-side can hit browser CORS restrictions. The base64 and r2_base64 endpoints return the file content inline (base64) so the frontend can render it without cross-origin fetches.

R2 Bucket CORS (Cloudflare Dashboard)

The Laravel config/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 Laravel allowed_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 in app/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
was a no-op for images (nginx serves them before Apache ever sees the request) and only lived in the HTTP field, never HTTPS. It was removed. Leave Apache CORS to Laravel (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:
Click OK/Apply — Plesk reloads nginx automatically.
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”).
Image
Why this is correct and safe:
  • ^~ /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, and try_files $uri =404 serves the exact same file as before — you are only adding a header, not changing how files resolve.
  • always sends the header even on 304 Not Modified (these files have etag/last-modified, so browsers frequently get 304s).
  • * is the right value here: CanvasKit fetches images with crossOrigin="anonymous" (no cookies/credentials), so a literal * is valid. Do not widen this to a global add_header at server scope — /api returns a reflected origin together with access-control-allow-credentials: true, and * is illegal alongside credentials; a global * could break API CORS. Keep it scoped to /storage/ only.
Step 3 — verify:
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 at storage/app/public/files/{userId}{originalName}, URL = {APP_URL}/storage/files/{name}.
  • type=2 — no file stored; file_name is null, url is the provided external URL.
  • type=3 — stored on the r2 disk in the folder of the owner the request names (since 01/11/2026, app/Services/R2StoragePathService.php): files/all-lessons/{training-slug}/ for training_id ({lesson_id}_ in front of the name when lesson_id is sent), files/all-classrooms/{classroom-slug}/ for classroom_id, files/all-quizzes/{quiz-slug}/ for quiz_id, and flat files/ when no owner is named. The name is {userId}_{timestamp}_{random8}.{ext}, the URL the R2 public URL, file_name the full key, and the answer carries path.
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).
Example Request (R2, a saved lesson’s image)
Example Request (local file)
Example Request (external URL)
Success Response — 200 OK
For a type 3 upload, path is the R2 key:
Validation Error — 422 Unprocessable Entity
Server Error — 500 Internal Server Error

2. Get File as Base64

Fetches the file’s bytes from its stored url (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
Success Response — 200 OK
Unreachable File — 404 Not Found

3. Delete File

Deletes a local file from disk and removes its database record. Only removes files found under public/files/{file_name}. Example Request
Success Response — 200 OK
File Not on Disk — 200 OK
Note: this endpoint only deletes the DB record when the physical file is present under public/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 the public disk.
  • Each stored file is renamed to {d-m-Y-H-i}-{sanitized-name}.{ext}.
  • All records are saved with type = 1 (internal).
Example Request
Success Response — 200 OK
Server Error — 500 Internal Server Error

R2 Cloud Storage (FilesRController)

5. Upload File to R2

Uploads a single file directly to Cloudflare R2 with public visibility. Requires type = 3. Form Data Parameters
  • Stored under files/{userId}_{timestamp}_{originalName} in the R2 bucket.
  • URL is built from R2_URL; if R2_CUSTOM_DOMAIN is set, a custom_domain_url is also returned.
Example Request
Success Response — 200 OK
custom_domain_url is only present when R2_CUSTOM_DOMAIN is configured.
Validation Error — 422 Unprocessable Entity
Server Error — 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 with type = 3. Example Request
Success Response — 200 OK
Not an R2 File — 400 Bad Request
Server Error — 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
Success Response — 200 OK
Not an R2 File — 400 Bad Request
Server Error — 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. Requires type = 3. Form Data Parameters
  • Each file stored at files/{userId}_{timestamp}_{originalName} in R2.
Example Request
Success Response — 200 OK
Server Error — 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
Success Response — 200 OK
Server Error — 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 in routes/api.php (once inside the FilesRController group and once standalone for testing) — both resolve to FilesRController@local_storage_stats.
Example Request
Success Response — 200 OK
If LOCAL_STORAGE_LIMIT_GB is not set, max_gb, remaining_gb, percentage_used, and local_storage_remaining_storage_gb are null, and all warnings are false.
Server Error — 500 Internal Server Error

11. List All Backend Files

Returns a paginated list of all files records, with optional filters by type and folder. Query Parameters Example Request
Success Response — 200 OK
Server Error — 500 Internal Server Error

12. List R2 Folders

Scans the files/ 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
Success Response — 200 OK
Server Error — 500 Internal Server Error

13. Test R2 Controller

A simple connectivity check confirming the R2 controller is reachable. Example Request
Success Response — 200 OK

Postman Setup

Step 1 — Environment Variables

Create a Postman environment with:

Step 2 — Login & Save Token

Create a POST {{base_url}}/auth/login request:
In the Tests tab, add this script to auto-save the token:

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:
  1. Set method to POST
  2. Set body type to form-data
  3. Add company_id as Text = {{company_id}}
  4. Add type as Text (1, 2, or 3)
  5. Add file as File (single upload) — or for bulk, add multiple rows keyed files[] set to File
  6. For external URLs (type=2), add url as Text instead of file

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_middleware returns 403.
  • Local vs R2: use /delete_file/{id} for type=1 files, /r2_delete_file/{id} for type=3 files.
  • 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_file returns “File does not exists.” because there is no local file.
  • Run php artisan storage:link before testing local uploads, or /storage/... URLs will 404.
  • Stats endpoints scan storage — on large buckets /r2_storage_stats may take a while (note the long R2 timeouts in the disk config).

Error Handling

All endpoints follow a consistent error format. Standard error shape
The 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 from 3.27.x → 3.44.x, now built with the CanvasKit renderer (the old --html renderer 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.
Symptom (Chrome console):
The 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 html renderer 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 fetch the pixel bytes (a cross-origin request with crossOrigin="anonymous") and upload them into a GPU texture. That programmatic read requires the image response to carry Access-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 via CachedNetworkImage/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 the flutter run dev server, so it isn’t even consulted.
Bottom line: the image fix is a one-line nginx directive on the backend Plesk vhost — neither .htaccess is the right lever.

Running / building the Flutter web app (CanvasKit)

  • localhost:5000 is the dev origin that must be allowed by the backend — /api already 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.