<!--
	covers: commands/FilesController.php config/parts/params.php controllers/PicturesController.php models/File.php models/Photo.php models/Picture.php models/PictureToOptimize.php
	verified: 3c56e1b
-->
# Media — files, pictures, photos

Three models in a chain, on-the-fly image versions, a compression queue.

## The model chain

```
File          — any uploaded file: name, type, extension, size
 └─ Picture   — a File that is an image: + width, height
Photo         — a gallery entry: pictureId + one owner FK + isPublished
Attachment    — a downloadable file attached to a record
```

`Picture extends File`; `Photo` and `Attachment` are independent `ActiveRecord`s that point at a `File`/`Picture`.

`File::ACCEPTED_CONTENT_TYPES` is `null` (anything); `Picture` narrows it to `/^image\/.+$/`.

## Where files live on disk

Under `web/contents/`, bucketed by the record's creation date:

```
web/contents/<Y>/<m>/<d>/<slug>-<id>-<His>[-<version>][-wms].<ext>
```

Built by `File::getFileName()` / `getPath()` ([`models/File.php:92`](../../models/File.php)). The pieces:

| Piece | Source |
|-------|--------|
| `<slug>` | `File::$createSlugFrom = 'name'` |
| `<id>` | the record id |
| `<His>` | `date('His')` of `createdAt` — a cache-buster tied to the record |
| `-<version>` | the named image version, only when `APP_FILE_VERSIONS_ENABLED` |
| `-wms` | the watermark postfix, from `Yii::$app->params['watermark']['postfix']` |

The URL mirrors the path: `/contents/<Y>/<m>/<d>/<filename>`.

## Image versions are generated on request

`config/components/urlManager.php` routes
`contents/<filename:.+\.(jpg|jpeg|png|bmp|gif)>` → `pictures/generate`, so a **404 on a version file becomes a
generation request**. nginx serves the file if it exists; if it does not, the request reaches
[`PicturesController::actionGenerate()`](../../controllers/PicturesController.php).

That action:

1. **Parses the filename back into its parts** — date segments, watermark postfix, version name, `His`
   timestamp, picture id. Anything malformed is a `NotFoundHttpException`. This parse is the security
   boundary: the version name must be a key of `Yii::$app->params['pictureVersions']`, the timestamp must be
   exactly 6 digits, the id numeric.
2. Loads the `Picture`, resizes with `claviska/simpleimage` according to the version spec.
3. Optionally overlays the watermark.
4. **Queues the result for compression** (`PictureToOptimize`), then writes the file, `chmod 0777`s it, and
   streams it to the browser with `toScreen()` + `die()`.

### The version registry

[`config/parts/params.php`](../../config/parts/params.php), `pictureVersions`. Each entry is
`width` / `height` / `cover` / `contain` / `quality`:

| Key | Constant | Spec |
|-----|----------|------|
| `adm` | `FileField::PICTURE_VERSION_ADMIN` | height 70 |
| `sqr` | `FileField::PICTURE_VERSION_SQUARE` | 160×160 contain |
| `pso` | `Photo::SIZE_DOCUMENT` | width 800 |
| `loc` | `Photo::SIZE_LOCATION` | 405×405 cover |
| `lcc` | `Photo::SIZE_LOCATION_CONTAIN` | 405×405 contain |
| — | `Article::PHOTO_SIZE_SMALL` | 516×344 contain |
| — | `Storage::PICTURE_SIZE_COMPARISON` | 240×160 cover |

The resize mode is chosen by which keys are present
([`controllers/PicturesController.php:84`](../../controllers/PicturesController.php)):

```
width XOR height  → resize()
width + height + contain → bestFit()
width + height + cover   → thumbnail()
otherwise                → Exception
```

**A version with both dimensions but neither `cover` nor `contain` throws.** Adding a version means adding the
constant on the model *and* the entry in `params.php`.

## Compression queue

`PictureToOptimize` rows are written by `actionGenerate()`. The cron
`./yii files/pictures-to-optimize` calls `PictureToOptimize::generateShellScript()`, which writes
`runtime/pictures-to-optimize.sh` running `optipng` on PNGs and `jpegoptim` on JPEGs.

> **The queue rows are deleted when the script is written, not when it succeeds.** If the shell script does
> not run, those images stay uncompressed forever — nothing re-queues them.

## Deletion

`File::afterDelete()` (`models/File.php:64`):

```php
$this->updateRelated( $this->contents );                       // null out contents.fileId
$mask = str_replace( '.' . $this->extension, '*', $this->fullPath );
foreach( glob( $mask ) as $file ) unlink( $file );             // original + every version
```

The glob mask is built by replacing the extension with `*`, so it catches every generated version and the
watermarked variants in one pass.

`Photo::afterDelete()` deletes its `Picture`, which cascades into the above.

## Creating files programmatically

| Method | Use |
|--------|-----|
| `File::createFromFile( $filename, $name )` | the base; everything else funnels here |
| `File::createFromUrl( $url, $name )` | downloads via `yii\httpclient` + `CurlTransport` into `runtime/`, then delegates; unlinks the temp file on both paths |
| — | **the legacy photo import always ends in `createFromFile()`.** A source image not already in `data/import/files/` is fetched into that tree first and then read from disk, so one image used on twelve units costs one download. Keyed on (owner, owner id, Drupal `fid`), so an existing photo is skipped entirely. See [MIGRATION-AND-IMPORT.md](MIGRATION-AND-IMPORT.md#photos). |
| `File::createFromString( $content, $name, $extension )` | writes to `runtime/` then delegates |

`Picture::addExtraAttributes()` overrides the hook to read `getimagesize()` and fill `width`/`height`,
throwing if the dimensions cannot be read.

## Photo ownership is a set of nullable FKs

`Photo` carries `articleId`, `storageId`, `storageTypeId`, `locationId`, `serviceId` — one column per possible
owner, all nullable, all indexed. **There is no polymorphic relation** (unlike the sibling Molino project).
Adding a gallery to a new model means adding another column to `Photo::getColumns()` and a migration.

`Photo::getInlineGridRow()` renders the admin sub-grid row directly as an HTML string — thumbnail, link,
filename — bypassing the field system.

## Traps

1. **The `test` environment falls back to live URLs.** `File::getUrl()` and `actionGenerate()` both check
   `YII_ENV_TEST && !is_file( $this->fullPath )` and redirect to `https://raktar24.hu/contents/…`, so a
   staging site with no uploads silently borrows production images.
2. **Generated versions are written with `0777`.** Deliberate, so the web user and the CLI user can both
   replace them.
3. **`APP_FILE_VERSIONS_ENABLED = false` collapses every version to the original filename** — the version and
   watermark suffixes disappear from `getFileName()`, so all versions resolve to the same file.
4. **The generation route only matches five extensions** (`jpg jpeg png bmp gif`). A WebP upload is served
   statically or 404s; it never reaches the generator.
5. **`actionGenerate()` ends in `die()`**, so nothing after it in the request runs — including the output
   rewriting in `View::endPage()`.
6. **`web/contents/` is not in Git and is not deployed.** A file created by an import here has to be copied
   to the server by hand. Everything one import wrote is under `web/contents/<the day it ran>/`, because
   `getPath()` buckets by `createdAt` — so the upload is one directory. `./yii import/contents-manifest`
   verifies afterwards that nothing is missing.
7. **`photo.legacyId` is the Drupal file id, and it is not unique** — one source image used on three units is
   three photos. The photo importer's key is the pair (owner, `legacyId`).
