create-nevela 0.1.4 → 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.
- package/package.json +1 -1
- package/project.mjs +11 -0
- package/template/laravel/composer.json +61 -59
- package/template/laravel/config/nevela.php +70 -0
- package/template/laravel/database/migrations/2026_10_04_000000_create_nevela_uploads_table.php +38 -0
- package/template/laravel/src/Console/GenerateCommand.php +25 -2
- package/template/laravel/src/Console/MakeResourceCommand.php +8 -0
- package/template/laravel/src/Console/SeedCommand.php +186 -124
- package/template/laravel/src/Console/UpdateCommand.php +64 -3
- package/template/laravel/src/Generator/ResourceGenerator.php +151 -19
- package/template/laravel/src/Http/ResourceRequest.php +4 -1
- package/template/laravel/src/Http/UploadController.php +147 -0
- package/template/laravel/src/Media/FileTypes.php +97 -0
- package/template/laravel/src/Media/ImageOptimizer.php +252 -0
- package/template/laravel/src/Media/Profile.php +94 -0
- package/template/laravel/src/Media/UploadRejected.php +8 -0
- package/template/laravel/src/Media/Uploads.php +325 -0
- package/template/laravel/src/Models/Upload.php +46 -0
- package/template/laravel/src/Nevela.php +20 -1
- package/template/laravel/src/NevelaServiceProvider.php +24 -0
- package/template/laravel/src/Rules/UploadKey.php +27 -0
- package/template/laravel/src/Support/Fake.php +194 -171
- package/template/laravel/src/Support/Field.php +79 -10
- package/template/laravel/src/Support/TemplateSource.php +1 -1
- package/template/web/app/api/[...path]/route.ts +87 -0
- package/template/web/app/dashboard/actions.ts +26 -9
- package/template/web/components/dashboard/dashboard-header.tsx +24 -2
- package/template/web/components/dashboard/fields/file-cell.tsx +10 -47
- package/template/web/components/dashboard/fields/file-field.tsx +25 -31
- package/template/web/lib/files.ts +21 -0
- package/template/web/package.json +2 -2
- 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,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
|
+
}
|