refactor: centralize media size limits in config + drop Document type
Three things in one move:
1. Centralize per-type size caps in config/trypost.php under media.max_size_mb.
The MediaType enum now reads from there:
MediaType::Image->maxSizeInMb() // 10 (env: MEDIA_IMAGE_MAX_SIZE_MB)
MediaType::Video->maxSizeInMb() // 1024 (env: MEDIA_VIDEO_MAX_SIZE_MB)
Plus convenience helpers maxSizeInBytes() and maxSizeInKb() so callers
don't have to multiply themselves. StoreAssetRequest now uses
MediaType::Video->maxSizeInKb() in its 'max:' rule and mimes derived
from MediaType::{Image,Video}->allowedMimeTypes(). storeChunked
validation moved to the new StoreChunkedAssetRequest FormRequest
(also reads from the enum). MediaAttacher uses
MediaType::Video->maxSizeInBytes() as the streaming-abort threshold
and enforces the per-type cap after MIME resolution.
2. Drop MediaType::Document. We never accepted PDFs anywhere — the
StoreAssetRequest mimes list excluded them, the storeChunked
extension regex excluded them, MediaAttacher excluded them. The only
places that referenced Document were:
- Platform::allowedMediaTypes for LinkedIn/LinkedInPage (declared
but unreachable)
- HasMedia::getMediaType fallback when MIME wasn't image/video/*
Both now cleaned up. HasMedia::getMediaType throws
InvalidArgumentException for unsupported MIMEs instead of silently
returning a fake 'document' type. Platform::LinkedIn now matches
every other social platform: [Image, Video].
3. Add MediaType::fromMime($mime): ?self — replaces the inline mime →
type loop that MediaAttacher used to roll. Returns null for
unsupported MIMEs (caller decides how to react).
Tests:
- MediaTypeTest rewritten for the new shape (no Document, config-driven
sizes, fromMime + size-helper coverage).
- PlatformTest no longer asserts Document on LinkedIn.
- HasMediaTest replaces the 'detects document type' case with one that
asserts the throw on unsupported MIMEs. The 'add media from path'
test now uses real PNG bytes from the fixture.
- AssetControllerTest chunked tests use real PNG bytes and assert 422
(FormRequest unprocessable) for malformed Content-Range headers,
matching the new validation layer.
This commit is contained in:
parent
ac361349da
commit
770266d36b
12 changed files with 194 additions and 88 deletions
|
|
@ -8,32 +8,53 @@ enum Type: string
|
|||
{
|
||||
case Image = 'image';
|
||||
case Video = 'video';
|
||||
case Document = 'document';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Image => 'Imagem',
|
||||
self::Video => 'Vídeo',
|
||||
self::Document => 'Documento',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function allowedMimeTypes(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::Image => ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
|
||||
self::Video => ['video/mp4', 'video/quicktime', 'video/webm'],
|
||||
self::Document => ['application/pdf'],
|
||||
};
|
||||
}
|
||||
|
||||
public function maxSizeInMb(): int
|
||||
{
|
||||
return match ($this) {
|
||||
self::Image => 10,
|
||||
self::Video => 2048,
|
||||
self::Document => 100,
|
||||
};
|
||||
return (int) config("trypost.media.max_size_mb.{$this->value}");
|
||||
}
|
||||
|
||||
public function maxSizeInBytes(): int
|
||||
{
|
||||
return $this->maxSizeInMb() * 1024 * 1024;
|
||||
}
|
||||
|
||||
public function maxSizeInKb(): int
|
||||
{
|
||||
return $this->maxSizeInMb() * 1024;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a Type from a MIME string. Returns null when the MIME is
|
||||
* not in any type's allow-list.
|
||||
*/
|
||||
public static function fromMime(string $mime): ?self
|
||||
{
|
||||
foreach (self::cases() as $type) {
|
||||
if (in_array($mime, $type->allowedMimeTypes(), true)) {
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ public function color(): string
|
|||
public function allowedMediaTypes(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::LinkedIn, self::LinkedInPage => [MediaType::Image, MediaType::Video, MediaType::Document],
|
||||
self::LinkedIn, self::LinkedInPage => [MediaType::Image, MediaType::Video],
|
||||
self::X => [MediaType::Image, MediaType::Video],
|
||||
self::TikTok => [MediaType::Video],
|
||||
self::YouTube => [MediaType::Video],
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use App\Http\Requests\App\Asset\StoreAssetFromUrlRequest;
|
||||
use App\Http\Requests\App\Asset\StoreAssetRequest;
|
||||
use App\Http\Requests\App\Asset\StoreChunkedAssetRequest;
|
||||
use App\Http\Resources\App\MediaResource;
|
||||
use App\Models\Media;
|
||||
use App\Services\UnsplashService;
|
||||
|
|
@ -66,33 +67,17 @@ public function store(StoreAssetRequest $request): MediaResource
|
|||
return new MediaResource($media);
|
||||
}
|
||||
|
||||
public function storeChunked(Request $request): JsonResponse
|
||||
public function storeChunked(StoreChunkedAssetRequest $request): JsonResponse
|
||||
{
|
||||
$workspace = $request->user()->currentWorkspace;
|
||||
|
||||
$this->authorize('createPost', $workspace);
|
||||
|
||||
$contentRange = $request->header('Content-Range');
|
||||
$rangeStart = (int) $request->validated('range_start');
|
||||
$rangeEnd = (int) $request->validated('range_end');
|
||||
$totalSize = (int) $request->validated('total_size');
|
||||
$fileName = (string) $request->validated('file_name');
|
||||
|
||||
preg_match('/bytes (\d+)-(\d+)\/(\d+)/', $contentRange, $matches);
|
||||
|
||||
if (empty($matches)) {
|
||||
abort(SymfonyResponse::HTTP_BAD_REQUEST, 'Invalid Content-Range header');
|
||||
}
|
||||
|
||||
$rangeStart = (int) $matches[1];
|
||||
$rangeEnd = (int) $matches[2];
|
||||
$totalSize = (int) $matches[3];
|
||||
|
||||
$fileName = $request->header('X-File-Name', 'upload');
|
||||
|
||||
if (! preg_match('/\.(jpe?g|png|gif|webp|mp4)$/i', $fileName)) {
|
||||
abort(SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'File type not supported.');
|
||||
}
|
||||
|
||||
if ($totalSize > 1073741824) { // 1GB
|
||||
abort(SymfonyResponse::HTTP_UNPROCESSABLE_ENTITY, 'File size exceeds the maximum allowed (1GB).');
|
||||
}
|
||||
$identifier = md5($request->user()->id.$fileName.$totalSize);
|
||||
$tempFile = storage_path("app/private/chunks/{$identifier}");
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Http\Requests\App\Asset;
|
||||
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreAssetRequest extends FormRequest
|
||||
|
|
@ -15,8 +16,17 @@ public function authorize(): bool
|
|||
|
||||
public function rules(): array
|
||||
{
|
||||
$allowedMimes = [...MediaType::Image->allowedMimeTypes(), ...MediaType::Video->allowedMimeTypes()];
|
||||
|
||||
return [
|
||||
'media' => ['required', 'file', 'max:1048576', 'mimetypes:image/jpeg,image/png,image/gif,image/webp,video/mp4'],
|
||||
// Use the largest per-type cap as the upper bound; per-type
|
||||
// enforcement happens after the upload via the Media model.
|
||||
'media' => [
|
||||
'required',
|
||||
'file',
|
||||
'max:'.MediaType::Video->maxSizeInKb(),
|
||||
'mimetypes:'.implode(',', $allowedMimes),
|
||||
],
|
||||
'meta' => ['sometimes', 'array'],
|
||||
'meta.width' => ['sometimes', 'integer', 'min:1'],
|
||||
'meta.height' => ['sometimes', 'integer', 'min:1'],
|
||||
|
|
|
|||
65
app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php
Normal file
65
app/Http/Requests/App/Asset/StoreChunkedAssetRequest.php
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\App\Asset;
|
||||
|
||||
use App\Enums\Media\Type as MediaType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Validates a chunked upload request. The chunk metadata (offset / total
|
||||
* size / filename) is encoded in the `Content-Range` and `X-File-Name`
|
||||
* headers, not in the body, so we lift it into the request bag via
|
||||
* `prepareForValidation` and then run standard rules against it.
|
||||
*/
|
||||
class StoreChunkedAssetRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
preg_match(
|
||||
'/bytes (\d+)-(\d+)\/(\d+)/',
|
||||
(string) $this->header('Content-Range'),
|
||||
$matches,
|
||||
);
|
||||
|
||||
$this->merge([
|
||||
'range_start' => isset($matches[1]) ? (int) $matches[1] : null,
|
||||
'range_end' => isset($matches[2]) ? (int) $matches[2] : null,
|
||||
'total_size' => isset($matches[3]) ? (int) $matches[3] : null,
|
||||
'file_name' => $this->header('X-File-Name', 'upload'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'range_start' => ['required', 'integer', 'min:0'],
|
||||
'range_end' => ['required', 'integer', 'gte:range_start'],
|
||||
'total_size' => ['required', 'integer', 'min:1', 'max:'.MediaType::Video->maxSizeInBytes()],
|
||||
'file_name' => ['required', 'string', 'regex:/\.(jpe?g|png|gif|webp|mp4)$/i'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'range_start.required' => 'Invalid Content-Range header',
|
||||
'range_end.required' => 'Invalid Content-Range header',
|
||||
'total_size.required' => 'Invalid Content-Range header',
|
||||
'total_size.max' => 'File size exceeds the maximum allowed ('.MediaType::Video->maxSizeInMb().' MB).',
|
||||
'file_name.regex' => 'File type not supported.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -164,7 +164,7 @@ private function getMediaType(string $mimeType): string
|
|||
return 'video';
|
||||
}
|
||||
|
||||
return 'document';
|
||||
throw new \InvalidArgumentException("Unsupported media MIME type: {$mimeType}");
|
||||
}
|
||||
|
||||
private function getMediaMeta(UploadedFile $file, string $type): array
|
||||
|
|
|
|||
|
|
@ -22,27 +22,23 @@
|
|||
*
|
||||
* Flow per URL:
|
||||
* 1. Stream the body to a temp file via Http::sink + a progress
|
||||
* callback that aborts mid-download once MAX_BYTES is exceeded.
|
||||
* 2. Validate the Content-Type against the MediaType enum's allow-list
|
||||
* AND the intersection of allowed media types across the post's
|
||||
* enabled platforms.
|
||||
* 3. Hand off to `Workspace::addMediaFromPath()` so storage path,
|
||||
* callback that aborts once we exceed the largest configured
|
||||
* per-type cap (video = 1 GB by default, see config/trypost.php).
|
||||
* 2. Resolve the MediaType from the response's Content-Type via
|
||||
* `MediaType::fromMime()`. Reject if the type isn't accepted by
|
||||
* the intersection of platforms enabled on the post.
|
||||
* 3. Enforce the per-type cap (`MediaType::Image->maxSizeInBytes()`
|
||||
* vs `Video`) — a 100 MB jpeg is rejected even though we
|
||||
* streamed up to the video cap.
|
||||
* 4. Hand off to `Workspace::addMediaFromPath()` so storage path,
|
||||
* MIME re-detection, image normalization, and the Media row stay
|
||||
* in one place (same path as the web upload flow).
|
||||
* 4. Append the resulting media item to the post's `media[]` JSON
|
||||
* 5. Append the resulting media item to the post's `media[]` JSON
|
||||
* column under a row lock so concurrent attach calls don't clobber
|
||||
* each other.
|
||||
*/
|
||||
class MediaAttacher
|
||||
{
|
||||
/**
|
||||
* Cap on URL-fetched payloads. Smaller than the web upload cap (1 GB)
|
||||
* because URL fetches have different operational constraints:
|
||||
* bandwidth, timeout, and unbounded user input. 50 MB covers a long
|
||||
* photo or a short video; bigger files should be uploaded directly.
|
||||
*/
|
||||
private const MAX_BYTES = 50 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* @param array<int, string> $urls
|
||||
* @return array{attached: array<int, array<string, mixed>>, failed: array<int, string>}
|
||||
|
|
@ -79,6 +75,11 @@ public function attachFromUrls(Post $post, array $urls): array
|
|||
*/
|
||||
private function processOne(Workspace $workspace, string $url, array $allowedTypes): ?array
|
||||
{
|
||||
// Use the largest configured per-type cap as the streaming-abort
|
||||
// threshold; the actual per-type limit is checked below once we
|
||||
// know the MIME.
|
||||
$streamCap = MediaType::Video->maxSizeInBytes();
|
||||
|
||||
$temp = tempnam(sys_get_temp_dir(), 'media_');
|
||||
|
||||
try {
|
||||
|
|
@ -86,25 +87,34 @@ private function processOne(Workspace $workspace, string $url, array $allowedTyp
|
|||
->sink($temp)
|
||||
->withOptions([
|
||||
'allow_redirects' => false,
|
||||
'progress' => static function ($total, $downloaded): void {
|
||||
if ($downloaded > self::MAX_BYTES) {
|
||||
'progress' => static function ($total, $downloaded) use ($streamCap): void {
|
||||
if ($downloaded > $streamCap) {
|
||||
throw new RuntimeException('exceeded max bytes');
|
||||
}
|
||||
},
|
||||
])
|
||||
->get($url);
|
||||
|
||||
if (! $response->successful() || filesize($temp) === 0) {
|
||||
$bytes = filesize($temp) ?: 0;
|
||||
|
||||
if (! $response->successful() || $bytes === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mime = trim(explode(';', (string) $response->header('Content-Type'))[0]);
|
||||
$type = $this->resolveType($mime);
|
||||
$type = MediaType::fromMime($mime);
|
||||
|
||||
if ($type === null || ! in_array($type, $allowedTypes, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Per-type size enforcement. Image is 10 MB even though we
|
||||
// streamed up to the video cap, so a 100 MB jpeg is rejected
|
||||
// here before we persist it.
|
||||
if ($bytes > $type->maxSizeInBytes()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$originalFilename = basename(parse_url($url, PHP_URL_PATH) ?? '') ?: 'download.bin';
|
||||
$media = $workspace->addMediaFromPath($temp, $originalFilename, 'assets');
|
||||
|
||||
|
|
@ -167,24 +177,4 @@ private function allowedMediaTypesFor(Post $post): array
|
|||
|
||||
return array_map(fn ($value) => MediaType::from($value), $intersection);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the MediaType for a given MIME by walking the enum's own
|
||||
* allow-list. Document is excluded from URL fetches (no PDFs via URL
|
||||
* — those go through direct upload only).
|
||||
*/
|
||||
private function resolveType(?string $mime): ?MediaType
|
||||
{
|
||||
if ($mime === null || $mime === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ([MediaType::Image, MediaType::Video] as $type) {
|
||||
if (in_array($mime, $type->allowedMimeTypes(), true)) {
|
||||
return $type;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,24 @@
|
|||
|
||||
'self_hosted' => env('SELF_HOSTED', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Media Size Limits
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Per-type size caps in megabytes. Single source of truth — direct
|
||||
| uploads (StoreAssetRequest, AssetController::storeChunked), URL
|
||||
| fetches (MediaAttacher), and the MediaType enum all read from here.
|
||||
|
|
||||
*/
|
||||
|
||||
'media' => [
|
||||
'max_size_mb' => [
|
||||
'image' => (int) env('MEDIA_IMAGE_MAX_SIZE_MB', 10),
|
||||
'video' => (int) env('MEDIA_VIDEO_MAX_SIZE_MB', 1024),
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Google Authentication
|
||||
|
|
|
|||
|
|
@ -165,15 +165,18 @@
|
|||
});
|
||||
|
||||
test('chunked upload completes with single chunk', function () {
|
||||
$content = str_repeat('x', 1000);
|
||||
// Use real PNG bytes so mime_content_type detects image/png. The MIME
|
||||
// is sniffed from content magic bytes, not the X-File-Name header.
|
||||
$content = file_get_contents(__DIR__.'/../fixtures/1x1.png');
|
||||
$size = strlen($content);
|
||||
|
||||
$response = $this->actingAs($this->user)->call(
|
||||
'POST',
|
||||
route('app.assets.store-chunked'),
|
||||
[], [], [],
|
||||
[
|
||||
'HTTP_CONTENT_RANGE' => 'bytes 0-999/1000',
|
||||
'HTTP_X_FILE_NAME' => 'test-video.mp4',
|
||||
'HTTP_CONTENT_RANGE' => 'bytes 0-'.($size - 1).'/'.$size,
|
||||
'HTTP_X_FILE_NAME' => 'test.png',
|
||||
'HTTP_ACCEPT' => 'application/json',
|
||||
'CONTENT_TYPE' => 'application/octet-stream',
|
||||
],
|
||||
|
|
@ -236,7 +239,9 @@
|
|||
'data',
|
||||
);
|
||||
|
||||
$response->assertStatus(400);
|
||||
// FormRequest validation surfaces parse failures as 422
|
||||
// (range_start / range_end / total_size all required).
|
||||
$response->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('chunked upload rejects unauthenticated', function () {
|
||||
|
|
|
|||
|
|
@ -7,23 +7,36 @@
|
|||
test('media type has correct values', function () {
|
||||
expect(Type::Image->value)->toBe('image');
|
||||
expect(Type::Video->value)->toBe('video');
|
||||
expect(Type::Document->value)->toBe('document');
|
||||
});
|
||||
|
||||
test('media type has labels', function () {
|
||||
expect(Type::Image->label())->toBe('Imagem');
|
||||
expect(Type::Video->label())->toBe('Vídeo');
|
||||
expect(Type::Document->label())->toBe('Documento');
|
||||
});
|
||||
|
||||
test('media type has allowed mime types', function () {
|
||||
expect(Type::Image->allowedMimeTypes())->toContain('image/jpeg', 'image/png');
|
||||
expect(Type::Video->allowedMimeTypes())->toContain('video/mp4', 'video/quicktime');
|
||||
expect(Type::Document->allowedMimeTypes())->toContain('application/pdf');
|
||||
});
|
||||
|
||||
test('media type has max size in mb', function () {
|
||||
test('media type max size in mb is read from config', function () {
|
||||
config(['trypost.media.max_size_mb.image' => 10]);
|
||||
config(['trypost.media.max_size_mb.video' => 1024]);
|
||||
|
||||
expect(Type::Image->maxSizeInMb())->toBe(10);
|
||||
expect(Type::Video->maxSizeInMb())->toBe(2048);
|
||||
expect(Type::Document->maxSizeInMb())->toBe(100);
|
||||
expect(Type::Video->maxSizeInMb())->toBe(1024);
|
||||
});
|
||||
|
||||
test('media type exposes derived size units', function () {
|
||||
config(['trypost.media.max_size_mb.video' => 1024]);
|
||||
|
||||
expect(Type::Video->maxSizeInKb())->toBe(1024 * 1024);
|
||||
expect(Type::Video->maxSizeInBytes())->toBe(1024 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test('media type resolves from mime', function () {
|
||||
expect(Type::fromMime('image/jpeg'))->toBe(Type::Image);
|
||||
expect(Type::fromMime('video/mp4'))->toBe(Type::Video);
|
||||
expect(Type::fromMime('application/pdf'))->toBeNull();
|
||||
expect(Type::fromMime('not-a-mime'))->toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
});
|
||||
|
||||
test('platform has correct allowed media types', function () {
|
||||
expect(Platform::LinkedIn->allowedMediaTypes())->toContain(MediaType::Image, MediaType::Video, MediaType::Document);
|
||||
expect(Platform::LinkedIn->allowedMediaTypes())->toContain(MediaType::Image, MediaType::Video);
|
||||
expect(Platform::X->allowedMediaTypes())->toContain(MediaType::Image, MediaType::Video);
|
||||
expect(Platform::TikTok->allowedMediaTypes())->toBe([MediaType::Video]);
|
||||
expect(Platform::YouTube->allowedMediaTypes())->toBe([MediaType::Video]);
|
||||
|
|
|
|||
|
|
@ -107,12 +107,12 @@
|
|||
$workspace = Workspace::factory()->create();
|
||||
|
||||
$tempFile = tempnam(sys_get_temp_dir(), 'test');
|
||||
file_put_contents($tempFile, 'fake image content');
|
||||
file_put_contents($tempFile, file_get_contents(__DIR__.'/../../fixtures/1x1.png'));
|
||||
|
||||
$media = $workspace->addMediaFromPath($tempFile, 'uploaded.jpg', 'logo');
|
||||
$media = $workspace->addMediaFromPath($tempFile, 'uploaded.png', 'logo');
|
||||
|
||||
expect($media)->toBeInstanceOf(Media::class);
|
||||
expect($media->original_filename)->toBe('uploaded.jpg');
|
||||
expect($media->original_filename)->toBe('uploaded.png');
|
||||
Storage::assertExists($media->path);
|
||||
|
||||
unlink($tempFile);
|
||||
|
|
@ -166,13 +166,12 @@
|
|||
expect($media->type->value)->toBe('video');
|
||||
});
|
||||
|
||||
test('add media detects document type', function () {
|
||||
test('add media throws on unsupported MIME type', function () {
|
||||
$workspace = Workspace::factory()->create();
|
||||
$file = UploadedFile::fake()->create('document.pdf', 1000, 'application/pdf');
|
||||
|
||||
$media = $workspace->addMedia($file, 'logo');
|
||||
|
||||
expect($media->type->value)->toBe('document');
|
||||
expect(fn () => $workspace->addMedia($file, 'logo'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
test('add media includes custom meta', function () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue