> ## Documentation Index
> Fetch the complete documentation index at: https://theteam-13fc7f49.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# File Management & Storage — API (v4)

## **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:

```text theme={null}
Authorization: Bearer {your_token}
```

The token is obtained from `POST /api/auth/login`.

The full middleware stack applied to every file route is:

```text theme={null}
['auth:sanctum', 'check_if_user_is_blocked_middleware', 'company_policy_middleware']
```

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**

```text theme={null}
https://your-domain.com/api
```

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

```text theme={null}
http://localhost:8020/api
```

***

## **File Types & The** `File `**Model**

The `File` model defines three storage types as string constants:

| **Constant** | **Value** | **Meaning** |
| :- | :- | :- |
| `TYPE_INTERNAL` | `"1"` | File physically stored on the **local** server disk |
| `TYPE_EXTERNAL` | `"2"` | **External URL only** — no bytes stored, just a `url` reference |
| `TYPE_R2` | `"3"` | File physically stored in **Cloudflare R2** |

`files `**table columns (fillable):**

| **Column** | **Type** | **Description** |
| :- | :- | :- |
| `id` | integer | Primary key |
| `company_id` | integer | Tenant owner (must exist in `companies`) |
| `user_id` | integer | Uploader (set automatically from the auth token) |
| `type` | integer | `1` = local, `2` = external URL, `3` = R2 (cast to integer) |
| `file_name` | string/null | Storage-relative path or name (`null` for external URLs) |
| `url` | string | Publicly accessible URL of the file |

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`):

```text theme={null}
'default' => env('FILESYSTEM_DISK', '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**

```text theme={null}
'local' => [
    'driver' => 'local',
    'root'   => storage_path('app'),
    'throw'  => false,
],

'public' => [
    'driver'     => 'local',
    'root'       => storage_path('app/public'),
    'url'        => env('APP_URL').'/storage',
    'visibility' => 'public',
    'throw'      => false,
],
```

* 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:

```text theme={null}
'r2' => [
    'driver'                  => 's3',
    'key'                     => env('R2_ACCESS_KEY_ID'),
    'secret'                  => env('R2_SECRET_ACCESS_KEY'),
    'region'                  => env('R2_DEFAULT_REGION', 'auto'),
    'bucket'                  => env('R2_BUCKET'),
    'endpoint'                => env('R2_ENDPOINT'),
    'use_path_style_endpoint' => env('R2_USE_PATH_STYLE_ENDPOINT', false),
    'url'                     => env('R2_URL'),
    'options' => [
        'http' => [
            'connect_timeout' => 60,
            'timeout'         => 500,
        ],
    ],
],
```

* 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](https://theteam-13fc7f49.mintlify.site/api-reference/r2-storage-paths.md).
* 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**

| **Variable** | **Example** | **Purpose** |
| :- | :- | :- |
| `FILESYSTEM_DISK` | `local` | Default Laravel disk |
| `APP_URL` | `https://backend-dev.the-team.tn` | Base URL used to build `/storage/...` public links |
| `LOCAL_STORAGE_LIMIT_GB` | `50` | *(optional)* Soft cap used for `%`/remaining calculations in local stats |
| `LOCAL_WARN_THRESHOLD` | `0.8` | *(optional)* Fraction of the limit at which a warning flag is raised |

> 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**

| **Variable** | **Example** | **Purpose** |
| :- | :- | :- |
| `R2_ACCESS_KEY_ID` | `9e2a99acbbd54c8a5361a771885d7d99` | R2 access key |
| `R2_SECRET_ACCESS_KEY` | `c3030c8622366b0b5b0a8c...` | R2 secret key |
| `R2_DEFAULT_REGION` | `auto` | Region (R2 uses `auto`) |
| `R2_BUCKET` | `laravelbucket` | Bucket name |
| `R2_ENDPOINT` | `https://5f19...r2.cloudflarestorage.com` | S3 API endpoint for the R2 account |
| `R2_USE_PATH_STYLE_ENDPOINT` | `true` | Use path-style addressing (recommended for R2) |
| `R2_URL` | `https://pub-f784...r2.dev` | Public bucket URL used to build file URLs |
| `R2_CUSTOM_DOMAIN` | `https://r2.the-team.tn` | *(optional)* Custom domain; when set, returned as `custom_domain_url` |

> `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**

| **Variable** | **Example** | **Purpose** |
| :- | :- | :- |
| `R2_FREE_TIER_STORAGE_GB` | `10` | R2 capacity used for `%` and remaining calculations (preferred) |
| `R2_TOTAL_CAPACITY_GB` | `10` | Legacy fallback for capacity if `R2_FREE_TIER_STORAGE_GB` is unset (default 10) |
| `R2_FREE_TIER_CLASS_A` | `1000000` | *(informational)* R2 free-tier Class A operations cap |
| `R2_FREE_TIER_CLASS_B` | `10000000` | *(informational)* R2 free-tier Class B operations cap |
| `R2_WARN_THRESHOLD` | `0.8` | Default fraction of capacity that triggers the "close to cap" warning |

> 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:

```text theme={null}
<IfModule mod_rewrite.c>
    <IfModule mod_negotiation.c>
        Options -MultiViews -Indexes
    </IfModule>

    RewriteEngine On

    # Handle Authorization Header
    RewriteCond %{HTTP:Authorization} .
    RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]

    # Redirect Trailing Slashes If Not A Folder...
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_URI} (.+)/$
    RewriteRule ^ %1 [L,R=301]

    # Send Requests To Front Controller...
    RewriteCond %{REQUEST_FILENAME} !-d
    RewriteCond %{REQUEST_FILENAME} !-f
    RewriteRule ^ index.php [L]
