feat: expand developer REST API, modularize logo controller, and update README for v3.2.0
This commit is contained in:
@@ -4,16 +4,11 @@ namespace Bjanczak\FilamentShortUrl\Http\Controllers;
|
||||
|
||||
use Bjanczak\FilamentShortUrl\Jobs\SendWebhookJob;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrl;
|
||||
use Bjanczak\FilamentShortUrl\Models\ShortUrlPixel;
|
||||
use Bjanczak\FilamentShortUrl\Services\ShortUrlService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class ShortUrlApiController extends Controller
|
||||
{
|
||||
@@ -46,27 +41,7 @@ class ShortUrlApiController extends Controller
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'destination_url' => 'required|url|max:2048',
|
||||
'url_key' => 'nullable|string|alpha_dash|max:50|unique:short_urls,url_key',
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'is_enabled' => 'nullable|boolean',
|
||||
'redirect_status_code' => 'nullable|integer|in:301,302',
|
||||
'single_use' => 'nullable|boolean',
|
||||
'forward_query_params' => 'nullable|boolean',
|
||||
'max_visits' => 'nullable|integer|min:1',
|
||||
'expiration_redirect_url' => 'nullable|url|max:2048',
|
||||
'activated_at' => 'nullable|date',
|
||||
'expires_at' => 'nullable|date',
|
||||
'webhook_url' => 'nullable|url|max:2048',
|
||||
'targeting_rules' => 'nullable|array',
|
||||
'password' => 'nullable|string|max:255',
|
||||
'show_warning_page' => 'nullable|boolean',
|
||||
'track_visits' => 'nullable|boolean',
|
||||
'track_browser_language' => 'nullable|boolean',
|
||||
'pixels' => 'nullable|array',
|
||||
'pixels.*' => 'integer|exists:short_url_pixels,id',
|
||||
]);
|
||||
$validated = $request->validate($this->getValidationRules($request));
|
||||
|
||||
$pixelIds = $validated['pixels'] ?? [];
|
||||
|
||||
@@ -88,12 +63,41 @@ class ShortUrlApiController extends Controller
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified short URL.
|
||||
*/
|
||||
public function show(string|int $idOrKey): JsonResponse
|
||||
{
|
||||
$shortUrl = $this->findLink($idOrKey);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->transformLink($shortUrl),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display statistics/analytics for the specified short URL.
|
||||
*/
|
||||
public function stats(Request $request, string|int $idOrKey): JsonResponse
|
||||
{
|
||||
$shortUrl = $this->findLink($idOrKey);
|
||||
|
||||
$stats = $shortUrl->getCachedStats(
|
||||
dateFrom: $request->query('date_from'),
|
||||
dateTo: $request->query('date_to')
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'data' => $stats,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified short URL.
|
||||
*/
|
||||
public function destroy(int $id): JsonResponse
|
||||
public function destroy(string|int $idOrKey): JsonResponse
|
||||
{
|
||||
$shortUrl = ShortUrl::findOrFail($id);
|
||||
$shortUrl = $this->findLink($idOrKey);
|
||||
$shortUrl->delete();
|
||||
|
||||
return response()->json([
|
||||
@@ -101,6 +105,138 @@ class ShortUrlApiController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified short URL.
|
||||
*/
|
||||
public function update(Request $request, string|int $idOrKey): JsonResponse
|
||||
{
|
||||
$shortUrl = $this->findLink($idOrKey);
|
||||
|
||||
// Merge existing dates for validation if only one of them is sent
|
||||
if ($request->has('expires_at') && ! $request->has('activated_at')) {
|
||||
$request->merge(['activated_at' => $shortUrl->activated_at?->toIso8601String()]);
|
||||
}
|
||||
if ($request->has('activated_at') && ! $request->has('expires_at')) {
|
||||
$request->merge(['expires_at' => $shortUrl->expires_at?->toIso8601String()]);
|
||||
}
|
||||
|
||||
$validated = $request->validate($this->getValidationRules($request, $shortUrl));
|
||||
|
||||
$pixelIds = $validated['pixels'] ?? null;
|
||||
unset($validated['pixels']);
|
||||
|
||||
$shortUrl->update($validated);
|
||||
|
||||
if ($pixelIds !== null) {
|
||||
$shortUrl->pixels()->sync($pixelIds);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Short URL updated successfully.',
|
||||
'data' => $this->transformLink($shortUrl->fresh()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a ShortUrl by database ID or URL key.
|
||||
*/
|
||||
private function findLink(string|int $idOrKey): ShortUrl
|
||||
{
|
||||
if (is_numeric($idOrKey)) {
|
||||
return ShortUrl::findOrFail((int) $idOrKey);
|
||||
}
|
||||
|
||||
$link = ShortUrl::where('url_key', $idOrKey)->first();
|
||||
|
||||
if (! $link) {
|
||||
abort(404, 'Short URL not found.');
|
||||
}
|
||||
|
||||
return $link;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules for creating or updating a short URL.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getValidationRules(Request $request, ?ShortUrl $model = null): array
|
||||
{
|
||||
$countries = __('filament-short-url::countries');
|
||||
$countryRule = is_array($countries) && ! empty($countries)
|
||||
? 'in:'.implode(',', array_merge(array_keys($countries), array_map('strtolower', array_keys($countries))))
|
||||
: 'string|max:10';
|
||||
|
||||
$languages = __('filament-short-url::languages');
|
||||
$languageRule = is_array($languages) && ! empty($languages)
|
||||
? 'in:'.implode(',', array_merge(array_keys($languages), array_map('strtoupper', array_keys($languages))))
|
||||
: 'string|max:10';
|
||||
|
||||
$isUpdate = $model !== null;
|
||||
|
||||
// Apply after_or_equal:today only if the activated_at date is actually being changed
|
||||
$activatedAtRule = 'nullable|date';
|
||||
if ($isUpdate) {
|
||||
if ($request->has('activated_at') && $request->input('activated_at') !== $model->activated_at?->toIso8601String() && $request->input('activated_at') !== $model->activated_at?->toDateTimeString()) {
|
||||
$activatedAtRule .= '|after_or_equal:today';
|
||||
}
|
||||
} else {
|
||||
$activatedAtRule .= '|after_or_equal:today';
|
||||
}
|
||||
|
||||
$uniqueKeyRule = 'unique:short_urls,url_key';
|
||||
if ($isUpdate) {
|
||||
$uniqueKeyRule .= ','.$model->id;
|
||||
}
|
||||
|
||||
return [
|
||||
'destination_url' => ($isUpdate ? 'sometimes|' : '').'required|url|max:2048',
|
||||
'url_key' => ($isUpdate ? 'sometimes|' : '').'nullable|string|alpha_dash|max:32|'.$uniqueKeyRule,
|
||||
'notes' => 'nullable|string|max:1000',
|
||||
'is_enabled' => 'nullable|boolean',
|
||||
'redirect_status_code' => 'nullable|integer|in:301,302',
|
||||
'single_use' => 'nullable|boolean',
|
||||
'forward_query_params' => 'nullable|boolean',
|
||||
'max_visits' => 'nullable|integer|min:1',
|
||||
'expiration_redirect_url' => 'nullable|url|max:2048',
|
||||
'activated_at' => $activatedAtRule,
|
||||
'expires_at' => 'nullable|date|after_or_equal:activated_at',
|
||||
'webhook_url' => 'nullable|url|max:2048',
|
||||
'targeting_rules' => 'nullable|array',
|
||||
'targeting_rules.type' => 'required_with:targeting_rules|string|in:none,device,geo,language,rotation',
|
||||
'targeting_rules.device' => 'nullable|array',
|
||||
'targeting_rules.device.mobile' => 'nullable|url|max:2048',
|
||||
'targeting_rules.device.tablet' => 'nullable|url|max:2048',
|
||||
'targeting_rules.device.desktop' => 'nullable|url|max:2048',
|
||||
'targeting_rules.device.ios' => 'nullable|url|max:2048',
|
||||
'targeting_rules.device.android' => 'nullable|url|max:2048',
|
||||
'targeting_rules.geo' => 'nullable|array',
|
||||
'targeting_rules.geo.*.country_code' => 'required_with:targeting_rules.geo|distinct:ignore_case|'.$countryRule,
|
||||
'targeting_rules.geo.*.url' => 'required_with:targeting_rules.geo|url|max:2048',
|
||||
'targeting_rules.language' => 'nullable|array',
|
||||
'targeting_rules.language.*.language_code' => 'required_with:targeting_rules.language|distinct:ignore_case|'.$languageRule,
|
||||
'targeting_rules.language.*.url' => 'required_with:targeting_rules.language|url|max:2048',
|
||||
'targeting_rules.rotation' => 'nullable|array',
|
||||
'targeting_rules.rotation.*.url' => 'required_with:targeting_rules.rotation|url|max:2048',
|
||||
'targeting_rules.rotation.*.weight' => 'required_with:targeting_rules.rotation|integer|min:1|max:1000',
|
||||
'password' => 'nullable|string|max:255',
|
||||
'show_warning_page' => 'nullable|boolean',
|
||||
'auto_open_app_mobile' => 'nullable|boolean',
|
||||
'ga_tracking_id' => 'nullable|string|regex:/^G-[A-Z0-9]+$/',
|
||||
'track_visits' => 'nullable|boolean',
|
||||
'track_ip_address' => 'nullable|boolean',
|
||||
'track_browser' => 'nullable|boolean',
|
||||
'track_browser_version' => 'nullable|boolean',
|
||||
'track_operating_system' => 'nullable|boolean',
|
||||
'track_operating_system_version' => 'nullable|boolean',
|
||||
'track_device_type' => 'nullable|boolean',
|
||||
'track_referer_url' => 'nullable|boolean',
|
||||
'track_browser_language' => 'nullable|boolean',
|
||||
'pixels' => 'nullable|array',
|
||||
'pixels.*' => 'integer|exists:short_url_pixels,id',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a ShortUrl model to API response array.
|
||||
*/
|
||||
@@ -124,7 +260,16 @@ class ShortUrlApiController extends Controller
|
||||
'targeting_rules' => $link->targeting_rules,
|
||||
'password' => $link->password,
|
||||
'show_warning_page' => (bool) $link->show_warning_page,
|
||||
'auto_open_app_mobile' => (bool) $link->auto_open_app_mobile,
|
||||
'ga_tracking_id' => $link->ga_tracking_id,
|
||||
'track_visits' => (bool) $link->track_visits,
|
||||
'track_ip_address' => (bool) $link->track_ip_address,
|
||||
'track_browser' => (bool) $link->track_browser,
|
||||
'track_browser_version' => (bool) $link->track_browser_version,
|
||||
'track_operating_system' => (bool) $link->track_operating_system,
|
||||
'track_operating_system_version' => (bool) $link->track_operating_system_version,
|
||||
'track_device_type' => (bool) $link->track_device_type,
|
||||
'track_referer_url' => (bool) $link->track_referer_url,
|
||||
'track_browser_language' => (bool) $link->track_browser_language,
|
||||
'pixels' => $pixels->map(fn ($p) => [
|
||||
'id' => $p->id,
|
||||
@@ -172,222 +317,4 @@ class ShortUrlApiController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload QR logo file from admin panel.
|
||||
*/
|
||||
public function uploadLogo(Request $request): JsonResponse
|
||||
{
|
||||
Log::info('ShortUrlApiController::uploadLogo called', [
|
||||
'auth_check' => auth()->check(),
|
||||
'user_id' => auth()->id(),
|
||||
'has_file' => $request->hasFile('logo'),
|
||||
'all_files' => array_keys($request->allFiles()),
|
||||
]);
|
||||
|
||||
if (! auth()->check()) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'logo' => 'required|image|max:10240',
|
||||
]);
|
||||
|
||||
if ($request->hasFile('logo')) {
|
||||
$file = $request->file('logo');
|
||||
$tempPath = $file->getRealPath();
|
||||
$filename = Str::random(40).'.webp';
|
||||
$targetPath = 'short-urls/tmp/'.$filename;
|
||||
|
||||
$processed = $this->processLogo($tempPath, $targetPath);
|
||||
|
||||
if ($processed) {
|
||||
$path = $targetPath;
|
||||
} else {
|
||||
// Fallback: store raw file if image processing fails
|
||||
$path = $file->store('short-urls/tmp', 'public');
|
||||
}
|
||||
|
||||
$url = route('short-url.logo', ['filename' => basename($path)]);
|
||||
|
||||
return response()->json([
|
||||
'path' => $path,
|
||||
'url' => $url,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json(['error' => 'No file uploaded'], 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the uploaded logo: detect available driver and optimize/convert to WebP.
|
||||
*/
|
||||
private function processLogo(string $filePath, string $targetPath): bool
|
||||
{
|
||||
if (extension_loaded('imagick') && class_exists(\Imagick::class)) {
|
||||
return $this->processLogoWithImagick($filePath, $targetPath);
|
||||
}
|
||||
|
||||
if (extension_loaded('gd') && function_exists('gd_info')) {
|
||||
return $this->processLogoWithGd($filePath, $targetPath);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the logo using Imagick: scale down to 800px max edge and convert to WebP.
|
||||
*/
|
||||
private function processLogoWithImagick(string $filePath, string $targetPath): bool
|
||||
{
|
||||
try {
|
||||
$imagick = new \Imagick($filePath);
|
||||
|
||||
// Get original dimensions
|
||||
$width = $imagick->getImageWidth();
|
||||
$height = $imagick->getImageHeight();
|
||||
|
||||
// Calculate new dimensions
|
||||
$maxDim = 800;
|
||||
if ($width > $maxDim || $height > $maxDim) {
|
||||
if ($width > $height) {
|
||||
$newWidth = $maxDim;
|
||||
$newHeight = (int) round(($height * $maxDim) / $width);
|
||||
} else {
|
||||
$newHeight = $maxDim;
|
||||
$newWidth = (int) round(($width * $maxDim) / $height);
|
||||
}
|
||||
$imagick->scaleImage($newWidth, $newHeight);
|
||||
}
|
||||
|
||||
// Convert to WebP format
|
||||
$imagick->setImageFormat('webp');
|
||||
$imagick->setImageCompressionQuality(85);
|
||||
|
||||
// Get image data as blob
|
||||
$webpData = $imagick->getImageBlob();
|
||||
|
||||
// Clear resources
|
||||
$imagick->clear();
|
||||
$imagick->destroy();
|
||||
|
||||
if ($webpData) {
|
||||
return Storage::disk('public')->put($targetPath, $webpData);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// Fall back to GD or raw store
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the uploaded logo using GD: scale down to 800px on the longer side (preserving aspect ratio) and convert to WebP.
|
||||
*/
|
||||
private function processLogoWithGd(string $filePath, string $targetPath): bool
|
||||
{
|
||||
// 1. Get original dimensions and type
|
||||
$info = @getimagesize($filePath);
|
||||
if (! $info) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$width, $height, $type] = $info;
|
||||
|
||||
// 2. Load image based on type
|
||||
switch ($type) {
|
||||
case IMAGETYPE_JPEG:
|
||||
$src = @imagecreatefromjpeg($filePath);
|
||||
break;
|
||||
case IMAGETYPE_PNG:
|
||||
$src = @imagecreatefrompng($filePath);
|
||||
break;
|
||||
case IMAGETYPE_WEBP:
|
||||
$src = @imagecreatefromwebp($filePath);
|
||||
break;
|
||||
case IMAGETYPE_GIF:
|
||||
$src = @imagecreatefromgif($filePath);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $src) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. Calculate new dimensions
|
||||
$maxDim = 800;
|
||||
$newWidth = $width;
|
||||
$newHeight = $height;
|
||||
|
||||
if ($width > $maxDim || $height > $maxDim) {
|
||||
if ($width > $height) {
|
||||
$newWidth = $maxDim;
|
||||
$newHeight = (int) round(($height * $maxDim) / $width);
|
||||
} else {
|
||||
$newHeight = $maxDim;
|
||||
$newWidth = (int) round(($width * $maxDim) / $height);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create new truecolor image
|
||||
$dst = imagecreatetruecolor($newWidth, $newHeight);
|
||||
if (! $dst) {
|
||||
imagedestroy($src);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Preserve transparency for PNG and WebP
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
|
||||
// Resize
|
||||
imagecopyresampled($dst, $src, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
|
||||
|
||||
// 5. Save as WebP
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
ob_start();
|
||||
$saved = imagewebp($dst, null, 85);
|
||||
$webpData = ob_get_clean();
|
||||
|
||||
// Free memory
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
|
||||
if ($saved && $webpData !== false) {
|
||||
return $disk->put($targetPath, $webpData);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the uploaded QR logo.
|
||||
*/
|
||||
public function serveLogo(string $filename): StreamedResponse|BinaryFileResponse
|
||||
{
|
||||
// Prevent directory traversal attacks
|
||||
$filename = basename($filename);
|
||||
if (! preg_match('/^[a-zA-Z0-9_\-]+\.[a-zA-Z0-9]+$/', $filename)) {
|
||||
abort(400, 'Invalid filename');
|
||||
}
|
||||
|
||||
$disk = Storage::disk('public');
|
||||
$path = 'short-urls/logos/'.$filename;
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
$path = 'short-urls/tmp/'.$filename;
|
||||
if (! $disk->exists($path)) {
|
||||
abort(404);
|
||||
}
|
||||
}
|
||||
|
||||
return $disk->response($path, null, [
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Methods' => 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user