# Files configuration

Source: https://docs.externa.qiick.io/docs/files-configuration

File manager storage and async thresholds are configured in `config/files.php` and `config/filesystems.php`. Env keys are documented under [Environment variables](/docs/environment-variables); this page explains how disks, URLs, and cleanup interact.

## Thresholds (`config/files.php`)

| Config key                 | Env                              | Default              | Meaning                                                                                                                              |
| -------------------------- | -------------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `disk`                     | `FILES_DISK`                     | `assets`             | File manager upload disk: `assets` (local), `s3` (S3/MinIO/R2), `gcs` (Google Cloud Storage, 1.4.0), or `azure` (Azure Blob, 1.4.0). |
| `duplicate_sync_max_bytes` | `FILES_DUPLICATE_SYNC_MAX_BYTES` | `52428800` (50 MB)   | Single non-folder files at or below this size are duplicated **inline**. Folders, bulk selections, and larger files are queued.      |
| `zip_ttl_minutes`          | `FILES_ZIP_TTL_MINUTES`          | `60`                 | Prepared zip archives under `storage/app/zips` expire after this many minutes.                                                       |
| `zip_max_bytes`            | `FILES_ZIP_MAX_BYTES`            | `104857600` (100 MB) | Maximum zip payload size enforced by the application.                                                                                |

```env
FILES_DISK=assets
FILES_DUPLICATE_SYNC_MAX_BYTES=52428800
FILES_ZIP_TTL_MINUTES=60
FILES_ZIP_MAX_BYTES=104857600
```

> **Queue dependency**
> Queued duplicates and zip builds require a running queue worker (`queue:listen` / `queue:work`). See [Operations](/docs/operations).

## Filesystem disks (`config/filesystems.php`)

| Disk             | Driver               | Root / notes                                                                                                                                   |
| ---------------- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `local`          | `local`              | `storage/app/private` — default private storage (`FILESYSTEM_DISK` defaults to `local`). `serve` enabled for authenticated streaming patterns. |
| `public`         | `local`              | `storage/app/public` — URL `{APP_URL}/storage`. Visibility `public`.                                                                           |
| `assets`         | `local`              | `storage/app/public/assets` — **public** file-manager bytes. URL uses `FILE_PUBLIC_URL_BASE` (see below).                                      |
| `private_assets` | `local`              | `storage/app/private/assets` — **effective-private** file-manager bytes. Not in `storage:link`; no public URL.                                 |
| `s3`             | `s3`                 | Optional cloud disk; scaffolded with standard `AWS_*` env vars.                                                                                |
| `gcs`            | `gcs`                | Google Cloud Storage (1.4.0) via `spatie/laravel-google-cloud-storage`; `GOOGLE_CLOUD_*` env vars.                                             |
| `azure`          | `azure-storage-blob` | Azure Blob Storage (1.4.0) via `azure-oss/storage-blob-laravel`; `AZURE_STORAGE_*` env vars.                                                   |

Default disk:

```env
FILESYSTEM_DISK=local
```

### Symbolic link

```php
'links' => [
    public_path('storage') => storage_path('app/public'),
],
```

Create the link once per environment:

```bash
php artisan storage:link
```

Without this link, browsers cannot resolve `/storage/...` URLs for the `public` / `assets` disks. The `private_assets` disk is intentionally **not** linked.

### Private vs public local bytes

When `FILES_DISK=assets` (default), Externa maps effective privacy onto the local pair:

| Effective access | Disk             | Browser URL                                              |
| ---------------- | ---------------- | -------------------------------------------------------- |
| public           | `assets`         | `{FILE_PUBLIC_URL_BASE}/storage/assets/{storage_path}`   |
| private          | `private_assets` | none — admin download / API `/api/v1/files/{id}/content` |

Visibility changes (`updateMetadata` / move into a private folder) move bytes between the two disks. One-shot migration for legacy private rows still on `assets`:

```bash
php artisan files:migrate-private-disk
```

When `FILES_DISK=s3`, objects stay on S3; private files still omit a static public URL and use auth'd paths (bucket ACL is the operator's responsibility).