</IfModule>
```

> 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**

```text theme={null}
Options -MultiViews -Indexes
```

* `-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**

```text theme={null}
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
```

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**

```text theme={null}
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]

# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]
```

* **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):

| **Option** | **Value** | **Meaning** |
| :- | :- | :- |
| `paths` | `['*']` | CORS handling applies to **all** paths (not just `api/*`) |
| `allowed_methods` | `['*']` | All HTTP methods allowed |
| `allowed_origins` | `['*']` | All origins allowed |
| `allowed_origins_patterns` | `[]` | No regex patterns configured |
| `allowed_headers` | `['*']` | All request headers allowed |
| `exposed_headers` | `[]` | No extra headers exposed to the browser |
| `max_age` | `0` | No preflight caching |
| `supports_credentials` | `true` | `Access-Control-Allow-Credentials: true` |

> ⚠️ **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:

```text theme={null}
'*.the-team.tn'
'*.theteam.com.tn'
'https://the-team.tn'
'https://www.the-team.tn'
'https://elearning-test.the-team.tn'
'https://elearning-dev.the-team.tn'
'https://elearning-prod.the-team.tn'
'http://localhost:3000'
'http://localhost:8000'
'http://localhost:8020'
'http://localhost:5000'
```

> **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:

```text theme={null}
[
  {
    "AllowedOrigins": [
      "*"
    ],
    "AllowedMethods": [
      "HEAD",
      "GET",
      "POST",
      "PUT",
      "DELETE"
    ],
    "AllowedHeaders": [
      "Content-Type",
      "Content-Language",
      "Content-Encoding",
      "Content-Disposition",
      "X-Amz-Acl"
    ],
    "ExposeHeaders": [
      "Content-Type",
      "Access-Control-Allow-Origin",
      "ETag",
      "Cache-Control",
      "Content-Disposition",
      "Content-Encoding",
      "Expires"
    ]
  }
]
```

| **Field** | **Value(s)** | **Purpose** |
| :- | :- | :- |
| `AllowedOrigins` | `*` | All origins may fetch bucket objects from the browser |
| `AllowedMethods` | `HEAD`, `GET`, `POST`, `PUT`, `DELETE` | Browser methods R2 will accept cross-origin |
| `AllowedHeaders` | `Content-Type`, `Content-Language`, `Content-Encoding`, `Content-Disposition`, `X-Amz-Acl` | Request headers the browser may send on cross-origin requests |
| `ExposeHeaders` | `Content-Type`, `Access-Control-Allow-Origin`, `ETag`, `Cache-Control`, `Content-Disposition`, `Content-Encoding`, `Expires` | Response headers exposed to browser JS reading the response |

> ⚠️ **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`):

| **Request** | `x-powered-by` | **CORS header on response** | **Served by** |
| :- | :- | :- | :- |
| `/storage/files/...png` | `PleskLin` only (+ `etag`, `last-modified`) | ❌ none (before the fix) | **nginx, static file** |
| `/api/...` | `PHP/8.2.x` + `PleskLin` | ✅ reflected origin + `access-control-allow-credentials: true` | **Laravel / PHP** |

#### **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

```text theme={null}
Header set Access-Control-Allow-Origin "*"
```

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:

```text theme={null}
location ^~ /storage/ {
    add_header Access-Control-Allow-Origin "*" always;
    try_files $uri =404;
}
```

Click **OK/Apply** — Plesk reloads nginx automatically.

> [!NOTE]
>
> 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](https://theteam-13fc7f49.mintlify.site/deployment/chatrooms-setup.md) (step "Proxy the WebSocket through nginx").

![Image](https://mintlify.s3.us-west-1.amazonaws.com/theteam-13fc7f49/images/image-3.png)

**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:**

```text theme={null}
curl -sI -H "Origin: http://localhost:5000" \
  "https://backend-test.theteam.com.tn/storage/files/4_4_strived_logo_cropped.png" \
  | grep -i access-control
# expect:  access-control-allow-origin: *
```

> 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`).

| **Field** | **Value** |
| :- | :- |
| **Method** | `POST` |
| **URL** | `/api/upload_file` |
| **Auth** | Bearer Token |
| **Content-Type** | `multipart/form-data` |

**Form Data Parameters**

| **Field** | **Type** | **Rules** |
| :- | :- | :- |
| `company_id` | integer | required, must exist in `companies` |
| `type` | integer | required, one of `1` (local), `2` (external URL), `3` (R2) |
| `file` | file | required when `type` is `1` or `3` |
| `url` | string | required when `type` is `2` |
| `training_id` | integer | type `3` only, optional: the training the file belongs to (a lesson's image / PDF). Must belong to `company_id` (a super-admin may name any company's), else `422` |
| `lesson_id` | integer | type `3` only, optional, only with `training_id`: the saved lesson, must be in one of that training's chapters, else `422`. Prefixes the file name with `{lesson_id}_` |
| `classroom_id` | integer | type `3` only, optional: the classroom the file belongs to (same company rule) |
| `quiz_id` | integer | type `3` only, optional: the quiz / exam a question image belongs to (same company rule) |

**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`.

> [!NOTE]
>
> **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](https://theteam-13fc7f49.mintlify.site/api-reference/r2-storage-paths.md). 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)**

```text theme={null}
POST /api/upload_file
Authorization: Bearer {token}
Content-Type: multipart/form-data

company_id  = 1
type        = 3
url         =
file        = (binary) governance-model.png
training_id = 7
lesson_id   = 318
```

**Example Request (local file)**

```text theme={null}
POST /api/upload_file
Authorization: Bearer {token}
Content-Type: multipart/form-data

company_id = 1
type       = 1
file       = (binary) lesson-cover.png
```

**Example Request (external URL)**

```text theme={null}
POST /api/upload_file
Authorization: Bearer {token}
Content-Type: multipart/form-data

company_id = 1
type       = 2
url        = https://cdn.example.com/banner.png
```

**Success Response** — `200 OK`

```text theme={null}
{
  "file_id": 42,
  "url": "https://backend-dev.the-team.tn/storage/files/5lesson-cover.png",
  "path": null,
  "message": "File uploaded successfully."
}
```

For a type `3` upload, `path` is the R2 key:

```text theme={null}
{
  "file_id": 912,
  "url": "https://r2.the-team.tn/files/all-lessons/pmp-foundations/318_5_1793289600_aZ3kP9qL.png",
  "path": "files/all-lessons/pmp-foundations/318_5_1793289600_aZ3kP9qL.png",
  "message": "File uploaded successfully."
}
```

**Validation Error** — `422 Unprocessable Entity`

```text theme={null}
{
  "message": "The given data was invalid.",
  "errors": {
    "file": ["The file field is required when type is 1 or 3."]
  }
}
```

**Server Error** — `500 Internal Server Error`

```text theme={null}
{
  "message": "Error message describing what went wrong"
}
```

***

### **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).

| **Field** | **Value** |
| :- | :- |
| **Method** | `GET` |
| **URL** | `/api/file/{id}/base64` |
| **Auth** | Bearer Token |

**Example Request**

```text theme={null}
GET /api/file/42/base64
Authorization: Bearer {token}
```

**Success Response** — `200 OK`

```text theme={null}
{
  "id": 42,
  "url": "https://backend-dev.the-team.tn/storage/files/5lesson-cover.png",
  "base64": "iVBORw0KGgoAAAANSUhEUgAA..."
}
```

**Unreachable File** — `404 Not Found`

```text theme={null}
{
  "message": "Unable to fetch file from URL"
}
```

***

### **3. Delete File**

Deletes a **local** file from disk and removes its database record. Only removes files found under `public/files/{file_name}`.

| **Field** | **Value** |
| :- | :- |
| **Method** | `DELETE` |
| **URL** | `/api/delete_file/{id}` |
| **Auth** | Bearer Token |

**Example Request**

```text theme={null}
DELETE /api/delete_file/42
Authorization: Bearer {token}
```

**Success Response** — `200 OK`

```text theme={null}
{
  "message": "File deleted successfully."
}
```

**File Not on Disk** — `200 OK`

```text theme={null}
{
  "message": "File does not exists."
}
```

> **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.

| **Field** | **Value** |
| :- | :- |
| **Method** | `POST` |
| **URL** | `/api/bulk_upload_to_storage` |
| **Auth** | Bearer Token |
| **Content-Type** | `multipart/form-data` |

**Form Data Parameters**

| **Field** | **Type** | **Rules** |
| :- | :- | :- |
| `company_id` | integer | required, must exist in `companies` |
| `files[]` | file array | required; each item must be a file |
| `folder_name` | string | optional; slugified. If omitted, a `d-m-Y-H-i` timestamp folder is used |

* 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**

```text theme={null}
POST /api/bulk_upload_to_storage
Authorization: Bearer {token}
Content-Type: multipart/form-data

company_id  = 1
folder_name = Course Materials
files[]     = (binary) slide1.pdf
files[]     = (binary) slide2.pdf
```

**Success Response** — `200 OK`

```text theme={null}
{
  "message": "Files uploaded successfully to local storage.",
  "files": [
    {
      "file_id": 51,
      "url": "https://backend-dev.the-team.tn/storage/files/course-materials/03-06-2026-14-30-slide1.pdf",
      "file_name": "slide1.pdf"
    },
    {
      "file_id": 52,
      "url": "https://backend-dev.the-team.tn/storage/files/course-materials/03-06-2026-14-30-slide2.pdf",
      "file_name": "slide2.pdf"
    }
  ]
}
```

**Server Error** — `500 Internal Server Error`

```text theme={null}
{
  "message": "An unexpected error occurred. Please contact your platform administrator.",
  "errors": "Detailed error message"
}
```

***

## **R2 Cloud Storage (**`FilesRController`**)**

### **5. Upload File to R2**

Uploads a single file directly to Cloudflare R2 with public visibility. Requires `type = 3`.

| **Field** | **Value** |
| :- | :- |
| **Method** | `POST` |
| **URL** | `/api/r2_upload_file` |
| **Auth** | Bearer Token |
| **Content-Type** | `multipart/form-data` |

**Form Data Parameters**

| **Field** | **Type** | **Rules** |
| :- | :- | :- |
| `company_id` | integer | required, must exist in `companies` |
| `type` | integer | required, **must be** `3` (R2) |
| `file` | file | required |

* 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**

```text theme={null}
POST /api/r2_upload_file
Authorization: Bearer {token}
Content-Type: multipart/form-data

company_id = 1
type       = 3
file       = (binary) intro-video.mp4
```

**Success Response** — `200 OK`

```text theme={null}
{
  "file_id": 88,
  "url": "https://pub-f784063c7e034df99a5468d0a840b67b.r2.dev/files/5_1717420800_intro-video.mp4",
  "message": "File uploaded successfully to R2.",
  "custom_domain_url": "https://r2.the-team.tn/files/5_1717420800_intro-video.mp4"
}
```

> `custom_domain_url` is only present when `R2_CUSTOM_DOMAIN` is configured.

**Validation Error** — `422 Unprocessable Entity`

```text theme={null}
{
  "message": "The given data was invalid.",
  "errors": {
    "type": ["The type must be 3."]
  }
}
```

**Server Error** — `500 Internal Server Error`

```text theme={null}
{
  "message": "Unable to upload the file(s). Please contact your platform administrator."
}
```

***

### **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`.

| **Field** | **Value** |
| :- | :- |
| **Method** | `GET` |
| **URL** | `/api/file_r2/{id}/base64` |
| **Auth** | Bearer Token |

**Example Request**

```text theme={null}
GET /api/file_r2/88/base64
Authorization: Bearer {token}
```

**Success Response** — `200 OK`

