create-nevela 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/project.mjs +11 -0
  3. package/template/laravel/composer.json +61 -59
  4. package/template/laravel/config/nevela.php +70 -0
  5. package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
  6. package/template/laravel/src/Console/GenerateCommand.php +25 -2
  7. package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
  8. package/template/laravel/src/Console/SeedCommand.php +186 -124
  9. package/template/laravel/src/Console/UpdateCommand.php +64 -3
  10. package/template/laravel/src/Generator/ResourceGenerator.php +151 -19
  11. package/template/laravel/src/Http/ResourceRequest.php +4 -1
  12. package/template/laravel/src/Http/UploadController.php +147 -0
  13. package/template/laravel/src/Media/FileTypes.php +97 -0
  14. package/template/laravel/src/Media/ImageOptimizer.php +252 -0
  15. package/template/laravel/src/Media/Profile.php +94 -0
  16. package/template/laravel/src/Media/UploadRejected.php +8 -0
  17. package/template/laravel/src/Media/Uploads.php +325 -0
  18. package/template/laravel/src/Models/Upload.php +46 -0
  19. package/template/laravel/src/Nevela.php +20 -1
  20. package/template/laravel/src/NevelaServiceProvider.php +24 -0
  21. package/template/laravel/src/Rules/UploadKey.php +27 -0
  22. package/template/laravel/src/Support/Fake.php +194 -171
  23. package/template/laravel/src/Support/Field.php +79 -10
  24. package/template/laravel/src/Support/TemplateSource.php +1 -1
  25. package/template/web/app/api/[...path]/route.ts +87 -0
  26. package/template/web/app/dashboard/actions.ts +26 -9
  27. package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
  28. package/template/web/components/dashboard/fields/file-field.tsx +25 -31
  29. package/template/web/lib/files.ts +21 -0
  30. package/template/web/package.json +1 -1
  31. package/template.mjs +1 -1