## `FILE_PUBLIC_URL_BASE`

At the top of `config/filesystems.php`:

```php
$filePublicBase = rtrim((string) env('FILE_PUBLIC_URL_BASE', env('APP_URL', 'http://localhost')), '/');
```

The `assets` disk URL is:

```text
{FILE_PUBLIC_URL_BASE}/storage/assets
```

| Variable               | Fallback  | When to set                                                                             |
| ---------------------- | --------- | --------------------------------------------------------------------------------------- |
| `FILE_PUBLIC_URL_BASE` | `APP_URL` | CDN or different public origin for asset URLs while the app still runs on another host. |

```env
# Optional — omit to use APP_URL
FILE_PUBLIC_URL_BASE=https://cdn.example.com
APP_URL=https://app.example.com
```

The `public` disk URL still uses `APP_URL` directly (`{APP_URL}/storage`). Keep both consistent unless you intentionally split origins.

## Optional S3 / MinIO

Set the **file manager** disk with `FILES_DISK` (default `assets`). Framework default `FILESYSTEM_DISK` stays `local` for private/local attachments.

```env
FILES_DISK=s3
FILESYSTEM_DISK=local

AWS_ACCESS_KEY_ID=externa
AWS_SECRET_ACCESS_KEY=externasecret
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=externa
AWS_ENDPOINT=http://minio:9000
AWS_USE_PATH_STYLE_ENDPOINT=true
AWS_URL=http://localhost:9000/externa
```