```text theme={null}
{
  "id": 88,
  "url": "https://pub-f784063c7e034df99a5468d0a840b67b.r2.dev/files/5_1717420800_intro-video.mp4",
  "base64": "AAAAIGZ0eXBpc29t..."
}
```

**Not an R2 File** — `400 Bad Request`

```text theme={null}
{
  "message": "File is not stored in R2."
}
```

**Server Error** — `500 Internal Server Error`

```text theme={null}
{
  "message": "Unable to retrieve the file(s). Please contact your platform administrator."
}
```

***

### **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.

| **Field** | **Value** |
| :- | :- |
| **Method** | `DELETE` |
| **URL** | `/api/r2_delete_file/{id}` |
| **Auth** | Bearer Token |

**Example Request**

```text theme={null}
DELETE /api/r2_delete_file/88
Authorization: Bearer {token}
```

**Success Response** — `200 OK`

```text theme={null}
{
  "message": "File deleted successfully from R2 and database."
}
```

**Not an R2 File** — `400 Bad Request`

```text theme={null}
{
  "message": "File is not stored in R2."
}
```

**Server Error** — `500 Internal Server Error`

```text theme={null}
{
  "message": "Unable to delete the file from storage. Please contact your platform administrator."
}
```

> 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`.

| **Field** | **Value** |
| :- | :- |
| **Method** | `POST` |
| **URL** | `/api/r2_bulk_upload` |
| **Auth** | Bearer Token |
| **Content-Type** | `multipart/form-data` |

**Form Data Parameters**

| **Field** | **Type** | **Rules** |
| :- | :- | :- |
| `company_id` | integer | required, must exist in `companies` |
| `type` | integer | required, **must be** `3` (R2) |
| `files[]` | file array | required; each item must be a file |

* Each file stored at `files/{userId}_{timestamp}_{originalName}` in R2.

**Example Request**

```text theme={null}
POST /api/r2_bulk_upload
Authorization: Bearer {token}
Content-Type: multipart/form-data

company_id = 1
type       = 3
files[]    = (binary) chapter1.mp4
files[]    = (binary) chapter2.mp4
```

**Success Response** — `200 OK`

```text theme={null}
{
  "message": "Files uploaded successfully to R2.",
  "files": [
    {
      "file_id": 90,
      "url": "https://pub-f784063c7e034df99a5468d0a840b67b.r2.dev/files/5_1717420900_chapter1.mp4",
      "file_name": "chapter1.mp4"
    },
    {
      "file_id": 91,
      "url": "https://pub-f784063c7e034df99a5468d0a840b67b.r2.dev/files/5_1717420901_chapter2.mp4",
      "file_name": "chapter2.mp4"
    }
  ]
}
```

**Server Error** — `500 Internal Server Error`

```text theme={null}
{
  "message": "Unable to upload the file(s). Please contact your platform administrator."
}
```

***

### **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.

| **Field** | **Value** |
| :- | :- |
| **Method** | `GET` |
| **URL** | `/api/r2_storage_stats` |
| **Auth** | Bearer Token |

**Query Parameters**

| **Field** | **Type** | **Rules** |
| :- | :- | :- |
| `threshold` | float | optional, `0 < t <= 1`; overrides `R2_WARN_THRESHOLD` (default `0.8`) |
| `prefix` | string | optional; scope the scan to a bucket prefix (default = whole bucket) |

**Example Request**

```text theme={null}
GET /api/r2_storage_stats?threshold=0.75&prefix=files/
Authorization: Bearer {token}
```

**Success Response** — `200 OK`

```text theme={null}
{
  "R2_current_month_number": 6,
  "R2_total_files": 124,
  "R2_total_size_bytes": 4831838208,
  "R2_total_size_gb": 4.5,
  "R2_free_tier_storage_gb": 10,
  "R2_remaining_storage_gb": 5.5,
  "R2_percentage_used": 45.0,
  "R2_average_file_size": "38.97 MB",
  "R2_largest_file_size": "512.4 MB",
  "R2_smallest_file_size": "12.3 KB",
  "R2_file_types_count": {
    "mp4": 40,
    "png": 60,
    "pdf": 24
  },
  "R2_month_to_date": {
    "files_added": 18,
    "bytes_added": 734003200,
    "avg_daily_growth_bytes": 244667733,
    "days_elapsed_in_month": 3,
    "days_remaining_in_month": 27,
    "projected_end_of_month": {
      "total_bytes": 11437838208,
      "total_gb": 10.652
    }
  },
  "R2_warnings": {
    "storage_close_to_cap": false,
    "storage_over_cap": false,
    "projection_over_cap": true
  },
  "message": "R2 storage statistics calculated successfully."
}
```

**Server Error** — `500 Internal Server Error`

```text theme={null}
{
  "message": "An unexpected error occurred. Please contact your platform administrator.",
  "exception_R2": "Detailed S3/AWS error message"
}
```

***

### **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.

| **Field** | **Value** |
| :- | :- |
| **Method** | `GET` |
| **URL** | `/api/local_storage_stats` |
| **Auth** | Bearer Token |

> 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**

```text theme={null}
GET /api/local_storage_stats
Authorization: Bearer {token}
```

**Success Response** — `200 OK`

```text theme={null}
{
  "used_bytes": 32212254720,
  "used_gb": 30.0,
  "max_gb": 50,
  "remaining_gb": 20.0,
  "percentage_used": 60.0,
  "local_storage_total_files": 210,
  "local_storage_total_size_bytes": 1610612736,
  "local_storage_total_size_gb": 1.5,
  "local_storage_file_types_count": {
    "pdf": 120,
    "png": 90
  },
  "local_storage_average_file_size": "7.31 MB",
  "local_storage_largest_file_size": "120.5 MB",
  "local_storage_smallest_file_size": "4.2 KB",
  "local_storage_month_to_date": {
    "files_added": 25,
    "bytes_added": 268435456,
    "avg_daily_growth_bytes": 89478485,
    "days_elapsed_in_month": 3,
    "days_remaining_in_month": 27,
    "projected_end_of_month": {
      "total_bytes": 4026531840,
      "total_gb": 3.75
    }
  },
  "local_storage_remaining_storage_gb": 20.0,
  "local_storage_warnings": {
    "storage_close_to_cap": false,
    "storage_over_cap": false,
    "projection_over_cap": false
  },
  "message": "Local storage statistics calculated successfully."
}
```

> 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`

