Deploy Ladill Transfer / deploy (push) Successful in 31s
Public URLs, encoded payloads, slug preview, payment callbacks, and asset paths now use ladl.link instead of ladill.com/q/*. Co-authored-by: Cursor <cursoragent@cursor.com>
455 lines
20 KiB
PHP
455 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Qr;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\QrCode;
|
|
use App\Models\QrEventRegistration;
|
|
use App\Models\QrWallet;
|
|
use App\Services\Billing\BillingClient;
|
|
use App\Services\Qr\QrAnalyticsService;
|
|
use App\Services\Qr\QrCodeManagerService;
|
|
use App\Services\Qr\QrImageGeneratorService;
|
|
use App\Services\Qr\QrPdfExporter;
|
|
use App\Services\Qr\QrWalletBillingService;
|
|
use App\Support\Qr\QrCornerStyleCatalog;
|
|
use App\Support\Qr\QrFrameStyleCatalog;
|
|
use App\Support\Qr\QrModuleStyleCatalog;
|
|
use App\Support\Qr\QrTypeCatalog;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\RedirectResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\View\View;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class QrCodeController extends Controller
|
|
{
|
|
public function __construct(
|
|
private QrCodeManagerService $manager,
|
|
private QrWalletBillingService $billing,
|
|
private QrAnalyticsService $analytics,
|
|
private QrImageGeneratorService $imageGenerator,
|
|
private QrPdfExporter $pdfExporter,
|
|
private BillingClient $platformBilling,
|
|
) {}
|
|
|
|
public function index(Request $request): View
|
|
{
|
|
$account = ladill_account();
|
|
$wallet = $this->manager->walletFor($account);
|
|
$qrCodes = $account->qrCodes()
|
|
->whereIn('type', QrTypeCatalog::eventTypes())
|
|
->withCount(['eventRegistrations as registrations_count' => fn ($q) => $q->where('status', QrEventRegistration::STATUS_CONFIRMED)])
|
|
->latest()
|
|
->get();
|
|
|
|
$totalRegistrations = (int) $qrCodes->sum('registrations_count');
|
|
|
|
$ladillWalletBalance = 0.0;
|
|
try {
|
|
$ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100;
|
|
} catch (Throwable $e) {
|
|
Log::warning('Events index could not load Ladill wallet balance', [
|
|
'user' => $account->public_id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
return view('qr-codes.index', [
|
|
'wallet' => $wallet,
|
|
'qrCodes' => $qrCodes,
|
|
'totalRegistrations' => $totalRegistrations,
|
|
'pricePerQr' => QrWallet::pricePerQr(),
|
|
'minTopup' => QrWallet::minTopupGhs(),
|
|
'ladillWalletBalance' => $ladillWalletBalance,
|
|
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
|
]);
|
|
}
|
|
|
|
public function create(Request $request): View
|
|
{
|
|
$account = ladill_account();
|
|
$wallet = $this->manager->walletFor($account);
|
|
$qrSettings = $account->getOrCreateQrSetting();
|
|
$requestedType = $request->query('type', QrCode::TYPE_EVENT);
|
|
if (! QrTypeCatalog::isValid($requestedType)) {
|
|
$requestedType = QrCode::TYPE_EVENT;
|
|
}
|
|
|
|
return view('qr-codes.create', [
|
|
'wallet' => $wallet,
|
|
'requestedType' => $requestedType,
|
|
'moduleStyles' => QrModuleStyleCatalog::visible(),
|
|
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
|
|
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
|
|
'frameStyles' => QrFrameStyleCatalog::visible(),
|
|
'pricePerQr' => QrWallet::pricePerQr(),
|
|
'minTopup' => QrWallet::minTopupGhs(),
|
|
'ladillWalletBalance' => $this->platformBilling->balanceMinor($account->public_id) / 100,
|
|
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
|
'accountDefaultStyle' => $qrSettings->resolvedDefaultStyle(),
|
|
'accountEventDefaults' => $qrSettings->resolvedEventDefaults(),
|
|
]);
|
|
}
|
|
|
|
public function checkSlug(Request $request): JsonResponse
|
|
{
|
|
$code = (string) $request->query('code', '');
|
|
|
|
if (! preg_match('/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', $code)) {
|
|
return response()->json(['available' => false, 'reason' => 'invalid']);
|
|
}
|
|
|
|
$taken = QrCode::where('short_code', $code)->exists();
|
|
|
|
return response()->json(['available' => ! $taken]);
|
|
}
|
|
|
|
public function store(Request $request): RedirectResponse
|
|
{
|
|
$validated = $request->validate([
|
|
'label' => ['required', 'string', 'max:120'],
|
|
'type' => ['required', 'in:' . implode(',', QrTypeCatalog::keys())],
|
|
'custom_short_code' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', 'unique:qr_codes,short_code'],
|
|
'destination_url' => ['nullable', 'url', 'max:2048'],
|
|
'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'],
|
|
'image' => ['nullable', 'image', 'max:10240'],
|
|
'images' => ['nullable', 'array'],
|
|
'images.*' => ['image', 'max:10240'],
|
|
'item_images' => ['nullable', 'array'],
|
|
'item_images.*' => ['array'],
|
|
'item_images.*.*' => ['image', 'max:4096'],
|
|
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
|
|
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
|
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
|
'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
|
'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
|
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
|
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
|
'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
|
'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
|
'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
|
...$this->styleRules(),
|
|
]);
|
|
|
|
if ($validated['type'] === QrCode::TYPE_DOCUMENT && ! $request->hasFile('document')) {
|
|
return back()->withInput()->with('error', 'Upload a PDF for PDF QR codes.');
|
|
}
|
|
|
|
if ($validated['type'] === QrCode::TYPE_IMAGE && ! $request->hasFile('image') && ! $request->hasFile('images')) {
|
|
return back()->withInput()->with('error', 'Upload at least one image.');
|
|
}
|
|
|
|
try {
|
|
$qrCode = $this->manager->create(ladill_account(), array_merge(
|
|
$request->all(),
|
|
[
|
|
'style' => $validated['style'] ?? [],
|
|
'custom_short_code' => $validated['custom_short_code'] ?? null,
|
|
'avatar' => $request->file('avatar'),
|
|
'book_file' => $request->file('book_file'),
|
|
'cover' => $request->file('cover'),
|
|
'menu_logo' => $request->file('menu_logo'),
|
|
'menu_cover' => $request->file('menu_cover'),
|
|
'business_logo' => $request->file('business_logo'),
|
|
'business_cover' => $request->file('business_cover'),
|
|
'church_logo' => $request->file('church_logo'),
|
|
'church_cover' => $request->file('church_cover'),
|
|
'event_logo' => $request->file('event_logo'),
|
|
'event_cover' => $request->file('event_cover'),
|
|
'itinerary_cover' => $request->file('itinerary_cover'),
|
|
],
|
|
));
|
|
} catch (RuntimeException $e) {
|
|
if (str_contains($e->getMessage(), 'Insufficient') || str_contains($e->getMessage(), 'Add at least')) {
|
|
return back()->withInput()->with('open_topup_modal', 'qr');
|
|
}
|
|
|
|
return back()->withInput()->with('error', $e->getMessage());
|
|
}
|
|
|
|
return redirect()->route('events.show', $qrCode)
|
|
->with('success', QrTypeCatalog::label($qrCode->type).' created.');
|
|
}
|
|
|
|
public function show(Request $request, QrCode $event): View
|
|
{
|
|
$this->authorize('view', $event);
|
|
|
|
$previewDataUri = $this->imageGenerator->previewDataUri($event);
|
|
$qrCode = $event->fresh();
|
|
$qrStyle = $qrCode->style();
|
|
$logoDataUri = ! empty($qrStyle['logo_path'])
|
|
? $this->imageGenerator->logoDataUri($qrStyle['logo_path'])
|
|
: null;
|
|
$account = ladill_account();
|
|
$wallet = $this->manager->walletFor($account);
|
|
$summary = $this->analytics->summaryFor($qrCode);
|
|
$dailyScans = $this->analytics->dailyScans($qrCode, 30);
|
|
$devices = $this->analytics->breakdown($qrCode, 'device_type');
|
|
$browsers = $this->analytics->breakdown($qrCode, 'browser');
|
|
$recentScans = $this->analytics->recentScans($qrCode);
|
|
|
|
$ladillWalletBalance = 0.0;
|
|
try {
|
|
$ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100;
|
|
} catch (Throwable $e) {
|
|
Log::warning('QR Plus show could not load Ladill wallet balance', [
|
|
'user' => $account->public_id,
|
|
'error' => $e->getMessage(),
|
|
]);
|
|
}
|
|
|
|
return view('qr-codes.show', [
|
|
'qrCode' => $qrCode,
|
|
'previewDataUri' => $previewDataUri,
|
|
'logoDataUri' => $logoDataUri,
|
|
'types' => QrTypeCatalog::all(),
|
|
'moduleStyles' => QrModuleStyleCatalog::visible(),
|
|
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
|
|
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
|
|
'frameStyles' => QrFrameStyleCatalog::visible(),
|
|
'wallet' => $wallet,
|
|
'summary' => $summary,
|
|
'dailyScans' => $dailyScans,
|
|
'devices' => $devices,
|
|
'browsers' => $browsers,
|
|
'recentScans' => $recentScans,
|
|
'orders' => null,
|
|
'pricePerQr' => QrWallet::pricePerQr(),
|
|
'minTopup' => QrWallet::minTopupGhs(),
|
|
'ladillWalletBalance' => $ladillWalletBalance,
|
|
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
|
|
]);
|
|
}
|
|
|
|
public function update(Request $request, QrCode $event): RedirectResponse
|
|
{
|
|
$this->authorize('update', $event);
|
|
$qrCode = $event;
|
|
|
|
$validated = $request->validate([
|
|
'label' => ['sometimes', 'string', 'max:120'],
|
|
'destination_url' => ['nullable', 'url', 'max:2048'],
|
|
'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'],
|
|
'image' => ['nullable', 'image', 'max:10240'],
|
|
'images' => ['nullable', 'array'],
|
|
'images.*' => ['image', 'max:10240'],
|
|
'item_images' => ['nullable', 'array'],
|
|
'item_images.*' => ['array'],
|
|
'item_images.*.*' => ['image', 'max:4096'],
|
|
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
|
|
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
|
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
|
'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
|
'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
|
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
|
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
|
'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
|
|
'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
|
'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
|
|
'remove_logo' => ['sometimes', 'boolean'],
|
|
'is_active' => ['sometimes', 'boolean'],
|
|
...$this->styleRules(),
|
|
]);
|
|
|
|
try {
|
|
$this->manager->update($qrCode, array_merge(
|
|
$request->all(),
|
|
[
|
|
'document' => $request->file('document'),
|
|
'logo' => $request->file('logo'),
|
|
'style' => $validated['style'] ?? null,
|
|
'is_active' => $request->boolean('is_active', $qrCode->is_active),
|
|
'remove_logo' => $request->boolean('remove_logo'),
|
|
'avatar' => $request->file('avatar'),
|
|
'book_file' => $request->file('book_file'),
|
|
'cover' => $request->file('cover'),
|
|
'menu_logo' => $request->file('menu_logo'),
|
|
'menu_cover' => $request->file('menu_cover'),
|
|
'business_logo' => $request->file('business_logo'),
|
|
'business_cover' => $request->file('business_cover'),
|
|
'church_logo' => $request->file('church_logo'),
|
|
'church_cover' => $request->file('church_cover'),
|
|
'event_logo' => $request->file('event_logo'),
|
|
'event_cover' => $request->file('event_cover'),
|
|
'itinerary_cover' => $request->file('itinerary_cover'),
|
|
],
|
|
));
|
|
} catch (RuntimeException $e) {
|
|
return back()->with('error', $e->getMessage());
|
|
}
|
|
|
|
return back()->with('success', QrTypeCatalog::label($qrCode->type).' updated.');
|
|
}
|
|
|
|
public function stylePreview(Request $request): Response
|
|
{
|
|
$validated = $request->validate(array_merge($this->styleRules(), [
|
|
'short_code' => ['nullable', 'string', 'max:16'],
|
|
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
|
|
'use_existing_logo' => ['nullable', 'boolean'],
|
|
'existing_logo_path' => ['nullable', 'string', 'max:500'],
|
|
]));
|
|
|
|
$shortCode = $validated['short_code'] ?? 'preview';
|
|
$style = $validated['style'] ?? [];
|
|
$tempLogoPath = null;
|
|
|
|
if ($request->hasFile('logo')) {
|
|
$tempLogoPath = $request->file('logo')->store('tmp/previews', 'qr');
|
|
$style['logo_path'] = $tempLogoPath;
|
|
} elseif ($request->boolean('use_existing_logo') && ! empty($validated['existing_logo_path'])) {
|
|
$existingPath = $validated['existing_logo_path'];
|
|
$userPrefix = ladill_account()->id . '/';
|
|
if (str_starts_with($existingPath, $userPrefix) && Storage::disk('qr')->exists($existingPath)) {
|
|
$style['logo_path'] = $existingPath;
|
|
}
|
|
}
|
|
|
|
$png = $this->imageGenerator->renderPng(\App\Support\LadillLink::url($shortCode), $style);
|
|
|
|
if ($tempLogoPath) {
|
|
Storage::disk('qr')->delete($tempLogoPath);
|
|
}
|
|
|
|
return response($png, 200, [
|
|
'Content-Type' => 'image/png',
|
|
'Cache-Control' => 'no-store',
|
|
]);
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function styleRules(): array
|
|
{
|
|
return [
|
|
'style' => ['nullable', 'array'],
|
|
'style.foreground' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
|
'style.background' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
|
'style.error_correction' => ['nullable', 'in:L,M,Q,H'],
|
|
'style.margin' => ['nullable', 'integer', 'min:0', 'max:10'],
|
|
'style.module_style' => ['nullable', 'in:' . implode(',', QrModuleStyleCatalog::keys())],
|
|
'style.finder_outer' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::outerStyles()))],
|
|
'style.finder_inner' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::innerStyles()))],
|
|
'style.frame_style' => ['nullable', 'in:' . implode(',', QrFrameStyleCatalog::keys())],
|
|
'style.frame_text' => ['nullable', 'string', 'max:100'],
|
|
'style.frame_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
|
'style.scale' => ['nullable', 'integer', 'min:4', 'max:16'],
|
|
'style.gradient_type' => ['nullable', 'in:none,linear,radial'],
|
|
'style.gradient_color1' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
|
'style.gradient_color2' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
|
'style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'],
|
|
'style.logo_size' => ['nullable', 'numeric', 'min:0.1', 'max:0.4'],
|
|
'style.logo_margin' => ['nullable', 'integer', 'min:0', 'max:15'],
|
|
'style.logo_white_bg' => ['nullable', 'boolean'],
|
|
'style.logo_shape' => ['nullable', 'in:none,rounded,circle'],
|
|
];
|
|
}
|
|
|
|
public function preview(QrCode $event): Response
|
|
{
|
|
$this->authorize('view', $event);
|
|
|
|
$qrCode = $this->imageGenerator->ensureValidImages($event);
|
|
|
|
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
|
|
|
|
if ($bytes === null) {
|
|
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
|
$this->imageGenerator->generateAndStore($qrCode);
|
|
}
|
|
|
|
return response($bytes, 200, [
|
|
'Content-Type' => 'image/png',
|
|
'Cache-Control' => 'private, max-age=3600',
|
|
]);
|
|
}
|
|
|
|
public function download(QrCode $event, string $format): StreamedResponse
|
|
{
|
|
$this->authorize('view', $event);
|
|
|
|
$qrCode = $this->imageGenerator->ensureValidImages($event);
|
|
$path = $format === 'svg' ? $qrCode->svg_path : $qrCode->png_path;
|
|
$filename = Str::slug($qrCode->label) . '-qr.' . ($format === 'svg' ? 'svg' : 'png');
|
|
|
|
if ($format === 'png') {
|
|
$bytes = $this->imageGenerator->normalizeStoredPng($path);
|
|
if ($bytes === null) {
|
|
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
|
$this->imageGenerator->generateAndStore($qrCode);
|
|
}
|
|
|
|
return response()->streamDownload(fn () => print($bytes), $filename, [
|
|
'Content-Type' => 'image/png',
|
|
]);
|
|
}
|
|
|
|
if ($format === 'pdf') {
|
|
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
|
|
if ($bytes === null) {
|
|
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
|
|
$this->imageGenerator->generateAndStore($qrCode);
|
|
}
|
|
|
|
$pdf = $this->pdfExporter->fromPng($bytes, $qrCode->label);
|
|
$filename = Str::slug($qrCode->label) . '-qr.pdf';
|
|
|
|
return response()->streamDownload(fn () => print($pdf), $filename, [
|
|
'Content-Type' => 'application/pdf',
|
|
]);
|
|
}
|
|
|
|
abort_unless($path && Storage::disk('qr')->exists($path), 404);
|
|
|
|
return Storage::disk('qr')->download($path, $filename);
|
|
}
|
|
|
|
/**
|
|
* Persist the exact client-rendered QR (qr-code-styling) as the canonical
|
|
* SVG + PNG so downloads match the preview pixel-for-pixel. The browser
|
|
* renders with the real /q/{code} URL on the show page, then posts here.
|
|
*/
|
|
public function storeCanonicalImage(Request $request, QrCode $event): JsonResponse
|
|
{
|
|
$this->authorize('update', $event);
|
|
$qrCode = $event;
|
|
|
|
$validated = $request->validate([
|
|
'svg' => ['required', 'string', 'max:600000'],
|
|
'png' => ['required', 'string', 'max:4000000'],
|
|
]);
|
|
|
|
$svg = $this->imageGenerator->sanitizeSvg($validated['svg']);
|
|
if ($svg === null) {
|
|
return response()->json(['error' => 'Invalid SVG.'], 422);
|
|
}
|
|
|
|
$pngB64 = $validated['png'];
|
|
if (str_contains($pngB64, ',')) {
|
|
$pngB64 = substr($pngB64, strpos($pngB64, ',') + 1);
|
|
}
|
|
$png = base64_decode($pngB64, true);
|
|
if ($png === false || ! $this->imageGenerator->isValidPngBinary($png)) {
|
|
return response()->json(['error' => 'Invalid PNG.'], 422);
|
|
}
|
|
|
|
$basePath = $qrCode->user_id . '/codes/' . $qrCode->id;
|
|
Storage::disk('qr')->put($basePath . '/qr.svg', $svg);
|
|
Storage::disk('qr')->put($basePath . '/qr.png', $png);
|
|
|
|
$qrCode->update([
|
|
'svg_path' => $basePath . '/qr.svg',
|
|
'png_path' => $basePath . '/qr.png',
|
|
]);
|
|
|
|
return response()->json(['ok' => true]);
|
|
}
|
|
}
|