Local Compose MinIO: `docker compose --profile minio up` ([Installation — Docker](/docs/installation#docker-compose-local-full-stack)).

Upload, download, where-used, thumbnails/transforms, and zip **source reads** go through the Storage disk API (S3-safe). Chat/AI temporary attachments default to the `local` disk; `absolutePath()` materializes remote objects to a temp file when needed.

## Google Cloud Storage (core 1.4.0)

From externa-core **1.4.0** ([#188](https://github.com/qiick-io/externa-core/issues/188)). The driver ships with core (`spatie/laravel-google-cloud-storage`).

```env
FILES_DISK=gcs
GOOGLE_CLOUD_PROJECT_ID=my-project
GOOGLE_CLOUD_KEY_FILE=/run/secrets/gcs-service-account.json
GOOGLE_CLOUD_STORAGE_BUCKET=externa-files
# GOOGLE_CLOUD_STORAGE_PATH_PREFIX=
# GOOGLE_CLOUD_STORAGE_API_URI=
# GOOGLE_CLOUD_STORAGE_API_ENDPOINT=
# GOOGLE_CLOUD_STORAGE_VISIBILITY=private
```

| Env                                 | Default   | Notes                                                                                                            |
| ----------------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------- |
| `GOOGLE_CLOUD_PROJECT_ID`           | empty     | GCP project id                                                                                                   |
| `GOOGLE_CLOUD_KEY_FILE`             | empty     | Service-account JSON path. Leave empty to use Application Default Credentials / `GOOGLE_APPLICATION_CREDENTIALS` |
| `GOOGLE_CLOUD_STORAGE_BUCKET`       | empty     | Bucket name                                                                                                      |
| `GOOGLE_CLOUD_STORAGE_PATH_PREFIX`  | empty     | Optional key prefix inside the bucket                                                                            |
| `GOOGLE_CLOUD_STORAGE_API_URI`      | empty     | Optional public URI base for object URLs                                                                         |
| `GOOGLE_CLOUD_STORAGE_API_ENDPOINT` | empty     | Optional API endpoint (emulators)                                                                                |
| `GOOGLE_CLOUD_STORAGE_VISIBILITY`   | `private` | Default object visibility                                                                                        |

Objects are written with `Cache-Control: public,max-age=86400`.

## Azure Blob Storage (core 1.4.0)

From externa-core **1.4.0** ([#188](https://github.com/qiick-io/externa-core/issues/188)). The driver ships with core (`azure-oss/storage-blob-laravel`).

```env
FILES_DISK=azure
AZURE_STORAGE_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=…;AccountKey=…;EndpointSuffix=core.windows.net"
AZURE_STORAGE_CONTAINER=externa
# AZURE_STORAGE_PREFIX=
# AZURE_STORAGE_URL=
```

| Env                               | Default   | Notes                                             |
| --------------------------------- | --------- | ------------------------------------------------- |
| `AZURE_STORAGE_CONNECTION_STRING` | empty     | Shared-key connection string (simplest auth path) |
| `AZURE_STORAGE_CONTAINER`         | `externa` | Container name                                    |
| `AZURE_STORAGE_PREFIX`            | empty     | Optional blob prefix                              |
| `AZURE_STORAGE_URL`               | empty     | Optional public base URL (CDN / custom domain)    |

Entra ID / managed identity options are documented by the package.

> **Admin CSP and remote disks**
> The admin [security headers](/docs/configuration#security-headers) add the origin of the configured disk's `url` (`AWS_URL` for `s3`, `AZURE_STORAGE_URL` for `azure`) to `img-src` and `media-src`, so previews from the bucket load. The `gcs` disk has no `url` key, so image previews rely on the default `img-src https:`; if video/audio previews from GCS are blocked, set a full `SECURITY_CSP`. Files still stored on a previous disk after a `FILES_DISK` switch also need a `SECURITY_CSP` override.

### Remaining limits (precise)

| Still local / ephemeral                        | Why                                                                                                                                                                                |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Prepared zip archives under `storage/app/zips` | Built on a shared volume between `app` and Horizon; not sticky to one host bind for **user files**, but zips themselves are local ephemeral artifacts (TTL + `files:cleanup-zips`) |
| Chunk upload scratch on the chosen disk        | Chunks live on `FILES_DISK` (S3 OK); session metadata is in DB                                                                                                                     |
| `storage:link` for `assets`                    | Only required when `FILES_DISK=assets` (local public assets)                                                                                                                       |

> **Multi-container**
> With `FILES_DISK=s3` (or `gcs` / `azure`), app replicas do **not** need a sticky host bind for CMS user files. Still share a `storage` volume (or accept zip loss) so prepared downloads and local chat temps work across Horizon workers.

## Cleanup commands

Scheduled in `routes/console.php` (see [Operations](/docs/operations)):

| Command                      | Schedule | Purpose                                                                                                                                                |
| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `files:cleanup-uploads`      | hourly   | Removes expired **partial/chunked** upload sessions and their chunk storage (`CleanupStaleFileUploadsCommand` → `FileService::cleanupStaleUploads()`). |
| `files:cleanup-zips`         | hourly   | Removes prepared zip downloads older than `zip_ttl_minutes` (`CleanupExpiredFileZipsCommand` → `FileService::cleanupExpiredZips()`).                   |
| `files:migrate-private-disk` | manual   | Moves effective-private local files from `assets` → `private_assets` (upgrade / one-shot).                                                             |

Run manually:

```bash
php artisan files:cleanup-uploads
php artisan files:cleanup-zips
php artisan files:migrate-private-disk
```

## Upload policy (app code, not env)

Thresholds above do **not** alone configure which file types are accepted. From core **1.3.0**, uploads use an **extension denylist** (`ForbiddenUploadExtension`) **plus** a **MIME allowlist + magic-byte check** (`MimeTypeValidator`, `config/files.php`). From **1.4.0**, Settings → Files & Media can override the MIME/extension allowlist and opt into **SVG**; the hard floor (executables, HTML, SSI) cannot be disabled. Defaults still reject SVG/JSON/XML until opted in. See [File manager — Upload security](/docs/file-manager#upload-security).

Operator-tunable **byte caps**, **upload type policy**, and **image transform presets** live under **Settings → Files & Media** (`/settings/project/files`) — [Project settings — Files & Media](/docs/project-settings#files-and-media-settings-project-files) · [Image transforms](/docs/image-transforms).

## Related HTTP surface

JSON and Inertia file-manager endpoints live under `/files/*` with the `can.manage.files` middleware. Full route table: [Admin API](/docs/admin-api). Data model notes: [File tree model](/docs/file-tree-model).