```text theme={null}
{
  "message": "An unexpected error occurred while calculating local storage.",
  "error": "Unable to read local disk space stats."
}
```

***

### **11. List All Backend Files**

Returns a paginated list of all `files` records, with optional filters by type and folder.

| **Field** | **Value** |
| :- | :- |
| **Method** | `GET` |
| **URL** | `/api/list_all_backend_files` |
| **Auth** | Bearer Token |

**Query Parameters**

| **Field** | **Type** | **Rules** |
| :- | :- | :- |
| `page` | integer | optional, default `1` |
| `per_page` | integer | optional, default `20` |
| `type` | integer | optional, filter by `1`/`2`/`3` |
| `folder_name` | string | optional, filter to files under `files/{folder_name}/` |

**Example Request**

```text theme={null}
GET /api/list_all_backend_files?type=3&per_page=10&page=1
Authorization: Bearer {token}
```

**Success Response** — `200 OK`

```text theme={null}
{
  "files": [
    {
      "id": 88,
      "file_name": "files/5_1717420800_intro-video.mp4",
      "url": "https://pub-f784063c7e034df99a5468d0a840b67b.r2.dev/files/5_1717420800_intro-video.mp4",
      "type": 3,
      "folder_name": "5_1717420800_intro-video.mp4"
    }
  ],
  "pagination": {
    "total_files": 1,
    "per_page": 10,
    "current_page": 1,
    "last_page": 1,
    "from": 1,
    "to": 1
  },
  "message": "Files retrieved successfully."
}
```

**Server Error** — `500 Internal Server Error`

```text theme={null}
{
  "message": "Unable to retrieve file(s). Please contact your platform administrator."
}
```

***

### **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)`.

| **Field** | **Value** |
| :- | :- |
| **Method** | `GET` |
| **URL** | `/api/list_r2_folders` |
| **Auth** | Bearer Token |

**Example Request**

```text theme={null}
GET /api/list_r2_folders
Authorization: Bearer {token}
```

**Success Response** — `200 OK`

```text theme={null}
{
  "folders": {
    "(root)": 12,
    "course-materials": 40,
    "all-chatrooms": 12,
    "all-classrooms": 7,
    "all-lessons": 41,
    "all-quizzes": 9,
    "backups": 3,
    "instructor_bg_items": 5,
    "instructor_background_items": 18,
    "lesson-images": 72
  },
  "message": "R2 folders retrieved successfully."
}
```

