Uploads were written to the qr disk byte-for-byte. A bookshop cover arrived as a 9000x6600 print-resolution JPEG — 18MB — and was then served in full to every visitor of a public bio page. During a launch that one file accounted for roughly 1.6GB of 1.9GB of image traffic, and because these assets are proxied through the app rather than served statically, PHP streamed every byte of it and held a worker for the duration. Nothing the QR/bio pages display needs more than a couple of thousand pixels, so UploadedImageOptimizer caps the long edge at 2000px and re-encodes on the way in. The incident file becomes ~350KB. Configurable via qr.image.*. Deliberately conservative, because a broken upload is worse than a large one: - Format is preserved. A PNG stays a PNG so transparency survives; converting to JPEG would put black boxes behind logos. - GIF is excluded — GD would silently drop animation frames. - SVG and any file GD cannot read is stored untouched. - Small files are passed through rather than lossily re-encoded for no gain. - Output is discarded if it came out larger than the original. - Any failure logs and falls back to storing the original. Applied to the seven image paths only. Books (PDF/EPUB) and documents keep the plain store — the optimiser would have fallen through for them anyway, but routing non-images through something called an image optimiser invites the wrong change later. This is the source-level counterpart to the nginx cache: caching stops the bytes being regenerated, this stops them existing. Tests: 6 new, including the real 9000x6600 shape, PNG alpha survival, format preservation and non-image passthrough. Pre-existing suite failures unchanged at 8. Co-Authored-By: Claude <noreply@anthropic.com>
652 lines
27 KiB
PHP
652 lines
27 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Qr;
|
|
|
|
use App\Models\QrCode;
|
|
use App\Models\QrDocument;
|
|
use App\Models\QrWallet;
|
|
use App\Models\User;
|
|
use App\Support\Qr\QrStyleDefaults;
|
|
use App\Support\Qr\UploadedImageOptimizer;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use RuntimeException;
|
|
|
|
class QrCodeManagerService
|
|
{
|
|
public function __construct(
|
|
private QrWalletBillingService $billing,
|
|
private QrImageGeneratorService $imageGenerator,
|
|
private QrPayloadValidator $payloadValidator,
|
|
) {}
|
|
|
|
public function walletFor(User $user): QrWallet
|
|
{
|
|
return $user->qrWallet()->firstOrCreate(
|
|
[],
|
|
['credit_balance' => 0, 'status' => QrWallet::STATUS_ACTIVE],
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function create(User $user, array $data): QrCode
|
|
{
|
|
$wallet = $this->walletFor($user);
|
|
|
|
// Fast path: one cached balance read. Debit is still authoritative (402 if
|
|
// funds are short). Avoids a second can-afford HTTP round-trip.
|
|
if (! $wallet->canCreateQr()) {
|
|
throw new RuntimeException('Add at least GHS ' . number_format(QrWallet::pricePerQr(), 2) . ' to your QR wallet before creating codes.');
|
|
}
|
|
|
|
$type = (string) ($data['type'] ?? '');
|
|
$validated = $this->payloadValidator->validateForCreate($type, $data);
|
|
$style = QrStyleDefaults::merge($data['style'] ?? null);
|
|
|
|
// Persist the code first (short DB transaction — no remote billing/image work).
|
|
$qrCode = DB::transaction(function () use ($user, $data, $type, $validated, $style) {
|
|
$documentId = null;
|
|
$content = $validated['content'];
|
|
|
|
if ($type === QrCode::TYPE_DOCUMENT) {
|
|
$file = $data['document'] ?? null;
|
|
if (! $file instanceof UploadedFile) {
|
|
throw new RuntimeException('A PDF document is required for PDF QR codes.');
|
|
}
|
|
$documentId = $this->storeDocument($user, $file, $data['label'] ?? 'Document')->id;
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_IMAGE) {
|
|
$images = $this->storeImages($user, $data);
|
|
$content['images'] = $images;
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) {
|
|
$content['avatar_path'] = $this->storeVcardAvatar($user, $data['avatar']);
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_BOOK) {
|
|
$bookFile = $data['book_file'] ?? null;
|
|
if (! $bookFile instanceof UploadedFile) {
|
|
throw new RuntimeException('Upload the book file (PDF or EPUB).');
|
|
}
|
|
$bookData = $this->storeBookFile($user, $bookFile);
|
|
$content['file_path'] = $bookData['path'];
|
|
$content['file_type'] = $bookData['type'];
|
|
$content['file_size'] = $bookData['size'];
|
|
|
|
if (($data['cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeBookCover($user, $data['cover']);
|
|
}
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) {
|
|
$content['icon_path'] = $this->storeMenuBrandImage($user, $data['app_icon'], 'app-icons');
|
|
}
|
|
|
|
if (in_array($type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) {
|
|
$content['sections'] = $this->injectItemImages($user, $content['sections'] ?? [], $data, $type);
|
|
if (($data['menu_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['menu_logo'], 'menu-logos');
|
|
}
|
|
if (($data['menu_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['menu_cover'], 'menu-covers');
|
|
}
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_BUSINESS) {
|
|
if (($data['business_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['business_logo'], 'business-logos');
|
|
}
|
|
if (($data['business_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['business_cover'], 'business-covers');
|
|
}
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_PORTFOLIO) {
|
|
if (($data['portfolio_avatar'] ?? null) instanceof UploadedFile) {
|
|
$content['avatar_path'] = $this->storeMenuBrandImage($user, $data['portfolio_avatar'], 'portfolio-avatars');
|
|
}
|
|
if (($data['portfolio_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['portfolio_cover'], 'portfolio-covers');
|
|
}
|
|
$content['projects'] = $this->injectListImages(
|
|
$user,
|
|
$content['projects'] ?? [],
|
|
$data['project_images'] ?? [],
|
|
'portfolio-projects',
|
|
'image_path',
|
|
);
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_BOOKSHOP) {
|
|
if (($data['bookshop_avatar'] ?? null) instanceof UploadedFile) {
|
|
$content['avatar_path'] = $this->storeMenuBrandImage($user, $data['bookshop_avatar'], 'bookshop-avatars');
|
|
}
|
|
if (($data['bookshop_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['bookshop_cover'], 'bookshop-covers');
|
|
}
|
|
$content['books'] = $this->injectListImages(
|
|
$user,
|
|
$content['books'] ?? [],
|
|
$data['book_covers'] ?? [],
|
|
'bookshop-books',
|
|
'cover_path',
|
|
);
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_CHURCH) {
|
|
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['church_logo'], 'church-logos');
|
|
}
|
|
if (($data['church_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['church_cover'], 'church-covers');
|
|
}
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_EVENT) {
|
|
if (($data['event_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($user, $data['event_logo'], 'event-logos');
|
|
}
|
|
if (($data['event_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['event_cover'], 'event-covers');
|
|
}
|
|
}
|
|
|
|
if ($type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($user, $data['itinerary_cover'], 'itinerary-covers');
|
|
}
|
|
|
|
if ($style['logo_path'] === null && ($data['logo'] ?? null) instanceof UploadedFile) {
|
|
$style['logo_path'] = $this->storeLogo($user, $data['logo']);
|
|
}
|
|
|
|
$shortCode = (isset($data['custom_short_code']) && $data['custom_short_code'] !== '')
|
|
? (string) $data['custom_short_code']
|
|
: $this->generateUniqueShortCode();
|
|
|
|
$payload = [
|
|
'content' => $content,
|
|
'style' => $style,
|
|
];
|
|
|
|
// Freeze the WiFi auto-join payload so the printed code never changes on edit.
|
|
if ($type === QrCode::TYPE_WIFI) {
|
|
$payload['wifi_encoded'] = \App\Support\Qr\QrWifiPayload::encode($content);
|
|
}
|
|
|
|
return QrCode::create([
|
|
'user_id' => $user->id,
|
|
'short_code' => $shortCode,
|
|
'type' => $type,
|
|
'label' => trim((string) $data['label']),
|
|
'destination_url' => $validated['destination_url'],
|
|
'qr_document_id' => $documentId,
|
|
'payload' => $payload,
|
|
'is_active' => true,
|
|
'destination_updated_at' => now(),
|
|
]);
|
|
});
|
|
|
|
try {
|
|
$this->billing->debitForQrCreation($wallet, $qrCode);
|
|
} catch (RuntimeException $e) {
|
|
// Roll back the unpaid code so a failed debit cannot leave a free QR.
|
|
$qrCode->delete();
|
|
throw $e;
|
|
}
|
|
|
|
// Image render is local CPU work — keep it outside the DB transaction.
|
|
$this->imageGenerator->generateAndStore($qrCode);
|
|
|
|
return $qrCode->fresh(['document']);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
*/
|
|
public function update(QrCode $qrCode, array $data): QrCode
|
|
{
|
|
$content = $qrCode->content();
|
|
$style = $qrCode->style();
|
|
$destinationUrl = $qrCode->destination_url;
|
|
$regenerate = false;
|
|
|
|
if (isset($data['label']) && trim((string) $data['label']) !== '') {
|
|
$qrCode->label = trim((string) $data['label']);
|
|
}
|
|
|
|
if (array_key_exists('is_active', $data)) {
|
|
$qrCode->is_active = (bool) $data['is_active'];
|
|
}
|
|
|
|
$typeFields = array_merge($content, $data);
|
|
if ($this->hasContentChanges($qrCode, $data)) {
|
|
$validated = $this->payloadValidator->validateForUpdate($qrCode, $typeFields);
|
|
$content = $validated['content'];
|
|
$destinationUrl = $validated['destination_url'];
|
|
$qrCode->destination_updated_at = now();
|
|
}
|
|
|
|
if ($qrCode->isDocumentType() && isset($data['document']) && $data['document'] instanceof UploadedFile) {
|
|
$document = $this->storeDocument($qrCode->user, $data['document'], $data['label'] ?? $qrCode->label);
|
|
$qrCode->qr_document_id = $document->id;
|
|
$qrCode->destination_updated_at = now();
|
|
}
|
|
|
|
if ($qrCode->isImageType()) {
|
|
$newImages = $this->storeImages($qrCode->user, $data, false);
|
|
if ($newImages !== []) {
|
|
$content['images'] = array_merge($content['images'] ?? [], $newImages);
|
|
$qrCode->destination_updated_at = now();
|
|
}
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_VCARD && ($data['avatar'] ?? null) instanceof UploadedFile) {
|
|
if ($oldPath = $content['avatar_path'] ?? null) {
|
|
Storage::disk('qr')->delete($oldPath);
|
|
}
|
|
$content['avatar_path'] = $this->storeVcardAvatar($qrCode->user, $data['avatar']);
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_BOOK) {
|
|
if (($data['book_file'] ?? null) instanceof UploadedFile) {
|
|
$bookData = $this->storeBookFile($qrCode->user, $data['book_file']);
|
|
$content['file_path'] = $bookData['path'];
|
|
$content['file_type'] = $bookData['type'];
|
|
$content['file_size'] = $bookData['size'];
|
|
$qrCode->destination_updated_at = now();
|
|
}
|
|
if (($data['cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeBookCover($qrCode->user, $data['cover']);
|
|
}
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_APP && ($data['app_icon'] ?? null) instanceof UploadedFile) {
|
|
$content['icon_path'] = $this->storeMenuBrandImage($qrCode->user, $data['app_icon'], 'app-icons');
|
|
}
|
|
|
|
if (in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true)) {
|
|
$content['sections'] = $this->injectItemImages($qrCode->user, $content['sections'] ?? [], $data, $qrCode->type);
|
|
if (($data['menu_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_logo'], 'menu-logos');
|
|
}
|
|
if (($data['menu_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['menu_cover'], 'menu-covers');
|
|
}
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_BUSINESS) {
|
|
if (($data['business_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_logo'], 'business-logos');
|
|
}
|
|
if (($data['business_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['business_cover'], 'business-covers');
|
|
}
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_PORTFOLIO) {
|
|
if (($data['portfolio_avatar'] ?? null) instanceof UploadedFile) {
|
|
$content['avatar_path'] = $this->storeMenuBrandImage($qrCode->user, $data['portfolio_avatar'], 'portfolio-avatars');
|
|
}
|
|
if (($data['portfolio_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['portfolio_cover'], 'portfolio-covers');
|
|
}
|
|
$content['projects'] = $this->injectListImages(
|
|
$qrCode->user,
|
|
$content['projects'] ?? [],
|
|
$data['project_images'] ?? [],
|
|
'portfolio-projects',
|
|
'image_path',
|
|
);
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_BOOKSHOP) {
|
|
if (($data['bookshop_avatar'] ?? null) instanceof UploadedFile) {
|
|
$content['avatar_path'] = $this->storeMenuBrandImage($qrCode->user, $data['bookshop_avatar'], 'bookshop-avatars');
|
|
}
|
|
if (($data['bookshop_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['bookshop_cover'], 'bookshop-covers');
|
|
}
|
|
$content['books'] = $this->injectListImages(
|
|
$qrCode->user,
|
|
$content['books'] ?? [],
|
|
$data['book_covers'] ?? [],
|
|
'bookshop-books',
|
|
'cover_path',
|
|
);
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_CHURCH) {
|
|
if (($data['church_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_logo'], 'church-logos');
|
|
}
|
|
if (($data['church_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['church_cover'], 'church-covers');
|
|
}
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_EVENT) {
|
|
if (($data['event_logo'] ?? null) instanceof UploadedFile) {
|
|
$content['logo_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_logo'], 'event-logos');
|
|
}
|
|
if (($data['event_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['event_cover'], 'event-covers');
|
|
}
|
|
}
|
|
|
|
if ($qrCode->type === QrCode::TYPE_ITINERARY && ($data['itinerary_cover'] ?? null) instanceof UploadedFile) {
|
|
$content['cover_path'] = $this->storeMenuBrandImage($qrCode->user, $data['itinerary_cover'], 'itinerary-covers');
|
|
}
|
|
|
|
$previousStyle = $style;
|
|
|
|
if (isset($data['style']) && is_array($data['style'])) {
|
|
$style = QrStyleDefaults::merge(array_merge($style, $data['style']));
|
|
}
|
|
|
|
if (($data['logo'] ?? null) instanceof UploadedFile) {
|
|
$style['logo_path'] = $this->storeLogo($qrCode->user, $data['logo']);
|
|
$regenerate = true;
|
|
}
|
|
|
|
if (($data['remove_logo'] ?? false) && ($style['logo_path'] ?? null)) {
|
|
$style['logo_path'] = null;
|
|
$regenerate = true;
|
|
}
|
|
|
|
// Only re-render the PNG/SVG when style actually changed (forms always post style[]).
|
|
if (! $regenerate && $this->styleChanged($previousStyle, $style)) {
|
|
$regenerate = true;
|
|
}
|
|
|
|
$qrCode->destination_url = $destinationUrl;
|
|
// Preserve frozen keys (e.g. wifi_encoded) so the printed WiFi code stays put.
|
|
$payload = (array) ($qrCode->payload ?? []);
|
|
$payload['content'] = $content;
|
|
$payload['style'] = $style;
|
|
$qrCode->payload = $payload;
|
|
$qrCode->save();
|
|
|
|
if ($regenerate) {
|
|
$this->imageGenerator->generateAndStore($qrCode);
|
|
}
|
|
|
|
return $qrCode->fresh(['document']);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $before
|
|
* @param array<string, mixed> $after
|
|
*/
|
|
private function styleChanged(array $before, array $after): bool
|
|
{
|
|
$before = QrStyleDefaults::merge($before);
|
|
$after = QrStyleDefaults::merge($after);
|
|
ksort($before);
|
|
ksort($after);
|
|
|
|
return $before !== $after;
|
|
}
|
|
|
|
/**
|
|
* Inject uploaded item images into the sections array.
|
|
* Form field: item_images[sectionIndex][itemIndex] (UploadedFile)
|
|
*
|
|
* @param array<int, mixed> $sections
|
|
* @param array<string, mixed> $data
|
|
* @return array<int, mixed>
|
|
*/
|
|
private function injectItemImages(User $user, array $sections, array $data, string $type): array
|
|
{
|
|
$uploads = $data['item_images'] ?? [];
|
|
if (! is_array($uploads) || $uploads === []) {
|
|
return $sections;
|
|
}
|
|
|
|
foreach ($uploads as $sIndex => $itemUploads) {
|
|
if (! is_array($itemUploads)) {
|
|
continue;
|
|
}
|
|
foreach ($itemUploads as $iIndex => $file) {
|
|
if (! ($file instanceof UploadedFile)) {
|
|
continue;
|
|
}
|
|
if (! isset($sections[$sIndex]['items'][$iIndex])) {
|
|
continue;
|
|
}
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! str_starts_with($mime, 'image/')) {
|
|
continue;
|
|
}
|
|
$subdir = $type === QrCode::TYPE_SHOP ? 'shop-items' : 'menu-items';
|
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
|
$path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext;
|
|
UploadedImageOptimizer::store($file, $path, 'qr');
|
|
$sections[$sIndex]['items'][$iIndex]['image_path'] = $path;
|
|
}
|
|
}
|
|
|
|
return $sections;
|
|
}
|
|
|
|
/** @param array<string, mixed> $data */
|
|
private function hasContentChanges(QrCode $qrCode, array $data): bool
|
|
{
|
|
$keys = match ($qrCode->type) {
|
|
QrCode::TYPE_URL => ['destination_url'],
|
|
QrCode::TYPE_LINK_LIST => ['links'],
|
|
QrCode::TYPE_VCARD => ['first_name', 'last_name', 'phone', 'email', 'company', 'website', 'address', 'note', 'social'],
|
|
QrCode::TYPE_BUSINESS => ['name', 'tagline', 'phone', 'email', 'website', 'address', 'hours'],
|
|
QrCode::TYPE_CHURCH => ['name', 'denomination', 'description', 'phone', 'email', 'website', 'address', 'service_times', 'org_type', 'accepts_payment', 'collection_types', 'brand_color'],
|
|
QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open'],
|
|
QrCode::TYPE_ITINERARY => ['title', 'subtitle', 'description', 'event_date', 'location', 'brand_color', 'days'],
|
|
QrCode::TYPE_DOCUMENT => ['allow_download'],
|
|
QrCode::TYPE_MENU => ['menu_title', 'sections', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
|
|
QrCode::TYPE_SHOP => ['shop_title', 'sections', 'currency', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
|
|
QrCode::TYPE_APP => ['app_name', 'ios_url', 'android_url', 'web_url', 'app_icon'],
|
|
QrCode::TYPE_BOOK => ['book_title', 'author', 'description', 'price_ghs'],
|
|
QrCode::TYPE_PORTFOLIO => ['name', 'role', 'bio', 'email', 'phone', 'website', 'brand_color', 'projects', 'social'],
|
|
QrCode::TYPE_BOOKSHOP => ['author_name', 'tagline', 'bio', 'email', 'website', 'brand_color', 'books', 'social'],
|
|
QrCode::TYPE_WIFI => ['ssid', 'password', 'encryption', 'hidden'],
|
|
default => [],
|
|
};
|
|
|
|
foreach ($keys as $key) {
|
|
if (array_key_exists($key, $data)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function storeDocument(User $user, UploadedFile $file, string $title): QrDocument
|
|
{
|
|
$maxBytes = (int) config('qr.max_pdf_bytes', 104857600);
|
|
|
|
if ($file->getSize() > $maxBytes) {
|
|
throw new RuntimeException('PDF must be 100 MB or smaller.');
|
|
}
|
|
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! in_array($mime, ['application/pdf', 'application/x-pdf'], true)) {
|
|
throw new RuntimeException('Only PDF documents are supported.');
|
|
}
|
|
|
|
$uuid = Str::uuid()->toString();
|
|
$path = $user->id . '/documents/' . $uuid . '.pdf';
|
|
|
|
$file->storeAs('', $path, 'qr');
|
|
|
|
return QrDocument::create([
|
|
'user_id' => $user->id,
|
|
'title' => $title,
|
|
'disk' => 'qr',
|
|
'path' => $path,
|
|
'mime_type' => 'application/pdf',
|
|
'size_bytes' => (int) $file->getSize(),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $data
|
|
* @return list<array{path: string, title: string}>
|
|
*/
|
|
private function storeImages(User $user, array $data, bool $required = true): array
|
|
{
|
|
$files = [];
|
|
if (($data['image'] ?? null) instanceof UploadedFile) {
|
|
$files[] = $data['image'];
|
|
}
|
|
if (is_array($data['images'] ?? null)) {
|
|
foreach ($data['images'] as $file) {
|
|
if ($file instanceof UploadedFile) {
|
|
$files[] = $file;
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($required && $files === []) {
|
|
$this->payloadValidator->validateImageUpload(null);
|
|
}
|
|
|
|
$stored = [];
|
|
foreach ($files as $index => $file) {
|
|
$this->payloadValidator->validateImageUpload($file);
|
|
$uuid = Str::uuid()->toString();
|
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
|
$path = $user->id . '/images/' . $uuid . '.' . $ext;
|
|
UploadedImageOptimizer::store($file, $path, 'qr');
|
|
$stored[] = [
|
|
'path' => $path,
|
|
'title' => $file->getClientOriginalName() ?: ('Image ' . ($index + 1)),
|
|
];
|
|
}
|
|
|
|
return $stored;
|
|
}
|
|
|
|
private function storeVcardAvatar(User $user, UploadedFile $file): string
|
|
{
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! str_starts_with($mime, 'image/')) {
|
|
throw new RuntimeException('Avatar must be an image file.');
|
|
}
|
|
|
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
|
$path = $user->id . '/vcards/' . Str::uuid()->toString() . '.' . $ext;
|
|
UploadedImageOptimizer::store($file, $path, 'qr');
|
|
|
|
return $path;
|
|
}
|
|
|
|
private function storeBookFile(User $user, UploadedFile $file): array
|
|
{
|
|
$ext = strtolower($file->getClientOriginalExtension() ?: '');
|
|
$mime = $file->getMimeType() ?: '';
|
|
|
|
if ($ext === 'pdf' || in_array($mime, ['application/pdf', 'application/x-pdf'], true)) {
|
|
$type = 'pdf';
|
|
} elseif ($ext === 'epub' || str_contains($mime, 'epub')) {
|
|
$type = 'epub';
|
|
} else {
|
|
throw new RuntimeException('Only PDF and EPUB files are supported for books.');
|
|
}
|
|
|
|
$path = $user->id . '/books/' . Str::uuid()->toString() . '.' . $type;
|
|
$file->storeAs('', $path, 'qr');
|
|
|
|
return ['path' => $path, 'type' => $type, 'size' => (int) $file->getSize()];
|
|
}
|
|
|
|
private function storeMenuBrandImage(User $user, UploadedFile $file, string $subdir): string
|
|
{
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! str_starts_with($mime, 'image/')) {
|
|
throw new RuntimeException('Only image files are supported.');
|
|
}
|
|
|
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
|
$path = $user->id . '/' . $subdir . '/' . Str::uuid()->toString() . '.' . $ext;
|
|
UploadedImageOptimizer::store($file, $path, 'qr');
|
|
|
|
return $path;
|
|
}
|
|
|
|
/**
|
|
* Inject uploaded list images (project images / book covers) into a flat list.
|
|
*
|
|
* @param array<int, mixed> $items
|
|
* @param array<int, mixed> $uploads
|
|
* @return array<int, mixed>
|
|
*/
|
|
private function injectListImages(User $user, array $items, array $uploads, string $subdir, string $pathKey): array
|
|
{
|
|
if ($uploads === []) {
|
|
return $items;
|
|
}
|
|
|
|
foreach ($uploads as $index => $file) {
|
|
if (! ($file instanceof UploadedFile) || ! isset($items[$index])) {
|
|
continue;
|
|
}
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! str_starts_with($mime, 'image/')) {
|
|
continue;
|
|
}
|
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
|
$path = $user->id.'/'.$subdir.'/'.Str::uuid()->toString().'.'.$ext;
|
|
UploadedImageOptimizer::store($file, $path, 'qr');
|
|
$items[$index][$pathKey] = $path;
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
private function storeBookCover(User $user, UploadedFile $file): string
|
|
{
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! str_starts_with($mime, 'image/')) {
|
|
throw new RuntimeException('Book cover must be an image file.');
|
|
}
|
|
|
|
$ext = $file->getClientOriginalExtension() ?: 'jpg';
|
|
$path = $user->id . '/book-covers/' . Str::uuid()->toString() . '.' . $ext;
|
|
UploadedImageOptimizer::store($file, $path, 'qr');
|
|
|
|
return $path;
|
|
}
|
|
|
|
private function storeLogo(User $user, UploadedFile $file): string
|
|
{
|
|
$mime = $file->getMimeType() ?: '';
|
|
if (! str_starts_with($mime, 'image/')) {
|
|
throw new RuntimeException('Logo must be an image file.');
|
|
}
|
|
|
|
$path = $user->id . '/logos/' . Str::uuid()->toString() . '.' . ($file->getClientOriginalExtension() ?: 'png');
|
|
UploadedImageOptimizer::store($file, $path, 'qr');
|
|
|
|
return $path;
|
|
}
|
|
|
|
private function generateUniqueShortCode(): string
|
|
{
|
|
$length = (int) config('qr.short_code_length', 8);
|
|
|
|
for ($attempt = 0; $attempt < 20; $attempt++) {
|
|
$code = Str::lower(Str::random($length));
|
|
if (! QrCode::query()->where('short_code', $code)->exists()) {
|
|
return $code;
|
|
}
|
|
}
|
|
|
|
throw new RuntimeException('Could not generate a unique QR short code.');
|
|
}
|
|
}
|