Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.22% covered (success)
92.22%
83 / 90
80.00% covered (warning)
80.00%
8 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Library
92.22% covered (success)
92.22%
83 / 90
80.00% covered (warning)
80.00%
8 / 10
36.61
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
 page
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 list
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 match
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 item
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 delete
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
6.56
 filterKinds
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 since
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 rangeSince
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
5
 purgeRenditions
76.92% covered (warning)
76.92%
10 / 13
0.00% covered (danger)
0.00%
0 / 1
5.31
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Assets;
6
7use Celema\Quma\Database;
8use Cosray\Config;
9use Cosray\Exception\RuntimeException;
10use Cosray\References\Usage;
11use Cosray\Storage\Storage;
12use Cosray\Util\Path;
13use DateTimeImmutable;
14use PDOException;
15use RecursiveDirectoryIterator;
16use RecursiveIteratorIterator;
17
18/**
19 * The asset catalog as the panel browses and prunes it: paged listings
20 * filtered by kind, filename and upload date, and the deletion of assets
21 * nothing references. The media screen and the library picker share it.
22 */
23final class Library
24{
25    /** The filter vocabulary, which splits the catalog kind `file` in two. */
26    public const array KINDS = ['image', 'video', 'audio', 'document'];
27
28    /** Upload date ranges the media screen offers, as URL tokens. */
29    public const array RANGES = ['7d', '30d', 'year'];
30
31    public const int LIMIT = 60;
32
33    public function __construct(
34        private readonly Database $db,
35        private readonly Config $config,
36    ) {}
37
38    /**
39     * One page of the catalog, newest first. `$kinds` takes filter kinds;
40     * none, or all four, list everything. `$q` matches the filename, `$since`
41     * cuts on the created timestamp. The counts report per-kind totals that
42     * honor `$q` and `$since` but not `$kinds`, so a filter can show what
43     * selecting each kind would yield.
44     *
45     * @param list<string> $kinds
46     */
47    public function page(
48        array $kinds = [],
49        string $q = '',
50        ?string $since = null,
51        int $page = 1,
52    ): LibraryPage {
53        $page = max(1, $page);
54        $offset = ($page - 1) * self::LIMIT;
55        $list = $this->list($kinds, $q, $since, self::LIMIT, $offset);
56        $counts = array_fill_keys(self::KINDS, 0);
57
58        foreach ($this->db->assets->counts($this->match('', $q, $since))->all() as $row) {
59            $counts[(string) $row['kind']] = (int) $row['total'];
60        }
61
62        return new LibraryPage(
63            assets: $list['assets'],
64            page: $page,
65            more: ($offset + count($list['assets'])) < $list['total'],
66            total: $list['total'],
67            counts: $counts,
68        );
69    }
70
71    /**
72     * A window of the catalog, newest first, with the number of matches.
73     *
74     * @param list<string> $kinds
75     * @return array{assets: list<Asset>, total: int}
76     */
77    public function list(array $kinds, string $q, ?string $since, int $limit, int $offset): array
78    {
79        $kinds = self::filterKinds($kinds);
80        $args = [
81            ...$this->match($kinds === [] ? '' : (string) json_encode($kinds), $q, $since),
82            'limit' => $limit,
83            'offset' => $offset,
84        ];
85        $rows = $this->db->assets->list($args)->all();
86
87        return [
88            'assets' => array_map(fn(array $row): Asset => Asset::fromRow($row, $this->config), $rows),
89            // 0 when paging past the end: the window count needs a row to ride on.
90            'total' => $rows === [] ? 0 : (int) $rows[0]['total'],
91        ];
92    }
93
94    /** @return array<string, string> */
95    private function match(string $kinds, string $q, ?string $since): array
96    {
97        $args = [];
98
99        if ($kinds !== '') {
100            $args['kinds'] = $kinds;
101        }
102
103        $q = trim($q);
104
105        if ($q !== '') {
106            $args['q'] = '%' . addcslashes($q, '%_\\') . '%';
107        }
108
109        if ($since !== null) {
110            $args['since'] = $since;
111        }
112
113        return $args;
114    }
115
116    /**
117     * An asset as a picker hands it to the browser: what a control needs to
118     * show and store it.
119     *
120     * @return array{uid: string, filename: string, url: string, thumbUrl: string, previewUrl: string, kind: string, mime: ?string, bytes: ?int, width: ?int, height: ?int}
121     */
122    public static function item(Asset $asset): array
123    {
124        return [
125            'uid' => $asset->uid,
126            'filename' => $asset->filename,
127            'url' => $asset->path(),
128            'thumbUrl' => $asset->resizable() ? $asset->sizePath('thumb') : $asset->path(),
129            'previewUrl' => $asset->resizable() ? $asset->sizePath('preview') : $asset->path(),
130            'kind' => $asset->kind,
131            'mime' => $asset->mime,
132            'bytes' => $asset->bytes,
133            'width' => $asset->width,
134            'height' => $asset->height,
135        ];
136    }
137
138    /**
139     * Hard delete, unreferenced only. Returns null for an unknown asset, the
140     * owners that still reference it when it is in use, and an empty list
141     * once it is gone. The RESTRICT foreign key on `asset_references` is the
142     * backstop against references appearing mid-request. The catalog row
143     * goes first: a leftover file is a harmless orphan, a dangling row is not.
144     *
145     * @return list<array{ownerType: string, ownerUid: string, title: string, nodeType: ?string, published: ?bool}>|null
146     */
147    public function delete(string $uid): ?array
148    {
149        $row = $this->db->assets->byUid(['uid' => $uid])->first();
150
151        if (!$row) {
152            return null;
153        }
154
155        $usage = new Usage($this->db);
156        $owners = $usage->forAsset($uid);
157
158        if ($owners !== []) {
159            return $owners;
160        }
161
162        try {
163            $this->db->assets->delete(['uid' => $uid])->run();
164        } catch (PDOException $e) {
165            // RESTRICT violations report SQLSTATE 23001; plain FK
166            // violations 23503.
167            if (in_array((string) $e->getCode(), ['23001', '23503'], true)) {
168                return $usage->forAsset($uid);
169            }
170
171            throw $e;
172        }
173
174        if ($row['disk'] === 'local') {
175            new Storage($this->config)->deleteDirectory(dirname((string) $row['key']));
176            $this->purgeRenditions((string) $row['key']);
177        }
178
179        return [];
180    }
181
182    /**
183     * The valid filter kinds among the given ones, from a list or a
184     * comma-separated string. All four mean everything, so they come back
185     * as none, which keeps the query plan flat.
186     *
187     * @param string|list<string> $kinds
188     * @return list<string>
189     */
190    public static function filterKinds(string|array $kinds): array
191    {
192        $kinds = is_string($kinds) ? explode(',', $kinds) : $kinds;
193        $requested = array_values(array_intersect(self::KINDS, array_map(trim(...), $kinds)));
194
195        return count($requested) === count(self::KINDS) ? [] : $requested;
196    }
197
198    /** A created-timestamp cutoff, normalized; invalid input means none. */
199    public static function since(mixed $value): ?string
200    {
201        if (!is_string($value) || trim($value) === '') {
202            return null;
203        }
204
205        $time = strtotime($value);
206
207        return $time === false ? null : date(DATE_ATOM, $time);
208    }
209
210    /**
211     * The cutoff an upload date range stands for right now. A link keeps the
212     * token, not the timestamp, so a shared "last 7 days" stays that.
213     */
214    public static function rangeSince(string $range, ?DateTimeImmutable $now = null): ?string
215    {
216        $now ??= new DateTimeImmutable();
217
218        $since = match ($range) {
219            '7d' => $now->modify('-7 days'),
220            '30d' => $now->modify('-30 days'),
221            'year' => $now->setDate((int) $now->format('Y'), 1, 1)->setTime(0, 0),
222            default => null,
223        };
224
225        return $since?->format(DATE_ATOM);
226    }
227
228    /** Removes the rendition cache directory `{cache}/{shard}/{uid}/`. */
229    private function purgeRenditions(string $key): void
230    {
231        $root = rtrim($this->config->path->public, '\\/') . '/' . trim($this->config->path->cache, '/');
232
233        try {
234            $dir = Path::inside($root, dirname($key));
235        } catch (RuntimeException) {
236            return;
237        }
238
239        if (!is_dir($dir)) {
240            return;
241        }
242
243        $files = new RecursiveIteratorIterator(
244            new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
245            RecursiveIteratorIterator::CHILD_FIRST,
246        );
247
248        foreach ($files as $file) {
249            $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
250        }
251
252        rmdir($dir);
253    }
254}