# File manager

Source: https://docs.qiick.io/externa/file-manager

The file manager is the admin UI and JSON API under `/files/*`. `FileController` delegates to `FileService` (and transform/jobs for thumbnails, zip, and duplication). Every request passes through `can.manage.files`, which maps the route (and bulk action) to a specific file permission.

<!-- screenshot omitted -->

> **Related**
> Tree / versions / uploads model: [File tree model](https://docs.qiick.io/externa/file-tree-model). Disks & limits: [Files & storage](https://docs.qiick.io/externa/files-configuration). Named presets + focal point: [Image transforms](https://docs.qiick.io/externa/image-transforms). HTTP tables: [Admin & files API](https://docs.qiick.io/externa/admin-api). Async outcomes land in the [notifications bell](https://docs.qiick.io/externa/activity-and-notifications).

## Access & UI

| Item             | Detail                                                                  |
| ---------------- | ----------------------------------------------------------------------- |
| Controller       | `App\Http\Controllers\Admin\FileController`                             |
| Service          | `App\Services\FileService` (plus `FileTransformService` for thumbnails) |
| Middleware alias | `can.manage.files` → `App\Http\Middleware\EnsureCanManageFiles`         |
| Routes           | `routes/admin.php` (prefix `/files`)                                    |
| Frontend         | `resources/js/pages/admin/files/index.tsx`                              |
| Sidebar gate     | `can-show-files`                                                        |
| UI i18n          | `files.*` keys in `resources/js/locales/{en,it,de}.json` (shell locale) |

### Grid and details panel (1.4.0)

- **File cards** ([core #592](https://github.com/qiick-io/externa-core/pull/592), [#596](https://github.com/qiick-io/externa-core/pull/596)): every card is square, files and folders alike. The preview fills the card edge to edge above a 40px name row (`object-cover` on the file's focal point); only the selection checkbox, the favourite star and the **Private** badge sit over it. Non-image files and folders show their type or folder icon centred in the same area.
- **Name row**: the name on one line (full name in a tooltip); a **tag** button, only when the file has tags, that lists them in a hover card on hover or keyboard focus; and a **⋮** button with the same actions as the right-click menu (opening it selects the card).
- **Columns**: 2 on phones, then as many as fit at a minimum of 15rem (4 at 1440 px, 6 at 1920 px).
- **Focal point** ([core #583](https://github.com/qiick-io/externa-core/pull/583)): for images, the details panel shows the preview; click it to set the focal point (a crosshair marks it), or use the arrow keys (1%, Shift 10%) and Enter. **Reset to centre** clears it. The point drives the crop of every `cover` transform — [Image transforms — Focal point](https://docs.qiick.io/externa/image-transforms#focal-point).
- **Transforms fields removed**: the Translate X / Translate Y / Scale fields are gone from the panel because nothing read them. The database columns stay until a later migration.
- Folders and non-image files show no focal point.

The single Inertia page handles browse, selection, uploads, and bulk actions; many operations also expose JSON endpoints for the same UI. Labels follow the signed-in user’s **admin UI locale** (`en` / `it` / `de`) — see [Account settings — Admin UI locale](https://docs.qiick.io/externa/account-settings#admin-ui-locale).

## Upload security

Uploads run **two** gates (from core **1.3.0**, [#118](https://github.com/qiick-io/externa-core/issues/118) / [PR #267](https://github.com/qiick-io/externa-core/pull/267)):

1. **Extension denylist** — `App\Support\Uploads\ForbiddenUploadExtension` (last path extension wins) on direct upload / replace, chunked init / complete (`file_name`), and rename.
2. **MIME allowlist + magic bytes** — `App\Support\Uploads\MimeTypeValidator` against `config/files.php` `upload_allowed_mimes` / `upload_magic_signatures` (first 16 bytes). Applied on File Manager upload/replace, chunked assembly, Public API `POST /api/v1/files`, chat→Files save, and AI chat attachments.

Forbidden extensions include executable / script families such as `php`, `phtml`, `phar`, `exe`, `bat`, `cmd`, `js`, `mjs`, `vbs`, `asp`/`aspx`, `jsp`, `sh`, `ps1`, `dll`, `so`, and related aliases, plus **`svg` / `html` / `htm`**, and from **1.3.0** SSI / markup extensions **`shtml` / `shtm` / `xml` / `xhtml` / `xht` / `mht` / `xsl` / `svgz`** ([core PR #314](https://github.com/qiick-io/externa-core/pull/314)). Rejection is a validation error (`This file type is not allowed.`).

**Breaking (1.3.0):** **SVG, JSON, and XML** are not on the default MIME allowlist (SVG/HTML/SSI/markup also stay extension-denied). From **1.4.0**, operators can widen the allowlist and opt into SVG under **Settings → Files & Media** — HTML/SSI/executables remain on a non-disableable hard floor ([core#327](https://github.com/qiick-io/externa-core/issues/327)).

**Docker nginx:** official images sandbox public `/storage/` with `X-Content-Type-Options: nosniff` and a restrictive CSP (`sandbox; default-src 'none'; …`) so a public-disk asset cannot execute as a document — [Deploy with Docker](https://docs.qiick.io/externa/deploy-with-docker).

Plain-text fields (display **name**, metadata string values, **tags**) pass through `App\Support\Security\PlainTextSanitizer` (`trim` + `strip_tags`) before persist — stored XSS mitigation for the Files UI, not a full HTML sanitizer.

> **Other upload surfaces**
> **Team chat** attachments still accept **any** file type at upload time ([Chat](https://docs.qiick.io/externa/chat#attachments)). Saving a chat attachment into the Files pool goes through `FileService::uploadFile`, so both gates (including SVG/HTML deny + MIME allowlist) apply then. **AI chat** attachments also go through `MimeTypeValidator` (CSV/TXT/XLSX/PDF remain the practical set) — [AI chat](https://docs.qiick.io/externa/ai-chat).

## Public vs private visibility

Each file/folder has an `access` field (`files.access`): `public`, `private`, or `null` (inherit from the nearest ancestor). The detail panel **Visibility** select sets Inherit / Public / Private; folders marked private apply to children unless a child overrides. Admin users always see every file.

**Bytes:** effective-private files on the local `assets` family live on the **`private_assets`** disk (`storage/app/private/assets`) — **not** under `storage:link` / `/storage/assets/...`. `FileTransformService::publicUrl()` returns `null` for private files; the admin File Manager uses the session-auth `GET /files/{file}/download` (and thumbnail) path. Public files keep `{FILE_PUBLIC_URL_BASE}/storage/assets/...`. After upgrading, run `php artisan files:migrate-private-disk` once to move any legacy private bytes still on the public disk — [Files configuration](https://docs.qiick.io/externa/files-configuration).

Public CMS API (role **Files access**): `read` covers public files; `read_private` unlocks effective-private files. Without `read_private`, get/content/transform return **403**, list omits private rows, and item expanders return `null` / omit (no id leak). Without `read` on a **public** file field expand (`include=files`), the payload is `{ "id": N, "access": "denied" }` — grant **Files → Read** on the role. Typical **`public`** role: Read ✓, Read private ✕ — see [Public CMS API](https://docs.qiick.io/externa/public-cms-api#public-vs-private-files) and [Roles & permissions](https://docs.qiick.io/externa/roles-and-permissions#files-access-public-cms-api).

## Leave guard during upload

While any upload is `pending` / `uploading`, the files page registers with the admin leave guard. Navigating away shows **Upload in progress** — **Leave anyway** lets uploads finish in the background (global in-memory store); Keep editing stays on the page. Soft/force delete and bulk delete open a confirm dialog before running.

Open a file’s detail panel via `?file={id}` on `/files` (synced when opening/closing the panel). See [Routing — Admin UI deep-links](https://docs.qiick.io/externa/routing#admin-ui-deep-links-drawers-panels).

## Where used (collection references)

The detail panel **Where used** section calls `GET /files/{file}/where-used` (`files.where-used`). `FileWhereUsedScanner` scans `collections_items_values` for image / file / files / blocks fields that store this file id (on-demand; ~30s cache; no dedicated DB index in v1).

Each hit links to the item editor (`/collections/{id}/items/{item_id}`) and shows the field name. Soft-deleted items are ignored.

Soft/force delete confirms still allow **Delete anyway** when references exist — the dialog shows a reference count when the selected files can be scanned quickly (≤10 files). Open the detail panel for the full list of links.

> **Limits**
> Where-used is an on-demand LIKE prefilter + exact id match. Nested block file fields are supported; very large catalogs may feel slow until a dedicated index lands.

## Capabilities

| Capability          | How                                                                                            |
| ------------------- | ---------------------------------------------------------------------------------------------- |
| Browse              | `GET /files/{folder?}` (Inertia) and `GET /files/list` (JSON, page size 50)                    |
| Folders             | `POST /files/folders` → `FileService::createFolder`                                            |
| Direct upload       | `POST /files/upload` → `uploadFile`                                                            |
| Chunked upload      | `POST /files/uploads/init`, `…/chunk`, `…/complete`; `GET …/status`                            |
| Replace / versions  | `POST /files/{file}/replace` → `replaceFile` / version resolution                              |
| Copy                | `POST /files/{file}/copy` — sync or queued (see below)                                         |
| Move / rename       | `PATCH …/move`, `PATCH …/rename`                                                               |
| Metadata            | `PATCH /files/{file}` → `updateMetadata` (includes visibility `access`)                        |
| Tags                | `GET /files/tags`, `PUT /files/{file}/tags` (Spatie tags)                                      |
| Favorites           | `POST/DELETE /files/{file}/favorite`                                                           |
| Soft / force delete | `DELETE /files/{file}`, `POST …/restore`, `DELETE …/force`                                     |
| Where used          | `GET /files/{file}/where-used` → collection item references                                    |
| Attach / detach     | `POST …/attach`, `POST …/detach` (polymorphic links)                                           |
| Thumbnails          | `GET /files/{file}/thumbnail` → `FileTransformService::ensureThumbnail` (images)               |
| Bulk                | `POST /files/bulk` — move, delete, restore, force_delete, favorite/unfavorite, tag/untag, copy |
| Single download     | `GET /files/{file}/download`                                                                   |
| Zip (async)         | `POST /files/download` → `PrepareFilesZipJob`; download `GET /files/zips/{jobId}`              |

### Duplicate: sync vs queue

`FileController` copy behavior:

- **Sync** when the source is **not** a folder and `size <= config('files.duplicate_sync_max_bytes', 52428800)` (~50 MiB).
- **Queue** `DuplicateFilesJob` otherwise (folders, larger files, or **bulk** `action=copy`) → HTTP **202** with `{ queued: true, job_id }`.

Completed/failed duplication notifies via `FileDuplicationCompletedNotification` / `FileDuplicationFailedNotification`.

### Zip async + notifications

`PrepareFilesZipJob` builds the archive; the user is notified with:

- `FileZipReadyNotification` (`type`: `file_zip_ready`) — download via `files.zips.download`
- `FileZipFailedNotification` (`type`: `file_zip_failed`)

## `can.manage.files` mapping

`EnsureCanManageFiles` resolves the required permission with `EffectivePermissionResolver`, then checks it.

### Named route overrides

| Route name(s)                                                  | Permission                 |
| -------------------------------------------------------------- | -------------------------- |
| `files.restore`                                                | `can-restore-files`        |
| `files.force-delete`                                           | `can-force-delete-files`   |
| `files.download`, `files.download-many`, `files.zips.download` | `can-download-files`       |
| `files.favorite`, `files.unfavorite`                           | `can-favorite-files`       |
| `files.copy`                                                   | `can-copy-files`           |
| `files.replace`                                                | `can-replace-files`        |
| `files.tags`                                                   | `can-tag-files`            |
| `files.update`                                                 | `can-update-file-metadata` |

### Bulk `action` → permission

| `action`                  | Permission               |
| ------------------------- | ------------------------ |
| `restore`                 | `can-restore-files`      |
| `force_delete`            | `can-force-delete-files` |
| `favorite` / `unfavorite` | `can-favorite-files`     |
| `tag` / `untag`           | `can-tag-files`          |
| `copy`                    | `can-copy-files`         |
| `delete`                  | `can-delete-files`       |
| `move` / default          | `can-edit-files`         |

### Default by HTTP method

If no override matches:

| Method          | Permission         |
| --------------- | ------------------ |
| `GET` / `HEAD`  | `can-show-files`   |
| `POST`          | `can-create-files` |
| `PUT` / `PATCH` | `can-edit-files`   |
| `DELETE`        | `can-delete-files` |

## File PermissionEnum values

```
can-show-files
can-create-files
can-edit-files
can-delete-files
can-restore-files
can-force-delete-files
can-download-files
can-favorite-files
can-copy-files
can-replace-files
can-tag-files
can-update-file-metadata
```

## Route checklist

```
GET    /files/list
GET    /files/tags
POST   /files/folders
POST   /files/upload
POST   /files/bulk
POST   /files/download
GET    /files/zips/{jobId}
PATCH  /files/{file}
POST   /files/{file}/replace
POST   /files/{file}/copy
POST   /files/{file}/favorite
DELETE /files/{file}/favorite
PUT    /files/{file}/tags
GET    /files/{file}/download
GET    /files/{file}/thumbnail
PATCH  /files/{file}/move
PATCH  /files/{file}/rename
DELETE /files/{file}
POST   /files/{file}/restore
DELETE /files/{file}/force
POST   /files/{file}/attach
POST   /files/{file}/detach
POST   /files/uploads/init
POST   /files/uploads/chunk
POST   /files/uploads/complete
GET    /files/uploads/status
GET    /files/{folder?}          # Inertia index (whereNumber folder)
```

## Jobs

| Job                           | Notifications                                                               |
| ----------------------------- | --------------------------------------------------------------------------- |
| `App\Jobs\PrepareFilesZipJob` | `FileZipReadyNotification`, `FileZipFailedNotification`                     |
| `App\Jobs\DuplicateFilesJob`  | `FileDuplicationCompletedNotification`, `FileDuplicationFailedNotification` |

## Source map

| Concern          | Location                                                         |
| ---------------- | ---------------------------------------------------------------- |
| Controller       | `app/Http/Controllers/Admin/FileController.php`                  |
| Middleware       | `app/Http/Middleware/EnsureCanManageFiles.php`                   |
| Service          | `app/Services/FileService.php`                                   |
| Extension deny   | `app/Support/Uploads/ForbiddenUploadExtension.php`               |
| MIME / magic     | `app/Support/Uploads/MimeTypeValidator.php` + `config/files.php` |
| Plain-text strip | `app/Support/Security/PlainTextSanitizer.php`                    |
| Alias            | `bootstrap/app.php` → `can.manage.files`                         |
| Page             | `resources/js/pages/admin/files/index.tsx`                       |
| Locale strings   | `resources/js/locales/{en,it,de}.json` → `files.*`               |