@@ -0,0 +1,252 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Media;
4
+
5
+ use GdImage;
6
+ use RuntimeException;
7
+
8
+ /**
9
+ * Turns an uploaded image into the files worth storing: one that fits the profile's box,
10
+ * and the profile's named renditions. Pure PHP and GD: no Laravel, no filesystem writes.
11
+ *
12
+ * What it does to every image, following Grit's pipeline:
13
+ *
14
+ * - Turns it the right way up. A phone stores a portrait photo sideways with a note saying
15
+ * so (EXIF orientation); the note is applied to the pixels, then dropped with the rest of
16
+ * the metadata, which also removes the location the photo was taken at.
17
+ * - Scales it down to fit, never up.
18
+ * - Picks the format from the pixels when the profile says "auto": transparency is kept
19
+ * (lossless WebP), and everything else becomes lossy WebP, a quarter smaller than a JPEG
20
+ * that looks the same. Encoding a transparent logo as JPEG, and giving it a black box,
21
+ * can't happen.
22
+ * - Refuses an image that would decode to more pixels than the profile allows, from its
23
+ * header, before any memory is spent on it.
24
+ */
25
+ final class ImageOptimizer
26
+ {
27
+ /** The types worth optimising. GIF is left alone: decoding one keeps only its first frame. */
28
+ public const OPTIMISABLE = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
29
+
30
+ public static function available(): bool
31
+ {
32
+ return function_exists('imagecreatefromstring') && function_exists('imagecopyresampled');
33
+ }
34
+
35
+ /** Whether this build of PHP can write a format. */
36
+ public static function writes(string $format): bool
37
+ {
38
+ return match ($format) {
39
+ 'webp' => function_exists('imagewebp'),
40
+ 'avif' => function_exists('imageavif'),
41
+ 'jpeg' => function_exists('imagejpeg'),
42
+ 'png' => function_exists('imagepng'),
43
+ default => false,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * @return array{
49
+ * primary: array{bytes: string, width: int, height: int, mime: string, ext: string},
50
+ * renditions: array<string, array{bytes: string, width: int, height: int, mime: string, ext: string}>,
51
+ * originalWidth: int,
52
+ * originalHeight: int,
53
+ * }
54
+ *
55
+ * @throws RuntimeException when the image can't be read, or is too large to decode safely
56
+ */
57
+ public static function transform(string $bytes, Profile $profile): array
58
+ {
59
+ if (! self::available()) {
60
+ throw new RuntimeException('PHP\'s GD extension is not installed, so images are stored as they are uploaded.');
61
+ }
62
+ $info = @getimagesizefromstring($bytes);
63
+ if ($info === false) {
64
+ throw new RuntimeException('The file is not an image PHP can read.');
65
+ }
66
+ [$width, $height] = $info;
67
+ if ($width < 1 || $height < 1 || $width * $height > $profile->maxPixels) {
68
+ throw new RuntimeException("The image is {$width}×{$height}, which is more than the ".number_format($profile->maxPixels).' pixels this field allows.');
69
+ }
70
+
71
+ $source = @imagecreatefromstring($bytes);
72
+ if (! $source instanceof GdImage) {
73
+ // An animated WebP lands here: GD reads still images only.
74
+ throw new RuntimeException('The image could not be decoded.');
75
+ }
76
+ if (! imageistruecolor($source)) {
77
+ imagepalettetotruecolor($source);
78
+ }
79
+ $source = self::upright($source, $bytes, $info['mime'] ?? '');
80
+ $width = imagesx($source);
81
+ $height = imagesy($source);
82
+
83
+ $transparent = ($info['mime'] ?? '') !== 'image/jpeg' && self::hasTransparency($source);
84
+ $format = self::format($profile->format, $transparent);
85
+
86
+ $primary = self::render($source, $profile->max, $format, $profile->quality, $transparent);
87
+
88
+ // A flat graphic (a diagram, a screenshot) is what PNG is good at: as a PNG it can be
89
+ // smaller than any lossy version of itself. So a PNG is also written as one, and the
90
+ // smaller result is kept, with its renditions in the same format so they sit beside
91
+ // it. It is written again, never copied: a PNG can carry text and location data in
92
+ // its metadata, and writing it from the pixels is what leaves that behind.
93
+ if (($info['mime'] ?? '') === 'image/png' && $profile->format === 'auto' && $format !== 'png' && self::writes('png')) {
94
+ $png = self::render($source, $profile->max, 'png', $profile->quality, $transparent);
95
+ if (strlen($png['bytes']) < strlen($primary['bytes'])) {
96
+ $format = 'png';
97
+ $primary = $png;
98
+ }
99
+ }
100
+
101
+ $result = [
102
+ 'primary' => $primary,
103
+ 'renditions' => [],
104
+ 'originalWidth' => $width,
105
+ 'originalHeight' => $height,
106
+ ];
107
+ foreach ($profile->renditions as $name => $size) {
108
+ $result['renditions'][$name] = self::render($source, $size, $format, $profile->quality, $transparent);
109
+ }
110
+
111
+ return $result;
112
+ }
113
+
114
+ /**
115
+ * What "auto" means for this image, and what to fall back to when PHP can't write the
116
+ * format that was asked for.
117
+ */
118
+ public static function format(string $wanted, bool $transparent): string
119
+ {
120
+ if ($wanted !== 'auto' && self::writes($wanted)) {
121
+ return $wanted;
122
+ }
123
+ if (self::writes('webp')) {
124
+ return 'webp';
125
+ }
126
+
127
+ return $transparent ? 'png' : 'jpeg';
128
+ }
129
+
130
+ /** Apply the EXIF orientation of a JPEG to its pixels. */
131
+ private static function upright(GdImage $image, string $bytes, string $mime): GdImage
132
+ {
133
+ if ($mime !== 'image/jpeg' || ! function_exists('exif_read_data')) {
134
+ return $image;
135
+ }
136
+ $exif = @exif_read_data('data://image/jpeg;base64,'.base64_encode($bytes));
137
+ $orientation = is_array($exif) ? (int) ($exif['Orientation'] ?? 1) : 1;
138
+
139
+ // imagerotate turns anticlockwise, so 90° clockwise is -90.
140
+ $turn = match ($orientation) {
141
+ 3, 4 => 180,
142
+ 5, 6 => -90,
143
+ 7, 8 => 90,
144
+ default => 0,
145
+ };
146
+ if ($turn !== 0) {
147
+ $rotated = imagerotate($image, $turn, 0);
148
+ if ($rotated instanceof GdImage) {
149
+ $image = $rotated;
150
+ }
151
+ }
152
+ if (in_array($orientation, [2, 4, 5, 7], true)) {
153
+ imageflip($image, IMG_FLIP_HORIZONTAL);
154
+ }
155
+
156
+ return $image;
157
+ }
158
+
159
+ /**
160
+ * Whether any part of the image is see-through enough to matter. Checked on a small
161
+ * copy: reading twelve million pixels one by one in PHP takes seconds, and averaging
162
+ * them down first finds every transparent area a person could see.
163
+ */
164
+ private static function hasTransparency(GdImage $image): bool
165
+ {
166
+ $side = 48;
167
+ $small = imagecreatetruecolor($side, $side);
168
+ imagealphablending($small, false);
169
+ imagesavealpha($small, true);
170
+ imagecopyresampled($small, $image, 0, 0, 0, 0, $side, $side, imagesx($image), imagesy($image));
171
+ for ($y = 0; $y < $side; $y++) {
172
+ for ($x = 0; $x < $side; $x++) {
173
+ // The top 7 bits are alpha: 0 is solid, 127 is fully transparent.
174
+ if (((imagecolorat($small, $x, $y) >> 24) & 0x7F) > 2) {
175
+ return true;
176
+ }
177
+ }
178
+ }
179
+
180
+ return false;
181
+ }
182
+
183
+ /**
184
+ * @param array{0: int, 1: int, 2: bool} $size
185
+ * @return array{bytes: string, width: int, height: int, mime: string, ext: string}
186
+ */
187
+ private static function render(GdImage $source, array $size, string $format, int $quality, bool $transparent): array
188
+ {
189
+ [$boxWidth, $boxHeight, $crop] = $size;
190
+ $width = imagesx($source);
191
+ $height = imagesy($source);
192
+ $fromX = $fromY = 0;
193
+ $fromWidth = $width;
194
+ $fromHeight = $height;
195
+
196
+ if ($crop) {
197
+ // Take the largest centred area with the box's shape, then scale that down.
198
+ $ratio = $boxWidth / $boxHeight;
199
+ if ($width / $height > $ratio) {
200
+ $fromWidth = max(1, (int) round($height * $ratio));
201
+ $fromX = intdiv($width - $fromWidth, 2);
202
+ } else {
203
+ $fromHeight = max(1, (int) round($width / $ratio));
204
+ $fromY = intdiv($height - $fromHeight, 2);
205
+ }
206
+ }
207
+ $scale = min(1, $boxWidth / $fromWidth, $boxHeight / $fromHeight);
208
+ $toWidth = max(1, (int) round($fromWidth * $scale));
209
+ $toHeight = max(1, (int) round($fromHeight * $scale));
210
+
211
+ $keepAlpha = $transparent && $format !== 'jpeg';
212
+ $target = imagecreatetruecolor($toWidth, $toHeight);
213
+ if ($keepAlpha) {
214
+ imagealphablending($target, false);
215
+ imagesavealpha($target, true);
216
+ imagefill($target, 0, 0, imagecolorallocatealpha($target, 0, 0, 0, 127));
217
+ } else {
218
+ // Anything see-through is put on white, not on the black a JPEG would give it.
219
+ imagefill($target, 0, 0, imagecolorallocate($target, 255, 255, 255));
220
+ }
221
+ imagecopyresampled($target, $source, 0, 0, $fromX, $fromY, $toWidth, $toHeight, $fromWidth, $fromHeight);
222
+
223
+ ob_start();
224
+ $written = match ($format) {
225
+ // Lossless when there is transparency to keep: a logo's edges don't survive lossy.
226
+ 'webp' => imagewebp($target, null, $keepAlpha && defined('IMG_WEBP_LOSSLESS') ? IMG_WEBP_LOSSLESS : $quality),
227
+ 'avif' => imageavif($target, null, $quality),
228
+ 'png' => imagepng($target, null, 9),
229
+ default => self::jpeg($target, $quality),
230
+ };
231
+ $bytes = (string) ob_get_clean();
232
+ if (! $written || $bytes === '') {
233
+ throw new RuntimeException("The image could not be written as {$format}.");
234
+ }
235
+
236
+ return [
237
+ 'bytes' => $bytes,
238
+ 'width' => $toWidth,
239
+ 'height' => $toHeight,
240
+ 'mime' => 'image/'.$format,
241
+ 'ext' => $format === 'jpeg' ? 'jpg' : $format,
242
+ ];
243
+ }
244
+
245
+ private static function jpeg(GdImage $image, int $quality): bool
246
+ {
247
+ // Progressive: the picture appears whole and sharpens, instead of arriving top to bottom.
248
+ imageinterlace($image, true);
249
+
250
+ return imagejpeg($image, null, $quality);
251
+ }
252
+ }
@@ -0,0 +1,94 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Media;
4
+
5
+ /**
6
+ * How one image field's uploads are optimised. The idea and the numbers are Grit's.
7
+ *
8
+ * Every default is one somebody would otherwise have to research:
9
+ *
10
+ * 1600 covers a 2x display at a typical content width. Storing more keeps pixels no
11
+ * browser will draw.
12
+ * 82 the quality at which a lossy image is indistinguishable from its source. Below
13
+ * about 75, gradients and skin start to show artefacts.
14
+ * 400 the thumbnail a table row or a grid of cards needs at 2x.
15
+ *
16
+ * Profiles are plain arrays in config/nevela.php, under uploads.profiles. A named profile
17
+ * only says what differs from the default.
18
+ */
19
+ final class Profile
20
+ {
21
+ public const FORMATS = ['auto', 'webp', 'jpeg', 'png', 'avif'];
22
+
23
+ public const DEFAULTS = [
24
+ 'max' => [1600, 1600],
25
+ 'quality' => 82,
26
+ 'format' => 'auto',
27
+ 'renditions' => ['thumb' => [400, 400, 'crop']],
28
+ 'keep_original' => true,
29
+ 'on_error' => 'store_original',
30
+ 'max_pixels' => 50_000_000,
31
+ ];
32
+
33
+ /**
34
+ * @param array{0: int, 1: int, 2: bool} $max The box the stored image fits inside: width, height, crop to fill
35
+ * @param int $quality 1 to 100, for lossy formats
36
+ * @param string $format One of FORMATS. "auto" keeps transparency and otherwise picks the smallest
37
+ * @param array<string, array{0: int, 1: int, 2: bool}> $renditions Extra sizes made alongside, by name
38
+ * @param bool $keepOriginal Whether the untouched upload is kept, privately, for reprocessing
39
+ * @param bool $reject Whether an image that can't be optimised is refused, rather than stored as it came
40
+ * @param int $maxPixels Refuse anything that would decode to more than this (a decompression bomb is small on disk)
41
+ */
42
+ public function __construct(
43
+ public readonly string $name,
44
+ public readonly array $max,
45
+ public readonly int $quality,
46
+ public readonly string $format,
47
+ public readonly array $renditions,
48
+ public readonly bool $keepOriginal,
49
+ public readonly bool $reject,
50
+ public readonly int $maxPixels,
51
+ ) {}
52
+
53
+ /**
54
+ * @param array<string, mixed> $options What this profile sets
55
+ * @param array<string, mixed> $defaults What it falls back to: the app's "default" profile
56
+ */
57
+ public static function fromArray(string $name, array $options, array $defaults = []): self
58
+ {
59
+ $o = $options + $defaults + self::DEFAULTS;
60
+ $renditions = [];
61
+ foreach ((array) $o['renditions'] as $key => $size) {
62
+ if (preg_match('/^[a-z][a-z0-9-]*$/', (string) $key)) {
63
+ $renditions[(string) $key] = self::size((array) $size);
64
+ }
65
+ }
66
+ $quality = (int) $o['quality'];
67
+
68
+ return new self(
69
+ $name,
70
+ self::size((array) $o['max']),
71
+ $quality >= 1 && $quality <= 100 ? $quality : self::DEFAULTS['quality'],
72
+ in_array($o['format'], self::FORMATS, true) ? $o['format'] : 'auto',
73
+ $renditions,
74
+ (bool) $o['keep_original'],
75
+ $o['on_error'] === 'reject',
76
+ max(1, (int) $o['max_pixels']),
77
+ );
78
+ }
79
+
80
+ /**
81
+ * [width, height] fits inside the box; a third value of "crop" (or true) fills it exactly.
82
+ *
83
+ * @param array<int, mixed> $size
84
+ * @return array{0: int, 1: int, 2: bool}
85
+ */
86
+ private static function size(array $size): array
87
+ {
88
+ $size = array_values($size);
89
+ $width = max(1, (int) ($size[0] ?? self::DEFAULTS['max'][0]));
90
+ $height = max(1, (int) ($size[1] ?? $width));
91
+
92
+ return [$width, $height, in_array($size[2] ?? false, ['crop', 'fill', true], true)];
93
+ }
94
+ }
@@ -0,0 +1,8 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Media;
4
+
5
+ use RuntimeException;
6
+
7
+ /** An upload the field's profile refuses, with a message fit to show the person who sent it. */
8
+ final class UploadRejected extends RuntimeException {}
@@ -0,0 +1,325 @@
1
+ <?php
2
+
3
+ namespace Nevela\Laravel\Media;
4
+
5
+ use Illuminate\Contracts\Filesystem\Filesystem;
6
+ use Illuminate\Support\Facades\Storage;
7
+ use Illuminate\Support\Str;
8
+ use Nevela\Laravel\Models\Upload;
9
+ use Nevela\Laravel\Support\Descriptor;
10
+ use Nevela\Laravel\Support\Field;
11
+ use RuntimeException;
12
+ use Throwable;
13
+
14
+ /**
15
+ * Stores uploaded files and answers questions about them.
16
+ *
17
+ * A record's file field holds a key such as
18
+ * `products/image/2026/10/<uuid>-kettle.webp`. An image's renditions sit beside it with
19
+ * their name before the extension (`…-kettle.thumb.webp`), so a client that knows the key
20
+ * can ask for a size without looking anything up. The untouched original is kept on a
21
+ * private disk, so the image can be optimised again later with different settings.
22
+ */
23
+ final class Uploads
24
+ {
25
+ /** @var array<string, Upload|null> Rows already looked up in this request, by key. */
26
+ private static array $loaded = [];
27
+
28
+ public static function disk(): string
29
+ {
30
+ return (string) config('nevela.uploads.disk', 'public');
31
+ }
32
+
33
+ public static function maxBytes(): int
34
+ {
35
+ return (int) config('nevela.uploads.max_bytes', 10 * 1024 * 1024);
36
+ }
37
+
38
+ /** The profile a field names, or the default when it names none or one that isn't defined. */
39
+ public static function profile(?string $name): Profile
40
+ {
41
+ $profiles = (array) config('nevela.uploads.profiles', []);
42
+ $default = (array) ($profiles['default'] ?? []);
43
+ if ($name === null || $name === 'default' || ! isset($profiles[$name])) {
44
+ return Profile::fromArray('default', $default);
45
+ }
46
+
47
+ return Profile::fromArray($name, (array) $profiles[$name], $default);
48
+ }
49
+
50
+ /**
51
+ * Store one upload, optimising it first when it is an image for an image field.
52
+ *
53
+ * @param string $name The file's name as the person had it
54
+ * @param string $path Where the uploaded bytes are on this machine
55
+ * @param string $mime What the file is, from its contents
56
+ *
57
+ * @throws UploadRejected when the field's profile refuses images it can't optimise
58
+ */
59
+ public static function store(Descriptor $resource, Field $field, string $name, string $path, string $mime, ?string $uploadedBy = null): Upload
60
+ {
61
+ $disk = Storage::disk(self::disk());
62
+ $extension = FileTypes::safeExtension($name);
63
+ $slug = Str::limit(Str::slug(pathinfo($name, PATHINFO_FILENAME)) ?: 'file', 60, '');
64
+ $base = sprintf('%s/%s/%s/%s-%s', $resource->table, $field->name, date('Y/m'), (string) Str::uuid(), $slug);
65
+ $bytes = (string) file_get_contents($path);
66
+
67
+ $row = [
68
+ 'disk' => self::disk(),
69
+ 'resource' => $resource->name,
70
+ 'field' => $field->name,
71
+ 'name' => Str::limit($name, 200, ''),
72
+ 'uploaded_by' => $uploadedBy,
73
+ ];
74
+ $profile = self::profile($field->profile);
75
+
76
+ if ($field->isImage() && in_array($mime, ImageOptimizer::OPTIMISABLE, true)) {
77
+ $written = [];
78
+ $originals = Storage::disk((string) config('nevela.uploads.originals_disk', 'local'));
79
+ $originalKey = null;
80
+ try {
81
+ self::makeRoom();
82
+ $result = ImageOptimizer::transform($bytes, $profile);
83
+ $primary = $result['primary'];
84
+ $key = "{$base}.{$primary['ext']}";
85
+ self::write($disk, $written[] = $key, $primary['bytes'], 'public');
86
+
87
+ $renditions = [];
88
+ foreach ($result['renditions'] as $rendition => $image) {
89
+ $renditionKey = "{$base}.{$rendition}.{$image['ext']}";
90
+ self::write($disk, $written[] = $renditionKey, $image['bytes'], 'public');
91
+ $renditions[$rendition] = ['key' => $renditionKey, 'width' => $image['width'], 'height' => $image['height'], 'size' => strlen($image['bytes'])];
92
+ }
93
+
94
+ if ($profile->keepOriginal) {
95
+ $originalKey = "nevela/originals/{$base}.{$extension}";
96
+ self::write($originals, $originalKey, $bytes);
97
+ }
98
+
99
+ return self::remember(Upload::create($row + [
100
+ 'key' => $key,
101
+ 'mime' => $primary['mime'],
102
+ 'size' => strlen($primary['bytes']),
103
+ 'width' => $primary['width'],
104
+ 'height' => $primary['height'],
105
+ 'renditions' => $renditions,
106
+ 'profile' => $profile->name,
107
+ 'optimised' => true,
108
+ 'original_key' => $originalKey,
109
+ 'original_size' => strlen($bytes),
110
+ ]));
111
+ } catch (Throwable $e) {
112
+ // Whatever was written before it failed is taken away again: no half-made sets.
113
+ $disk->delete($written);
114
+ if ($originalKey !== null) {
115
+ $originals->delete($originalKey);
116
+ }
117
+ if ($profile->reject) {
118
+ throw new UploadRejected($e->getMessage(), previous: $e);
119
+ }
120
+ // Losing somebody's file because an encoder choked is worse than storing a
121
+ // large one. It is kept as it came and marked, so it can be found later.
122
+ report($e);
123
+ }
124
+ }
125
+
126
+ $key = "{$base}.{$extension}";
127
+ self::write($disk, $key, $bytes, 'public');
128
+ $size = str_starts_with($mime, 'image/') ? @getimagesizefromstring($bytes) : false;
129
+
130
+ return self::remember(Upload::create($row + [
131
+ 'key' => $key,
132
+ 'mime' => $mime,
133
+ 'size' => strlen($bytes),
134
+ 'width' => $size ? $size[0] : null,
135
+ 'height' => $size ? $size[1] : null,
136
+ 'profile' => $field->isImage() ? $profile->name : null,
137
+ 'optimised' => false,
138
+ ]));
139
+ }
140
+
141
+ /** The address a browser can fetch a stored file from. */
142
+ public static function url(string $key): string
143
+ {
144
+ $base = config('nevela.uploads.url');
145
+ if (is_string($base) && $base !== '') {
146
+ return rtrim($base, '/').'/'.ltrim($key, '/');
147
+ }
148
+
149
+ return route('nevela.files.show', ['path' => $key]);
150
+ }
151
+
152
+ /**
153
+ * Look up many keys in one query, so a page of records with images costs one extra
154
+ * query and not one per record.
155
+ *
156
+ * @param iterable<string|null> $keys
157
+ */
158
+ public static function preload(iterable $keys): void
159
+ {
160
+ $missing = [];
161
+ foreach ($keys as $key) {
162
+ if (is_string($key) && $key !== '' && ! array_key_exists($key, self::$loaded)) {
163
+ $missing[$key] = true;
164
+ }
165
+ }
166
+ if ($missing === []) {
167
+ return;
168
+ }
169
+ foreach (array_keys($missing) as $key) {
170
+ self::$loaded[$key] = null;
171
+ }
172
+ foreach (Upload::query()->whereIn('key', array_keys($missing))->get() as $upload) {
173
+ self::$loaded[$upload->key] = $upload;
174
+ }
175
+ }
176
+
177
+ public static function find(string $key): ?Upload
178
+ {
179
+ self::preload([$key]);
180
+
181
+ return self::$loaded[$key];
182
+ }
183
+
184
+ /**
185
+ * Everything a client needs to show a file well: its address, its dimensions (so the
186
+ * page doesn't jump when the image arrives) and each rendition's.
187
+ *
188
+ * @return array<string, mixed>|null
189
+ */
190
+ public static function ref(?string $key): ?array
191
+ {
192
+ if ($key === null || $key === '') {
193
+ return null;
194
+ }
195
+ $upload = self::find($key);
196
+ if ($upload === null) {
197
+ return ['key' => $key, 'url' => self::url($key)];
198
+ }
199
+ $renditions = [];
200
+ foreach ($upload->renditions ?? [] as $name => $rendition) {
201
+ $renditions[$name] = [
202
+ 'url' => self::url($rendition['key']),
203
+ 'width' => $rendition['width'],
204
+ 'height' => $rendition['height'],
205
+ 'size' => $rendition['size'],
206
+ ];
207
+ }
208
+
209
+ return [
210
+ 'key' => $upload->key,
211
+ 'url' => self::url($upload->key),
212
+ 'name' => $upload->name,
213
+ 'mime' => $upload->mime,
214
+ 'size' => $upload->size,
215
+ 'width' => $upload->width,
216
+ 'height' => $upload->height,
217
+ 'optimised' => $upload->optimised,
218
+ 'renditions' => (object) $renditions,
219
+ ];
220
+ }
221
+
222
+ public static function forget(): void
223
+ {
224
+ self::$loaded = [];
225
+ }
226
+
227
+ /**
228
+ * A few pictures to seed an image field with, made here so a seeded shop has something
229
+ * to look at without downloading anything. They go through the same optimiser as a
230
+ * real upload, so their renditions exist too.
231
+ *
232
+ * @return list<string> keys
233
+ */
234
+ public static function placeholders(Descriptor $resource, Field $field, int $count = 6): array
235
+ {
236
+ if (! ImageOptimizer::available() || ! function_exists('imagepng')) {
237
+ return [];
238
+ }
239
+ $palette = [[111, 0, 255], [0, 168, 150], [240, 113, 103], [38, 70, 83], [233, 196, 106], [72, 149, 239], [155, 93, 229], [42, 157, 143]];
240
+ $keys = [];
241
+ for ($i = 0; $i < $count; $i++) {
242
+ [$r, $g, $b] = $palette[$i % count($palette)];
243
+ $image = imagecreatetruecolor(800, 800);
244
+ // A soft top-to-bottom gradient with a lighter disc: enough to tell cards apart.
245
+ for ($y = 0; $y < 800; $y++) {
246
+ $shade = 1 - $y / 1600;
247
+ imageline($image, 0, $y, 799, $y, imagecolorallocate($image, (int) ($r * $shade), (int) ($g * $shade), (int) ($b * $shade)));
248
+ }
249
+ $light = imagecolorallocate($image, min(255, $r + 90), min(255, $g + 90), min(255, $b + 90));
250
+ imagefilledellipse($image, 400, 400, 360, 360, $light);
251
+ imagefilledellipse($image, 400, 400, 200, 200, imagecolorallocate($image, 255, 255, 255));
252
+
253
+ $path = self::scratchFile();
254
+ imagepng($image, $path);
255
+ try {
256
+ $keys[] = self::store($resource, $field, strtolower("{$resource->name}-".($i + 1).'.png'), $path, 'image/png')->key;
257
+ } finally {
258
+ @unlink($path);
259
+ }
260
+ }
261
+
262
+ return $keys;
263
+ }
264
+
265
+ /**
266
+ * A path for a file that lives for one request. In the app's own storage, not the system's
267
+ * temporary folder: a web server's PHP often can't write there, and tempnam() then picks
268
+ * somewhere else and says so with a notice Laravel treats as an error.
269
+ */
270
+ public static function scratchFile(): string
271
+ {
272
+ $dir = storage_path('framework/cache/nevela');
273
+ if (! is_dir($dir)) {
274
+ @mkdir($dir, 0775, true);
275
+ }
276
+
277
+ return $dir.DIRECTORY_SEPARATOR.Str::uuid().'.tmp';
278
+ }
279
+
280
+ /**
281
+ * Write a file, and fail loudly if it wasn't written. Laravel's disks answer false by
282
+ * default instead of throwing, and a record must never point at a file that isn't there.
283
+ *
284
+ * @throws RuntimeException
285
+ */
286
+ private static function write(Filesystem $disk, string $key, string $bytes, ?string $visibility = null): void
287
+ {
288
+ $stored = $visibility === null ? $disk->put($key, $bytes) : $disk->put($key, $bytes, $visibility);
289
+ if ($stored === false) {
290
+ throw new RuntimeException("The file could not be written to storage ({$key}). Check the disk in config/nevela.php and that it is writable.");
291
+ }
292
+ }
293
+
294
+ private static function remember(Upload $upload): Upload
295
+ {
296
+ return self::$loaded[$upload->key] = $upload;
297
+ }
298
+
299
+ /**
300
+ * Decoding a photo needs about five bytes per pixel, which a 12-megapixel phone picture
301
+ * takes past PHP's usual 128 MB. Raised for this request only.
302
+ */
303
+ private static function makeRoom(): void
304
+ {
305
+ $wanted = (string) config('nevela.uploads.memory', '512M');
306
+ $bytes = fn (string $value): int => (int) $value * match (strtoupper(substr(trim($value), -1))) {
307
+ 'G' => 1024 ** 3,
308
+ 'M' => 1024 ** 2,
309
+ 'K' => 1024,
310
+ default => 1,
311
+ };
312
+ $current = (string) ini_get('memory_limit');
313
+ if ($current !== '-1' && $bytes($current) < $bytes($wanted)) {
314
+ @ini_set('memory_limit', $wanted);
315
+ }
316
+ }
317
+
318
+ /** @throws RuntimeException */
319
+ public static function assertStorable(): void
320
+ {
321
+ if (! array_key_exists(self::disk(), (array) config('filesystems.disks', []))) {
322
+ throw new RuntimeException('The disk "'.self::disk().'" in config/nevela.php (uploads.disk) is not defined in config/filesystems.php.');
323
+ }
324
+ }
325
+ }