**Server Error** — `500 Internal Server Error`

```text theme={null}
{
  "message": "An unexpected error occurred. Please contact your platform administrator.",
  "error": "Detailed error message"
}
```

***

### **13. Test R2 Controller**

A simple connectivity check confirming the R2 controller is reachable.

| **Field** | **Value** |
| :- | :- |
| **Method** | `GET` |
| **URL** | `/api/test_files_r2` |
| **Auth** | Bearer Token |

**Example Request**

```text theme={null}
GET /api/test_files_r2
Authorization: Bearer {token}
```

**Success Response** — `200 OK`

```text theme={null}
{
  "message": "R2 Cloud Storage Controller is accessible."
}
```

***

## **Postman Setup**

### **Step 1 — Environment Variables**

Create a Postman environment with:

| **Variable** | **Value** |
| :- | :- |
| `base_url` | `https://your-domain.com/api` |
| `token` | *(set after login)* |
| `company_id` | `1` |

### **Step 2 — Login & Save Token**

Create a `POST {{base_url}}/auth/login` request:

```text theme={null}
{
  "email": "admin@example.com",
  "password": "your_password"
}
```

In the **Tests** tab, add this script to auto-save the token:

```text theme={null}
var jsonData = pm.response.json();
pm.environment.set("token", jsonData.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**

```text theme={null}
📁 File Management API
├── 📁 Auth
│   └── POST Login
├── 📁 Local Storage
│   ├── POST Upload File                /upload_file            (form-data)
│   ├── GET  File as Base64             /file/{id}/base64
│   ├── DEL  Delete File                /delete_file/{id}
│   └── POST Bulk Upload to Storage     /bulk_upload_to_storage (form-data)
├── 📁 R2 Cloud Storage
│   ├── POST Upload File to R2          /r2_upload_file         (form-data)
│   ├── GET  R2 File as Base64          /file_r2/{id}/base64
│   ├── DEL  Delete R2 File             /r2_delete_file/{id}
│   └── POST R2 Bulk Upload             /r2_bulk_upload         (form-data)
├── 📁 Listing & Stats
│   ├── GET  R2 Storage Stats           /r2_storage_stats?threshold=0.8
│   ├── GET  Local Storage Stats        /local_storage_stats
│   ├── GET  List All Backend Files     /list_all_backend_files?type=3
│   ├── GET  List R2 Folders            /list_r2_folders
│   └── GET  Test R2 Controller         /test_files_r2
```

### **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.

| **Status** | **Meaning** |
| :- | :- |
| `200` | Success (also used for "File does not exists." soft messages) |
| `400` | Bad request — e.g. operating an R2 endpoint on a non-R2 file |
| `401` | Unauthenticated — missing or invalid Bearer token |
| `403` | Forbidden — `company_id` mismatch or blocked user |
| `404` | Resource not found / file unreachable |
| `422` | Validation error — check the `errors` object |
| `500` | Server error — check `message` / `error` / `exception_R2` |

**Standard error shape**

```text theme={null}
{
  "message": "Human-readable error description",
  "errors": {
    "field_name": ["Validation message"]
  }
}
```

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):**

```text theme={null}
Access to image at 'https://backend-test.theteam.com.tn/storage/files/4banner%20PMP.png'
from origin 'http://localhost:5000' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

GET .../4banner%20PMP.png  net::ERR_FAILED 200 (OK)
Image failed to load. ... Error: EncodingError: The source image cannot be decoded.
```

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)**

| **Image host** | **Who sets CORS** | **Status** | **If broken, fix here** |
| :- | :- | :- | :- |
| `…/storage/files/...` (local, `type=1`) | **nginx** (Plesk → *Additional nginx directives*) | needed the `/storage/` `add_header` | Plesk nginx directive (this doc, CORS section) |
| `*.r2.dev` / R2 custom domain (`type=3`) | **Cloudflare R2 bucket CORS** | already `*` → works | Cloudflare dashboard → R2 → bucket → CORS Policy |
| `/api/...` (JSON) | **Laravel** `config/cors.php` | reflects origin + credentials → works | `config/cors.php` (do **not** add `*` globally to nginx) |

### **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)**

```text theme={null}
flutter pub get
flutter run -d chrome --web-hostname localhost --web-port 5000   # CanvasKit; DO NOT pass --web-renderer
flutter build web --release                                       # production build
```

* `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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.