Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.51% covered (warning)
86.51%
109 / 126
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Media
86.51% covered (warning)
86.51%
109 / 126
60.00% covered (warning)
60.00%
6 / 10
48.75
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
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
10
 ingestFailure
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 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
30
 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\SizeSpec;
19use Cosray\Auth;
20use Cosray\Config;
21use Cosray\Exception\IngestError;
22use Cosray\Exception\RuntimeException;
23use Cosray\Middleware\Permission;
24use Cosray\Users;
25use Psr\Http\Message\UploadedFileInterface as PsrUploadedFile;
26use Psr\Log\LoggerInterface as Logger;
27use Psr\Log\LogLevel;
28
29class Media
30{
31    /** PHP's upload error codes besides the size limit, by name. */
32    private const array UPLOAD_ERRORS = [
33        UPLOAD_ERR_FORM_SIZE => 'UPLOAD_ERR_FORM_SIZE',
34        UPLOAD_ERR_PARTIAL => 'UPLOAD_ERR_PARTIAL',
35        UPLOAD_ERR_NO_FILE => 'UPLOAD_ERR_NO_FILE',
36        UPLOAD_ERR_NO_TMP_DIR => 'UPLOAD_ERR_NO_TMP_DIR',
37        UPLOAD_ERR_CANT_WRITE => 'UPLOAD_ERR_CANT_WRITE',
38        UPLOAD_ERR_EXTENSION => 'UPLOAD_ERR_EXTENSION',
39    ];
40
41    protected ?Assets $assets = null;
42
43    public function __construct(
44        protected readonly Factory $factory,
45        protected readonly Request $request,
46        protected readonly Config $config,
47        protected readonly Database $db,
48        protected readonly Logger $logger,
49    ) {}
50
51    #[Permission('panel')]
52    public function upload(string $mediatype): Response
53    {
54        $response = Response::create($this->factory);
55        $file = $this->uploadedFile();
56        $filename = $file !== null
57            ? Ingest::safeFilename((string) ($file->getClientFilename() ?? ''))
58            : '';
59
60        if ($file === null || $filename === '') {
61            return $response->json([
62                'ok' => false,
63                'error' => __('media:upload-failed'),
64                'file' => __('media:unknown-filename'),
65            ], 400);
66        }
67
68        $error = $file->getError();
69        $contents = $error === UPLOAD_ERR_OK ? (string) $file->getStream() : '';
70        $fileSize = $file->getSize() ?? strlen($contents);
71        $maxSize = $this->config->upload->maxSize;
72
73        // PHP truncates oversized uploads before the stream reaches us, so
74        // this limit check must run on the transport size, not the bytes.
75        if ($error === UPLOAD_ERR_INI_SIZE || $fileSize > $maxSize) {
76            return $this->ingestFailure($response, IngestError::tooLarge($fileSize, $maxSize), $filename);
77        }
78
79        if ($error !== UPLOAD_ERR_OK) {
80            // A missing or unwritable temp directory or a blocking extension
81            // fails every upload until someone fixes the server; the others
82            // are interrupted or incomplete requests.
83            $serverSide = in_array($error, [UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE, UPLOAD_ERR_EXTENSION], true);
84            $this->logger->log($serverSide ? LogLevel::ERROR : LogLevel::INFO, 'Upload of {file} failed: {code}', [
85                'file' => $filename,
86                'code' => self::UPLOAD_ERRORS[$error] ?? (string) $error,
87            ]);
88
89            return $response->json([
90                'ok' => false,
91                'file' => $filename,
92                'error' => __('media:upload-server-error'),
93                'code' => 0,
94            ], 400);
95        }
96
97        try {
98            $result = new Ingest($this->config, $this->db)->ingest(
99                $contents,
100                $filename,
101                $mediatype,
102                new Actor($this->userId()),
103            );
104        } catch (IngestError $e) {
105            return $this->ingestFailure($response, $e, $filename);
106        }
107
108        return $response->json($this->uploadResult($result->row));
109    }
110
111    protected function ingestFailure(Response $response, IngestError $e, string $filename): Response
112    {
113        $payload = [
114            'ok' => false,
115            'file' => $filename,
116            'error' => $e->userMessage,
117            'code' => 0,
118        ];
119
120        if ($e->mime !== null) {
121            $payload['mime'] = $e->mime;
122        }
123
124        return $response->json($payload, 400);
125    }
126
127    /**
128     * Build the client payload for a catalog row.
129     *
130     * @param array<string, mixed> $row
131     * @return array<string, mixed>
132     */
133    protected function uploadResult(array $row): array
134    {
135        $asset = Asset::fromRow($row, $this->config);
136
137        return [
138            'ok' => true,
139            'error' => '',
140            'uid' => $asset->uid,
141            'filename' => $asset->filename,
142            'kind' => $asset->kind,
143            'mime' => $asset->mime,
144            'bytes' => $asset->bytes,
145            'width' => $asset->width,
146            'height' => $asset->height,
147            'url' => $asset->path(),
148            'thumbUrl' => $asset->resizable() ? $asset->sizePath('thumb') : $asset->path(),
149            'previewUrl' => $asset->resizable() ? $asset->sizePath('preview') : $asset->path(),
150        ];
151    }
152
153    protected function uploadedFile(): ?PsrUploadedFile
154    {
155        try {
156            return $this->request->file('file');
157        } catch (CoreRuntimeException|OutOfBoundsException) {
158            return null;
159        }
160    }
161
162    protected function userId(): int
163    {
164        $auth = new Auth(
165            $this->request->unwrap(),
166            new Users($this->db),
167            $this->config,
168            $this->request->get('session', null),
169        );
170        $user = $auth->user();
171
172        if (!$user) {
173            throw new RuntimeException('Upload requires an authenticated user');
174        }
175
176        return $user->id;
177    }
178
179    /**
180     * Fallback for rendition URLs whose file does not exist yet: the web
181     * server serves `{path.cache}/{shard}/{uid}/{stem}-{size}.{ext}`
182     * natively once generated, so PHP only ever sees the first request.
183     * Only sizes configured in `media.sizes` are generated â€” anything
184     * else is a 404, which bounds what this route can write to disk.
185     */
186    public function cache(string $slug): Response
187    {
188        $segments = explode('/', $slug);
189
190        if (count($segments) !== 3) {
191            throw new HttpNotFound($this->request);
192        }
193
194        [$shard, $uid, $file] = $segments;
195        $row = $this->db->assets->byUid(['uid' => $uid])->first();
196
197        if (!$row || $row['disk'] !== 'local') {
198            throw new HttpNotFound($this->request);
199        }
200
201        $asset = Asset::fromRow($row, $this->config);
202
203        if (dirname($asset->key) !== "{$shard}/{$uid}" || !$asset->resizable()) {
204            throw new HttpNotFound($this->request);
205        }
206
207        $spec = $this->sizeSpec($asset->key, $file);
208
209        try {
210            $image = $this
211                ->getAssets()
212                ->image($asset->key)
213                ->resize(
214                    $spec->size(),
215                    $spec->mode,
216                    $spec->enlarge,
217                    $spec->quality,
218                    $spec->name,
219                );
220        } catch (RuntimeException $e) {
221            throw new HttpNotFound($this->request, previous: $e);
222        }
223
224        $fileServer = $this->config->media->fileServer;
225
226        if ($fileServer) {
227            return $this->sendFile($fileServer, $image->path());
228        }
229
230        return Response::create($this->factory)->file($image->path());
231    }
232
233    /**
234     * Match a requested rendition basename against the asset's key and
235     * the configured sizes: `{stem}-{size}` with the key's extension.
236     */
237    protected function sizeSpec(string $key, string $file): SizeSpec
238    {
239        $base = basename($key);
240        $dot = strrpos($base, '.');
241        $stem = $dot === false || $dot === 0 ? $base : substr($base, 0, $dot);
242        $ext = $dot === false || $dot === 0 ? '' : substr($base, $dot);
243        $sizes = $this->config->media->sizes;
244
245        if (str_starts_with($file, "{$stem}-") && ($ext === '' || str_ends_with($file, $ext))) {
246            $name = substr($file, strlen($stem) + 1, strlen($file) - strlen($stem) - 1 - strlen($ext));
247
248            if ($name !== '' && $sizes->has($name)) {
249                return $sizes->get($name);
250            }
251        }
252
253        throw new HttpNotFound($this->request);
254    }
255
256    protected function sendFile(string $fileServer, string $file): Response
257    {
258        $response = Response::create($this->factory);
259        $response->header('Content-Type', mime_content_type($file) ?: 'application/octet-stream');
260
261        switch ($fileServer) {
262            case 'apache':
263                // apt install libapache2-mod-xsendfile
264                // a2enmod xsendfile
265                // Apache config:
266                //    XSendFile On
267                //    XSendFilePath "/path/to/files"
268                $response->header('X-Sendfile', $file);
269                break;
270            case 'nginx':
271                // Nginx config
272                //   location /path/to/files/ {
273                //       internal;
274                //           alias   /some/path/; # note the trailing slash
275                //       }
276                //   }
277
278                $response->header('X-Accel-Redirect', $file);
279                break;
280            default:
281                throw new RuntimeException(
282                    'File server not supported: `' . $fileServer . '`. Supported values `nginx`, `apache`.',
283                );
284        }
285
286        return $response;
287    }
288
289    protected function getAssets(): Assets
290    {
291        return $this->assets ??= new Assets($this->config);
292    }
293}