Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.92% covered (warning)
87.92%
211 / 240
65.00% covered (warning)
65.00%
13 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Media
87.92% covered (warning)
87.92%
211 / 240
65.00% covered (warning)
65.00%
13 / 20
92.58
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 upload
78.79% covered (warning)
78.79%
26 / 33
0.00% covered (danger)
0.00%
0 / 1
9.77
 ingestFailure
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 library
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
8
 filterKinds
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 since
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 libraryItem
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 detail
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 updateMeta
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 detailItem
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 localeIds
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 delete
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
6.47
 purgeRenditions
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 uploadResult
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 uploadedFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 userId
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 cache
88.89% covered (warning)
88.89%
24 / 27
0.00% covered (danger)
0.00%
0 / 1
8.09
 sizeSpec
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
10
 sendFile
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getAssets
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Controller;
6
7use Celema\Core\Exception\HttpNotFound;
8use Celema\Core\Exception\OutOfBoundsException;
9use Celema\Core\Exception\RuntimeException as CoreRuntimeException;
10use Celema\Core\Factory\Factory;
11use Celema\Core\Request;
12use Celema\Core\Response;
13use Celema\Quma\Database;
14use Cosray\Actor;
15use Cosray\Assets\Asset;
16use Cosray\Assets\Assets;
17use Cosray\Assets\Ingest;
18use Cosray\Assets\Meta;
19use Cosray\Assets\SizeSpec;
20use Cosray\Auth;
21use Cosray\Config;
22use Cosray\Exception\IngestError;
23use Cosray\Exception\RuntimeException;
24use Cosray\Locales;
25use Cosray\Middleware\Permission;
26use Cosray\References\Usage;
27use Cosray\Storage\Storage;
28use Cosray\Users;
29use PDOException;
30use Psr\Http\Message\UploadedFileInterface as PsrUploadedFile;
31use RecursiveDirectoryIterator;
32use RecursiveIteratorIterator;
33
34class Media
35{
36    protected ?Assets $assets = null;
37
38    public function __construct(
39        protected readonly Factory $factory,
40        protected readonly Request $request,
41        protected readonly Config $config,
42        protected readonly Database $db,
43        protected readonly Locales $locales,
44    ) {}
45
46    #[Permission('panel')]
47    public function upload(string $mediatype): Response
48    {
49        $response = Response::create($this->factory);
50        $file = $this->uploadedFile();
51        $filename = $file !== null
52            ? Ingest::safeFilename((string) ($file->getClientFilename() ?? ''))
53            : '';
54
55        if ($file === null || $filename === '') {
56            return $response->json([
57                'ok' => false,
58                'error' => __('media:upload-failed'),
59                'file' => __('media:unknown-filename'),
60            ], 400);
61        }
62
63        $error = $file->getError();
64        $contents = $error === UPLOAD_ERR_OK ? (string) $file->getStream() : '';
65        $fileSize = $file->getSize() ?? strlen($contents);
66        $maxSize = $this->config->upload->maxSize;
67
68        // PHP truncates oversized uploads before the stream reaches us, so
69        // this limit check must run on the transport size, not the bytes.
70        if ($error === UPLOAD_ERR_INI_SIZE || $fileSize > $maxSize) {
71            return $this->ingestFailure($response, IngestError::tooLarge($fileSize, $maxSize), $filename);
72        }
73
74        if ($error !== UPLOAD_ERR_OK) {
75            return $response->json([
76                'ok' => false,
77                'file' => $filename,
78                'error' => __('media:upload-server-error'),
79                'code' => 0,
80            ], 400);
81        }
82
83        try {
84            $result = new Ingest($this->config, $this->db)->ingest(
85                $contents,
86                $filename,
87                $mediatype,
88                new Actor($this->userId()),
89            );
90        } catch (IngestError $e) {
91            return $this->ingestFailure($response, $e, $filename);
92        }
93
94        return $response->json($this->uploadResult($result->row));
95    }
96
97    protected function ingestFailure(Response $response, IngestError $e, string $filename): Response
98    {
99        $payload = [
100            'ok' => false,
101            'file' => $filename,
102            'error' => $e->userMessage,
103            'code' => 0,
104        ];
105
106        if ($e->mime !== null) {
107            $payload['mime'] = $e->mime;
108        }
109
110        return $response->json($payload, 400);
111    }
112
113    /**
114     * Paged asset catalog listing for the panel (media screen, library
115     * picker, link modal). `kind` takes a comma-separated set from the
116     * filter vocabulary image/video/audio/document â€” which splits the
117     * catalog kind `file` in two; `file` itself (a File field accepts
118     * every kind) and no kind list everything. `q` matches the filename,
119     * `since` cuts on the created timestamp. `counts` reports per-kind
120     * totals honoring `q` and `since` but not `kind`, so a filter UI can
121     * show what selecting each kind would yield.
122     */
123    #[Permission('panel')]
124    public function library(): Response
125    {
126        $params = $this->request->params();
127        $q = trim((string) ($params['q'] ?? ''));
128        $page = max(1, (int) ($params['page'] ?? 1));
129        $limit = 60;
130        $args = ['limit' => $limit + 1, 'offset' => ($page - 1) * $limit];
131        // The null seed keeps the args named and non-empty when no filter
132        // applies â€” Quma templates refuse empty argument lists â€” and
133        // isset() in the template still skips the clause.
134        $countArgs = ['q' => null];
135
136        $kinds = $this->filterKinds((string) ($params['kind'] ?? ''));
137
138        if ($kinds !== []) {
139            $args['kinds'] = json_encode($kinds);
140        }
141
142        if ($q !== '') {
143            $args['q'] = '%' . addcslashes($q, '%_\\') . '%';
144            $countArgs['q'] = $args['q'];
145        }
146
147        $since = $this->since($params['since'] ?? null);
148
149        if ($since !== null) {
150            $args['since'] = $since;
151            $countArgs['since'] = $since;
152        }
153
154        if (isset($params['uids']) && $params['uids'] !== '') {
155            $args['uids'] = explode(',', (string) $params['uids']);
156        }
157
158        $rows = $this->db->assets->list($args)->all();
159        $more = count($rows) > $limit;
160        $counts = ['image' => 0, 'video' => 0, 'audio' => 0, 'document' => 0];
161
162        foreach ($this->db->assets->counts($countArgs)->all() as $row) {
163            $counts[(string) $row['kind']] = (int) $row['total'];
164        }
165
166        return Response::create($this->factory)->json([
167            'ok' => true,
168            'assets' => array_map($this->libraryItem(...), array_slice($rows, 0, $limit)),
169            'page' => $page,
170            'more' => $more,
171            // 0 when paging past the end: the window count needs a row to ride on.
172            'total' => $rows === [] ? 0 : (int) $rows[0]['total'],
173            'counts' => $counts,
174        ]);
175    }
176
177    /** @return list<string> */
178    protected function filterKinds(string $kind): array
179    {
180        $valid = ['image', 'video', 'audio', 'document'];
181        $requested = array_values(array_intersect($valid, array_map(trim(...), explode(',', $kind))));
182
183        // All four match everything; skipping the clause keeps the plan flat.
184        return count($requested) === count($valid) ? [] : $requested;
185    }
186
187    /** A created-timestamp cutoff, normalized; invalid input means none. */
188    protected function since(mixed $value): ?string
189    {
190        if (!is_string($value) || trim($value) === '') {
191            return null;
192        }
193
194        $time = strtotime($value);
195
196        return $time === false ? null : date(DATE_ATOM, $time);
197    }
198
199    protected function libraryItem(array $row): array
200    {
201        $asset = Asset::fromRow($row, $this->config);
202
203        return [
204            'uid' => $asset->uid,
205            'filename' => $asset->filename,
206            'url' => $asset->path(),
207            'thumbUrl' => $asset->resizable() ? $asset->sizePath('thumb') : $asset->path(),
208            'previewUrl' => $asset->resizable() ? $asset->sizePath('preview') : $asset->path(),
209            'kind' => $asset->kind,
210            'mime' => $asset->mime,
211            'bytes' => $asset->bytes,
212            'width' => $asset->width,
213            'height' => $asset->height,
214        ];
215    }
216
217    /**
218     * Single-asset detail for the media panel: the catalog row plus its
219     * editable meta and the display-ready usage list (who points at it).
220     */
221    #[Permission('panel')]
222    public function detail(string $uid): Response
223    {
224        $response = Response::create($this->factory);
225        $row = $this->db->assets->byUid(['uid' => $uid])->first();
226
227        if (!$row) {
228            return $response->json(['ok' => false, 'error' => __('media:unknown-file')], 404);
229        }
230
231        return $response->json([
232            'ok' => true,
233            'asset' => $this->detailItem(Asset::fromRow($row, $this->config), $row),
234            'usage' => new Usage($this->db)->forAsset($uid),
235        ]);
236    }
237
238    /**
239     * Persist the editable meta slice (localized alt/title/caption,
240     * scalar credit, image focal point). The submitted patch replaces
241     * the managed keys and leaves the rest of the bag untouched.
242     */
243    #[Permission('panel')]
244    public function updateMeta(string $uid): Response
245    {
246        $response = Response::create($this->factory);
247        $row = $this->db->assets->byUid(['uid' => $uid])->first();
248
249        if (!$row) {
250            return $response->json(['ok' => false, 'error' => __('media:unknown-file')], 404);
251        }
252
253        $stored = json_decode((string) ($row['meta'] ?? '{}'), true);
254        $input = $this->request->json();
255        $meta = Meta::apply(
256            is_array($stored) ? $stored : [],
257            is_array($input) ? $input['meta'] ?? $input : [],
258            $this->localeIds(),
259            Asset::fromRow($row, $this->config)->kind === 'image',
260        );
261
262        $this->db->assets->updateMeta(['uid' => $uid, 'meta' => json_encode($meta)])->run();
263
264        return $response->json(['ok' => true, 'meta' => $meta]);
265    }
266
267    protected function detailItem(Asset $asset, array $row): array
268    {
269        return [
270            'uid' => $asset->uid,
271            'filename' => $asset->filename,
272            'kind' => $asset->kind,
273            'mime' => $asset->mime,
274            'bytes' => $asset->bytes,
275            'width' => $asset->width,
276            'height' => $asset->height,
277            'url' => $asset->path(),
278            'previewUrl' => $asset->resizable() ? $asset->sizePath('preview') : $asset->path(),
279            'created' => isset($row['created']) ? (string) $row['created'] : null,
280            'meta' => $asset->meta,
281        ];
282    }
283
284    /** @return list<string> */
285    protected function localeIds(): array
286    {
287        $ids = [];
288
289        foreach ($this->locales as $locale) {
290            $ids[] = $locale->id;
291        }
292
293        return $ids;
294    }
295
296    /**
297     * Hard delete, unreferenced-only: the usage check answers 409 with
298     * a display-ready owner list; the RESTRICT FK on `asset_references`
299     * is the backstop against references appearing mid-request. The
300     * catalog row goes first â€” a leftover file is a harmless orphan, a
301     * dangling row is not.
302     */
303    #[Permission('panel')]
304    public function delete(string $uid): Response
305    {
306        $response = Response::create($this->factory);
307        $row = $this->db->assets->byUid(['uid' => $uid])->first();
308
309        if (!$row) {
310            return $response->json(['ok' => false, 'error' => __('media:unknown-file')], 404);
311        }
312
313        $usage = new Usage($this->db);
314        $owners = $usage->forAsset($uid);
315
316        if ($owners !== []) {
317            return $response->json(['ok' => false, 'usage' => $owners], 409);
318        }
319
320        try {
321            $this->db->assets->delete(['uid' => $uid])->run();
322        } catch (PDOException $e) {
323            // RESTRICT violations report SQLSTATE 23001; plain FK
324            // violations 23503.
325            if (in_array((string) $e->getCode(), ['23001', '23503'], true)) {
326                return $response->json(['ok' => false, 'usage' => $usage->forAsset($uid)], 409);
327            }
328
329            throw $e;
330        }
331
332        if ($row['disk'] === 'local') {
333            new Storage($this->config)->deleteDirectory(dirname((string) $row['key']));
334            $this->purgeRenditions((string) $row['key']);
335        }
336
337        return $response->json(['ok' => true]);
338    }
339
340    /** Removes the rendition cache directory `{cache}/{shard}/{uid}/`. */
341    protected function purgeRenditions(string $key): void
342    {
343        $root = rtrim($this->config->path->public, '\\/') . '/' . trim($this->config->path->cache, '/');
344        $dir = $root . '/' . dirname($key);
345
346        if (!is_dir($dir) || !str_starts_with((string) realpath($dir), (string) realpath($root))) {
347            return;
348        }
349
350        $files = new RecursiveIteratorIterator(
351            new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
352            RecursiveIteratorIterator::CHILD_FIRST,
353        );
354
355        foreach ($files as $file) {
356            $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
357        }
358
359        rmdir($dir);
360    }
361
362    /** Build the client payload for a catalog row. */
363    protected function uploadResult(array $row): array
364    {
365        $asset = Asset::fromRow($row, $this->config);
366
367        return [
368            'ok' => true,
369            'error' => '',
370            'uid' => $asset->uid,
371            'filename' => $asset->filename,
372            'kind' => $asset->kind,
373            'mime' => $asset->mime,
374            'bytes' => $asset->bytes,
375            'width' => $asset->width,
376            'height' => $asset->height,
377            'url' => $asset->path(),
378            'thumbUrl' => $asset->resizable() ? $asset->sizePath('thumb') : $asset->path(),
379            'previewUrl' => $asset->resizable() ? $asset->sizePath('preview') : $asset->path(),
380        ];
381    }
382
383    protected function uploadedFile(): ?PsrUploadedFile
384    {
385        try {
386            return $this->request->file('file');
387        } catch (CoreRuntimeException|OutOfBoundsException) {
388            return null;
389        }
390    }
391
392    protected function userId(): int
393    {
394        $auth = new Auth(
395            $this->request->unwrap(),
396            new Users($this->db),
397            $this->config,
398            $this->request->get('session', null),
399        );
400        $user = $auth->user();
401
402        if (!$user) {
403            throw new RuntimeException('Upload requires an authenticated user');
404        }
405
406        return $user->id;
407    }
408
409    /**
410     * Fallback for rendition URLs whose file does not exist yet: the web
411     * server serves `{path.cache}/{shard}/{uid}/{stem}-{size}.{ext}`
412     * natively once generated, so PHP only ever sees the first request.
413     * Only sizes configured in `media.sizes` are generated â€” anything
414     * else is a 404, which bounds what this route can write to disk.
415     */
416    public function cache(string $slug): Response
417    {
418        $segments = explode('/', $slug);
419
420        if (count($segments) !== 3) {
421            throw new HttpNotFound($this->request);
422        }
423
424        [$shard, $uid, $file] = $segments;
425        $row = $this->db->assets->byUid(['uid' => $uid])->first();
426
427        if (!$row || $row['disk'] !== 'local') {
428            throw new HttpNotFound($this->request);
429        }
430
431        $asset = Asset::fromRow($row, $this->config);
432
433        if (dirname($asset->key) !== "{$shard}/{$uid}" || !$asset->resizable()) {
434            throw new HttpNotFound($this->request);
435        }
436
437        $spec = $this->sizeSpec($asset->key, $file);
438
439        try {
440            $image = $this
441                ->getAssets()
442                ->image($asset->key)
443                ->resize(
444                    $spec->size(),
445                    $spec->mode,
446                    $spec->enlarge,
447                    $spec->quality,
448                    $spec->name,
449                );
450        } catch (RuntimeException $e) {
451            throw new HttpNotFound($this->request, previous: $e);
452        }
453
454        $fileServer = $this->config->media->fileServer;
455
456        if ($fileServer) {
457            return $this->sendFile($fileServer, $image->path());
458        }
459
460        return Response::create($this->factory)->file($image->path());
461    }
462
463    /**
464     * Match a requested rendition basename against the asset's key and
465     * the configured sizes: `{stem}-{size}` with the key's extension.
466     */
467    protected function sizeSpec(string $key, string $file): SizeSpec
468    {
469        $base = basename($key);
470        $dot = strrpos($base, '.');
471        $stem = $dot === false || $dot === 0 ? $base : substr($base, 0, $dot);
472        $ext = $dot === false || $dot === 0 ? '' : substr($base, $dot);
473        $sizes = $this->config->media->sizes;
474
475        if (str_starts_with($file, "{$stem}-") && ($ext === '' || str_ends_with($file, $ext))) {
476            $name = substr($file, strlen($stem) + 1, strlen($file) - strlen($stem) - 1 - strlen($ext));
477
478            if ($name !== '' && $sizes->has($name)) {
479                return $sizes->get($name);
480            }
481        }
482
483        throw new HttpNotFound($this->request);
484    }
485
486    protected function sendFile(string $fileServer, string $file): Response
487    {
488        $response = Response::create($this->factory);
489        $response->header('Content-Type', mime_content_type($file));
490
491        switch ($fileServer) {
492            case 'apache':
493                // apt install libapache2-mod-xsendfile
494                // a2enmod xsendfile
495                // Apache config:
496                //    XSendFile On
497                //    XSendFilePath "/path/to/files"
498                $response->header('X-Sendfile', $file);
499                break;
500            case 'nginx':
501                // Nginx config
502                //   location /path/to/files/ {
503                //       internal;
504                //           alias   /some/path/; # note the trailing slash
505                //       }
506                //   }
507
508                $response->header('X-Accel-Redirect', $file);
509                break;
510            default:
511                throw new RuntimeException(
512                    'File server not supported: `' . $fileServer . '`. Supported values `nginx`, `apache`.',
513                );
514        }
515
516        return $response;
517    }
518
519    protected function getAssets(): Assets
520    {
521        return $this->assets ??= new Assets($this->config);
522    }
523}