Major Code Review: Happy New Year 2023 (#877)

pull/879/head
Kei 3 months ago committed by GitHub
parent bb85b3435b
commit b59ef9a77e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -4,7 +4,6 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
UH_ANONYMIZE_IP_ADDR=true
UH_PUBLIC_SITE=true
UH_REGISTRATION=true
UH_HASH_LENGTH=6

@ -4,7 +4,6 @@ APP_KEY=base64:SsuVzwH9xQnlEqSgTdTbBKyJ0rYrgt5Wr71vv02jDV8=
APP_DEBUG=true
APP_URL=http://localhost
UH_ANONYMIZE_IP_ADDR=true
UH_PUBLIC_SITE=true
UH_REGISTRATION=true
UH_HASH_LENGTH=6

@ -1,65 +0,0 @@
<?php
namespace App\Actions;
use App\Helpers\Helper;
use App\Models\Url;
use App\Models\Visit;
class UrlRedirectAction
{
public function __construct(
public Visit $visit,
) {
}
/**
* Handle the HTTP redirect and return the redirect response.
*
* Redirect client to an existing short URL (no check performed) and
* execute tasks update clicks for short URL.
*
* @param Url $url \App\Models\Url
* @return \Illuminate\Http\RedirectResponse
*/
public function handleHttpRedirect(Url $url)
{
$this->storeVisitStat($url);
$statusCode = (int) config('urlhub.redirect_status_code');
$maxAge = (int) config('urlhub.redirect_cache_max_age');
$headers = [
'Cache-Control' => sprintf('private,max-age=%s', $maxAge),
];
return redirect()->away($url->destination, $statusCode, $headers);
}
/**
* Create visit statistics and store it in the database.
*
* @param Url $url \App\Models\Url
* @return void
*/
private function storeVisitStat(Url $url)
{
$logBotVisit = config('urlhub.track_bot_visits');
if ($logBotVisit === false && \Browser::isBot() === true) {
return;
}
Visit::create([
'url_id' => $url->id,
'url_author_id' => $url->user->id,
'visitor_id' => $this->visit->visitorId(),
'is_first_click' => $this->visit->isFirstClick($url),
'referer' => request()->header('referer'),
'ip' => Helper::anonymizeIp(request()->ip()),
'browser' => \Browser::browserFamily(),
'browser_version' => \Browser::browserVersion(),
'device' => \Browser::deviceType(),
'os' => \Browser::platformFamily(),
'os_version' => \Browser::platformVersion(),
]);
}
}

@ -1,16 +0,0 @@
<?php
use App\Helpers\NumHelper;
if (! function_exists('compactNumber')) {
/**
* \App\Helpers\NumHelper::compactNumber()
*
* @param int $value
* @return int|string
*/
function compactNumber($value)
{
return NumHelper::number_shorten($value);
}
}

@ -5,24 +5,9 @@ namespace App\Helpers;
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;
use Spatie\Url\Url as SpatieUrl;
use Symfony\Component\HttpFoundation\IpUtils;
class Helper
{
/**
* Anonymize an IPv4 or IPv6 address.
*
* @param string|null $address
*/
public static function anonymizeIp($address): string
{
if (config('urlhub.anonymize_ip_addr') === false) {
return $address;
}
return IPUtils::anonymize($address);
}
/**
* Display the link according to what You need.
*

@ -1,63 +0,0 @@
<?php
namespace App\Helpers;
class NumHelper
{
/**
* Convert large positive numbers in to short form like 1K+, 100K+, 199K+, 1M+, 10M+,
* 1B+ etc.
* Based on: ({@link https://gist.github.com/RadGH/84edff0cc81e6326029c}).
*/
public static function number_shorten(int $number): int|string
{
$nFormat = floor($number);
$suffix = '';
if ($number >= pow(10, 3) && $number < pow(10, 6)) {
// 1k-999k
$nFormat = self::numbPrec($number / pow(10, 3));
$suffix = 'K+';
if (($number / pow(10, 3) === 1) || ($number / pow(10, 4) === 1) || ($number / pow(10, 5) === 1)) {
$suffix = 'K';
}
} elseif ($number >= pow(10, 6) && $number < pow(10, 9)) {
// 1m-999m
$nFormat = self::numbPrec($number / pow(10, 6));
$suffix = 'M+';
if (($number / pow(10, 6) === 1) || ($number / pow(10, 7) === 1) || ($number / pow(10, 8) === 1)) {
$suffix = 'M';
}
} elseif ($number >= pow(10, 9) && $number < pow(10, 12)) {
// 1b-999b
$nFormat = self::numbPrec($number / pow(10, 9));
$suffix = 'B+';
if (($number / pow(10, 9) === 1) || ($number / pow(10, 10) === 1) || ($number / pow(10, 11) === 1)) {
$suffix = 'B';
}
} elseif ($number >= pow(10, 12)) {
// 1t+
$nFormat = self::numbPrec($number / pow(10, 12));
$suffix = 'T+';
if (($number / pow(10, 12) === 1) || ($number / pow(10, 13) === 1) || ($number / pow(10, 14) === 1)) {
$suffix = 'T';
}
}
return ! empty($nFormat.$suffix) ? $nFormat.$suffix : 0;
}
/**
* Alternative to make number_format() not to round numbers up.
*
* Based on: (@see https://stackoverflow.com/q/3833137).
*/
public static function numbPrec(float $number, int $precision = 2): float
{
return floor($number * pow(10, $precision)) / pow(10, $precision);
}
}

@ -1,43 +0,0 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreUrl;
use App\Models\Url;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Validator;
class UrlController extends Controller
{
/**
* UrlController constructor.
*/
public function __construct(
public Url $url
) {
$this->middleware('urlhublinkchecker')->only('create');
}
/**
* Store the data the user sent to create the Short URL.
*
* @param StoreUrl $request \App\Http\Requests\StoreUrl
* @return \Illuminate\Http\JsonResponse|\Illuminate\Http\Response
*/
public function store(StoreUrl $request)
{
$v = Validator::make($request->all(), (new StoreUrl)->rules());
if ($v->fails()) {
return response()->json(['errors' => $v->errors()->all()]);
}
$url = $this->url->shortenUrl($request, auth()->id());
return response([
'id' => $url->id,
'long_url' => $url->destination,
'short_url' => url($url->keyword),
], Response::HTTP_CREATED);
}
}

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\Url;
class AllUrlController extends Controller
{
@ -27,10 +28,10 @@ class AllUrlController extends Controller
/**
* Delete a Short URL on user (Admin) request.
*
* @param mixed $url
* @param Url $url \App\Models\Url
* @return \Illuminate\Http\RedirectResponse
*/
public function delete($url)
public function delete(Url $url)
{
$url->delete();

@ -5,7 +5,8 @@ namespace App\Http\Controllers\Dashboard;
use App\Http\Controllers\Controller;
use App\Models\Url;
use App\Models\User;
use App\Models\Visit;
use App\Services\KeyGeneratorService;
use App\Services\UHubLinkService;
use Illuminate\Http\Request;
class DashboardController extends Controller
@ -13,7 +14,7 @@ class DashboardController extends Controller
public function __construct(
public Url $url,
public User $user,
public Visit $visit
public UHubLinkService $uHubLinkService,
) {
}
@ -27,39 +28,37 @@ class DashboardController extends Controller
return view('backend.dashboard', [
'url' => $this->url,
'user' => $this->user,
'visit' => $this->visit,
'keyGeneratorService' => app(KeyGeneratorService::class),
]);
}
/**
* Show shortened url details page
*
* @param mixed $key
* @param string $urlKey A unique key for the shortened URL
* @return \Illuminate\Contracts\View\View
*/
public function edit($key)
public function edit(string $urlKey)
{
$url = Url::whereKeyword($key)->firstOrFail();
$url = Url::whereKeyword($urlKey)->firstOrFail();
$this->authorize('updateUrl', $url);
return view('backend.edit', compact('url'));
return view('backend.edit', ['url' => $url]);
}
/**
* Update the destination URL
*
* @param Request $request \Illuminate\Http\Request
* @param mixed $url
* @param Url $url \App\Models\Url
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function update(Request $request, $url)
public function update(Request $request, Url $url)
{
$url->destination = $request->long_url;
$url->title = $request->title;
$url->save();
$this->uHubLinkService->update($request, $url);
return to_route('dashboard')
->withFlashSuccess(__('Link changed successfully !'));
@ -68,12 +67,12 @@ class DashboardController extends Controller
/**
* Delete shortened URLs
*
* @param mixed $url
* @param Url $url \App\Models\Url
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function delete($url)
public function delete(Url $url)
{
$this->authorize('forceDelete', $url);
@ -84,12 +83,12 @@ class DashboardController extends Controller
}
/**
* @param mixed $key
* @param string $urlKey A unique key for the shortened URL
* @return \Illuminate\Http\RedirectResponse
*/
public function duplicate($key)
public function duplicate($urlKey)
{
$this->url->duplicate($key, auth()->id());
$this->uHubLinkService->duplicate($urlKey);
return redirect()->back()
->withFlashSuccess(__('The link has successfully duplicated.'));

@ -21,7 +21,7 @@ class ChangePasswordController extends Controller
{
$this->authorize('view', $user);
return view('backend.user.changepassword', compact('user'));
return view('backend.user.changepassword', ['user' => $user]);
}
/**

@ -38,7 +38,7 @@ class UserController extends Controller
{
$this->authorize('view', $user);
return view('backend.user.profile', compact('user'));
return view('backend.user.profile', ['user' => $user]);
}
/**

@ -2,9 +2,10 @@
namespace App\Http\Controllers;
use App\Actions\QrCodeAction;
use App\Http\Requests\StoreUrl;
use App\Models\Url;
use App\Services\QrCodeService;
use App\Services\UHubLinkService;
class UrlController extends Controller
{
@ -12,7 +13,8 @@ class UrlController extends Controller
* UrlController constructor.
*/
public function __construct(
public Url $url
public Url $url,
public UHubLinkService $uHubLinkService,
) {
$this->middleware('urlhublinkchecker')->only('create');
}
@ -25,7 +27,7 @@ class UrlController extends Controller
*/
public function create(StoreUrl $request)
{
$url = $this->url->shortenUrl($request, auth()->id());
$url = $this->uHubLinkService->create($request);
return to_route('su_detail', $url->keyword);
}
@ -35,18 +37,21 @@ class UrlController extends Controller
*
* @codeCoverageIgnore
*
* @param string $key
* @param string $urlKey A unique key for the shortened URL
* @return \Illuminate\Contracts\View\View
*/
public function showDetail($key)
public function showDetail(string $urlKey)
{
$url = Url::with('visit')->whereKeyword($key)->firstOrFail();
$data = ['url' => $url, 'visit' => new \App\Models\Visit];
$url = Url::with('visit')->whereKeyword($urlKey)->firstOrFail();
$data = [
'url' => $url,
'visit' => new \App\Models\Visit,
];
if (config('urlhub.qrcode')) {
$qrCode = (new QrCodeAction)->process($url->short_url);
$qrCode = app(QrCodeService::class)->execute($url->short_url);
$data = array_merge($data, compact(['qrCode']));
$data = array_merge($data, ['qrCode' => $qrCode]);
}
return view('frontend.short', $data);
@ -55,12 +60,12 @@ class UrlController extends Controller
/**
* Delete a shortened URL on user request.
*
* @param mixed $url
* @param Url $url \App\Models\Url
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Illuminate\Auth\Access\AuthorizationException
*/
public function delete($url)
public function delete(Url $url)
{
$this->authorize('forceDelete', $url);
@ -74,14 +79,14 @@ class UrlController extends Controller
* link. You can duplicate it and it will generated a new unique random
* key.
*
* @param string $urlKey A unique key for the shortened URL
* @return \Illuminate\Http\RedirectResponse
*/
public function duplicate(string $key)
public function duplicate(string $urlKey)
{
$randomKey = $this->url->randomString();
$this->url->duplicate($key, auth()->id(), $randomKey);
$this->uHubLinkService->duplicate($urlKey);
return to_route('su_detail', $randomKey)
return to_route('su_detail', $this->uHubLinkService->new_keyword)
->withFlashSuccess(__('The link has successfully duplicated.'));
}
}

@ -2,24 +2,38 @@
namespace App\Http\Controllers;
use App\Actions\UrlRedirectAction;
use App\Models\Url;
use App\Services\UrlRedirection;
use App\Services\VisitorService;
use Illuminate\Support\Facades\DB;
class UrlRedirectController extends Controller
{
public function __construct(
public UrlRedirection $urlRedirection,
public VisitorService $visitorService,
) {
}
/**
* Handle the logging of the URL and redirect the user to the intended
* long URL.
* Redirect the client to the intended long URL (no checks are performed)
* and executes the create visitor data task.
*
* @param string $urlKey A unique key for the shortened URL
* @return \Illuminate\Http\RedirectResponse
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException<\App\Models\Url>
*/
public function __invoke(UrlRedirectAction $action, string $key)
public function __invoke(string $urlKey)
{
return DB::transaction(function () use ($action, $key) {
$url = Url::whereKeyword($key)->firstOrFail();
return DB::transaction(function () use ($urlKey) {
// firstOrFail() will throw a ModelNotFoundException if the URL is not
// found and 404 will be returned to the client.
$url = Url::whereKeyword($urlKey)->firstOrFail();
$this->visitorService->create($url);
return $action->handleHttpRedirect($url);
return $this->urlRedirection->execute($url);
});
}
}

@ -4,7 +4,6 @@ namespace App\Http\Livewire\Table;
use App\Helpers\Helper;
use App\Models\Url;
use App\Models\Visit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Str;
@ -106,10 +105,9 @@ final class AllUlrTable extends PowerGridComponent
.Blade::render('@svg(\'icon-open-in-new\', \'!h-[0.7em] ml-1\')').
'</a>';
})
->addColumn('click', function (Url $url) {
$visit = new Visit;
$uClick = Helper::compactNumber($visit->totalClickPerUrl($url->id, unique: true));
$tClick = Helper::compactNumber($visit->totalClickPerUrl($url->id));
->addColumn('t_clicks', function (Url $url) {
$uClick = Helper::compactNumber($url->uniqueClicks);
$tClick = Helper::compactNumber($url->clicks);
$icon = Blade::render('@svg(\'icon-bar-chart\', \'ml-2 text-indigo-600\')');
$title = $uClick.' '.__('Uniques').' / '.$tClick.' '.__('Clicks');
@ -186,7 +184,7 @@ final class AllUlrTable extends PowerGridComponent
Column::add()
->title('CLICKS')
->field('click'),
->field('t_clicks'),
Column::add()
->title('CREATED AT')

@ -4,7 +4,6 @@ namespace App\Http\Livewire\Table;
use App\Helpers\Helper;
use App\Models\Url;
use App\Models\Visit;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Str;
@ -101,10 +100,9 @@ final class MyUrlTable extends PowerGridComponent
.Blade::render('@svg(\'icon-open-in-new\', \'!h-[0.7em] ml-1\')').
'</a>';
})
->addColumn('click', function (Url $url) {
$visit = new Visit;
$uClick = Helper::compactNumber($visit->totalClickPerUrl($url->id, unique: true));
$tClick = Helper::compactNumber($visit->totalClickPerUrl($url->id));
->addColumn('t_clicks', function (Url $url) {
$uClick = Helper::compactNumber($url->uniqueClicks);
$tClick = Helper::compactNumber($url->clicks);
$icon = Blade::render('@svg(\'icon-bar-chart\', \'ml-2 text-indigo-600\')');
$title = $uClick.' '.__('Uniques').' / '.$tClick.' '.__('Clicks');
@ -175,7 +173,7 @@ final class MyUrlTable extends PowerGridComponent
Column::add()
->title('CLICKS')
->field('click'),
->field('t_clicks'),
Column::add()
->title('CREATED AT')

@ -3,22 +3,18 @@
namespace App\Http\Middleware;
use App\Models\Url;
use App\Services\KeyGeneratorService;
use Illuminate\Http\Request;
use Illuminate\Routing\Route;
class UrlHubLinkChecker
{
public function __construct(
public Url $url
) {
}
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @return mixed
* @return \Illuminate\Http\Response|\Illuminate\Http\RedirectResponse
*/
public function handle($request, \Closure $next)
public function handle(Request $request, \Closure $next)
{
if ($this->customKeywordIsAcceptable($request) === false) {
return redirect()->back()
@ -47,10 +43,8 @@ class UrlHubLinkChecker
*
* - Prevent registered routes from being used as custom keywords.
* - Prevent using blacklisted words or reserved keywords as custom keywords.
*
* @param \Illuminate\Http\Request $request
*/
private function customKeywordIsAcceptable($request): bool
private function customKeywordIsAcceptable(Request $request): bool
{
$value = $request->custom_key;
$routes = array_map(
@ -74,7 +68,7 @@ class UrlHubLinkChecker
*/
private function canGenerateUniqueRandomKeys(): bool
{
if ($this->url->keyRemaining() === 0) {
if (app(KeyGeneratorService::class)->idleCapacity() === 0) {
return false;
}
@ -83,10 +77,8 @@ class UrlHubLinkChecker
/**
* Check if a destination URL already exists in the database.
*
* @param \Illuminate\Http\Request $request
*/
private function destinationUrlAlreadyExists($request): Url|null
private function destinationUrlAlreadyExists(Request $request): Url|null
{
$longUrl = rtrim($request->long_url, '/'); // Remove trailing slash

@ -21,7 +21,7 @@ class StoreUrl extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array
* @return array<string, mixed>
*/
public function rules()
{
@ -34,7 +34,7 @@ class StoreUrl extends FormRequest
/**
* Get the error messages for the defined validation rules.
*
* @return array
* @return array<string, mixed>
*/
public function messages()
{

@ -19,7 +19,7 @@ class UpdateUserEmail extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array
* @return array<string, mixed>
*/
public function rules()
{

@ -20,7 +20,7 @@ class UpdateUserPassword extends FormRequest
/**
* Get the validation rules that apply to the request.
*
* @return array
* @return array<string, mixed>
*/
public function rules()
{

@ -2,21 +2,17 @@
namespace App\Models;
use App\Helpers\Helper;
use App\Http\Requests\StoreUrl;
use App\Models\Traits\Hashidable;
use Embed\Embed;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
use Spatie\Url\Url as SpatieUrl;
/**
* @property int|null $user_id
* @property string $short_url
* @property string $destination
* @property string $title
* @property int $clicks
* @property int $uniqueClicks
*/
class Url extends Model
{
@ -89,7 +85,7 @@ class Url extends Model
protected function shortUrl(): Attribute
{
return Attribute::make(
get: fn ($value, $attributes) => url('/'.$attributes['keyword']),
get: fn ($value, $attr) => url('/'.$attr['keyword']),
);
}
@ -100,20 +96,17 @@ class Url extends Model
);
}
protected function title(): Attribute
protected function clicks(): Attribute
{
return Attribute::make(
set: function ($value) {
if (config('urlhub.web_title')) {
if (Str::startsWith($value, 'http')) {
return $this->getWebTitle($value);
}
return $value;
}
get: fn ($value, $attr) => $this->numberOfClicks($attr['id']),
);
}
return 'No Title';
},
protected function uniqueClicks(): Attribute
{
return Attribute::make(
get: fn ($value, $attr) => $this->numberOfClicks($attr['id'], unique: true),
);
}
@ -124,214 +117,70 @@ class Url extends Model
*/
/**
* @param StoreUrl $request \App\Http\Requests\StoreUrl
* @param int|string|null $userId Jika user_id tidak diisi, maka akan diisi
* null. Ini terjadi karena guest yang membuat
* URL. Lihat setUserIdAttribute().
* @return self
*/
public function shortenUrl(StoreUrl $request, $userId)
{
$key = $request->custom_key ?? $this->urlKey($request->long_url);
return Url::create([
'user_id' => $userId,
'destination' => $request->long_url,
'title' => $request->long_url,
'keyword' => $key,
'is_custom' => $request->custom_key ? true : false,
'ip' => Helper::anonymizeIp($request->ip()),
]);
}
/**
* @param int|string|null $userId \Illuminate\Contracts\Auth\Guard::id()
* @return bool \Illuminate\Database\Eloquent\Model::save()
*/
public function duplicate(string $key, $userId, string $randomKey = null)
{
$randomKey = $randomKey ?? $this->randomString();
$shortenedUrl = self::whereKeyword($key)->firstOrFail();
$replicate = $shortenedUrl->replicate()->fill([
'user_id' => $userId,
'keyword' => $randomKey,
'is_custom' => false,
]);
return $replicate->save();
}
public function urlKey(string $url): string
{
$length = config('urlhub.hash_length') * -1;
// Step 1
// Take a few characters at the end of the string to use as a unique key
$pattern = '/[^'.config('urlhub.hash_char').']/i';
$urlKey = substr(preg_replace($pattern, '', $url), $length);
// Step 2
// If step 1 fails (the already used or cannot be used), then the generator
// must generate a unique random string
if ($this->keyExists($urlKey)) {
$urlKey = $this->randomString();
}
return $urlKey;
}
/**
* Periksa apakah keyword tersedia atau tidak?
*
* Syarat keyword tersedia:
* - Tidak ada di database
* - Tidak ada di daftar config('urlhub.reserved_keyword')
* - Tidak digunakan oleh sistem sebagai rute
* The number of shortened URLs that have been created by each User
*/
private function keyExists(string $url): bool
public function numberOfUrls(int $userId): int
{
$route = \Illuminate\Routing\Route::class;
$routeCollection = \Illuminate\Support\Facades\Route::getRoutes()->get();
$routePath = array_map(fn ($route) => $route->uri, $routeCollection);
$isExistsInDb = self::whereKeyword($url)->first();
$isReservedKeyword = in_array($url, config('urlhub.reserved_keyword'));
$isRegisteredRoutePath = in_array($url, $routePath);
if ($isExistsInDb || $isReservedKeyword || $isRegisteredRoutePath) {
return true;
}
return false;
return self::whereUserId($userId)->count();
}
/**
* The number of unique random strings that have been used as the key for
* the long url that has been shortened
*
* Calculation formula:
* keyUsed = randomKey + customKey
*
* The character length of the generated for `customKey` should be similar
* to `randomKey`
* The total number of shortened URLs that have been created by guests
*/
public function keyUsed(): int
public function numberOfUrlsByGuests(): int
{
$hashLength = (int) config('urlhub.hash_length');
$regexPattern = '['.config('urlhub.hash_char').']{'.$hashLength.'}';
$randomKey = self::whereIsCustom(false)
->whereRaw('LENGTH(keyword) = ?', [$hashLength])
->count();
$customKey = self::whereIsCustom(true)
->whereRaw('LENGTH(keyword) = ?', [$hashLength])
->whereRaw("keyword REGEXP '".$regexPattern."'")
->count();
return $randomKey + $customKey;
return self::whereNull('user_id')->count();
}
/**
* Calculate the maximum number of unique random strings that can be
* generated
* Total shortened URLs created
*/
public function keyCapacity(): int
public function totalUrl(): int
{
$alphabet = strlen(config('urlhub.hash_char'));
$length = config('urlhub.hash_length');
// for testing purposes only
// tests\Unit\Middleware\UrlHubLinkCheckerTest.php
if ($length === 0) {
return 0;
}
return (int) pow($alphabet, $length);
return self::count();
}
/**
* Count unique random strings that can be generated
* Total clicks on each shortened URLs
*/
public function keyRemaining(): int
{
$keyCapacity = $this->keyCapacity();
$keyUsed = $this->keyUsed();
return max($keyCapacity - $keyUsed, 0);
}
public function keyRemainingInPercent(int $precision = 2): string
public function numberOfClicks(int $urlId, bool $unique = false): int
{
$capacity = $this->keyCapacity();
$remaining = $this->keyRemaining();
$result = round(($remaining / $capacity) * 100, $precision);
$lowerBoundInPercent = 1 / (10 ** $precision);
$upperBoundInPercent = 100 - $lowerBoundInPercent;
$lowerBound = $lowerBoundInPercent / 100;
$upperBound = 1 - $lowerBound;
$total = self::find($urlId)->visit()->count();
if ($remaining > 0 && $remaining < ($capacity * $lowerBound)) {
$result = $lowerBoundInPercent;
} elseif (($remaining > ($capacity * $upperBound)) && ($remaining < $capacity)) {
$result = $upperBoundInPercent;
if ($unique) {
$total = self::find($urlId)->visit()
->whereIsFirstClick(true)
->count();
}
return $result.'%';
return $total;
}
/**
* Count the number of URLs based on user id.
*
* @param int|string|null $userId
* Total clicks on all short URLs on each user
*/
public function urlCount($userId = null): int
public function numberOfClicksPerUser(int $userId = null): int
{
return self::whereUserId($userId)->count('keyword');
}
$url = self::whereUserId($userId)->get();
public function totalUrl(): int
{
return self::count('keyword');
return $url->sum(fn ($url) => $url->numberOfClicks($url->id));
}
/**
* Fetch the page title from the web page URL
*
* @throws \Exception
* Total clicks on all short URLs from guest users
*/
public function getWebTitle(string $url): string
public function numberOfClicksFromGuests(): int
{
$spatieUrl = SpatieUrl::fromString($url);
$defaultTitle = $spatieUrl->getHost().' - Untitled';
try {
$webTitle = (new Embed)->get($url)->title ?? $defaultTitle;
} catch (\Exception) {
// If failed or not found, then return "{domain_name} - Untitled"
$webTitle = $defaultTitle;
}
$url = self::whereNull('user_id')->get();
return $webTitle;
return $url->sum(fn ($url) => $url->numberOfClicks($url->id));
}
/**
* @return string
* Total clicks on all shortened URLs
*/
public function randomString()
public function totalClick(): int
{
$factory = new \RandomLib\Factory;
$generator = $factory->getMediumStrengthGenerator();
$character = config('urlhub.hash_char');
$length = config('urlhub.hash_length');
do {
$urlKey = $generator->generateString($length, $character);
} while ($this->keyExists($urlKey));
return $urlKey;
return Visit::count();
}
}

@ -64,11 +64,16 @@ class User extends Authenticatable
|--------------------------------------------------------------------------
*/
public function totalUsers(): int
{
return self::count();
}
/*
* Count the number of guests (URL without user id) by IP address, then
* grouped by IP address.
*/
public function guestCount(): int
public function totalGuestUsers(): int
{
$url = Url::select('ip', DB::raw('count(*) as total'))
->whereNull('user_id')->groupBy('ip')

@ -16,7 +16,6 @@ class Visit extends Model
*/
protected $fillable = [
'url_id',
'url_author_id',
'visitor_id',
'is_first_click',
'referer',
@ -50,70 +49,4 @@ class Visit extends Model
{
return $this->belongsTo(Url::class);
}
/*
|--------------------------------------------------------------------------
| General Functions
|--------------------------------------------------------------------------
*/
/**
* Generate unique Visitor Id
*/
public function visitorId(): string
{
$neighborVisitor = [
'ip' => request()->ip(),
'browser' => \Browser::browserFamily(),
'os' => \Browser::platformFamily(),
];
$visitorId = hash('sha3-256', implode($neighborVisitor));
if (auth()->check() === true) {
$visitorId = (string) auth()->id();
}
return $visitorId;
}
public function isFirstClick(Url $url): bool
{
$hasVisited = Visit::whereVisitorId($this->visitorId())
->whereUrlId($url->id)
->first();
return $hasVisited ? false : true;
}
/**
* total visit
*/
public function totalClick(): int
{
return self::count();
}
/**
* Total visit by user id
*/
public function totalClickPerUser(int $authorId = null): int
{
return self::whereUrlAuthorId($authorId)->count();
}
/**
* Total visit by URL id
*/
public function totalClickPerUrl(int $urlId, bool $unique = false): int
{
$total = self::whereUrlId($urlId)->count();
if ($unique) {
$total = self::whereUrlId($urlId)
->whereIsFirstClick(true)
->count();
}
return $total;
}
}

@ -2,8 +2,8 @@
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Models\User;
use App\Services\Fortify\CreateNewUser;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;

@ -2,8 +2,8 @@
namespace App\Rules\Url;
use App\Services\KeyGeneratorService;
use Illuminate\Contracts\Validation\InvokableRule;
use Illuminate\Routing\Route;
/**
* Check if keyword id is free (ie not already taken, not a URL path, and not
@ -21,28 +21,10 @@ class KeywordBlacklist implements InvokableRule
*/
public function __invoke($attribute, $value, $fail)
{
if (! $this->isProbihitedKeyword($value)) {
$fail('Not available.');
}
}
/**
* @param mixed $value
*/
protected function isProbihitedKeyword($value): bool
{
$routes = array_map(
fn (Route $route) => $route->uri,
\Route::getRoutes()->get()
);
$isReservedKeyword = in_array($value, config('urlhub.reserved_keyword'), true);
$isRegisteredRoute = in_array($value, $routes);
$stringCanBeUsedAsKey = app(KeyGeneratorService::class)->assertStringCanBeUsedAsKey($value);
if ($isRegisteredRoute || $isReservedKeyword) {
return false;
if ($stringCanBeUsedAsKey === false) {
$fail('Not available.');
}
return true;
}
}

@ -1,6 +1,6 @@
<?php
namespace App\Actions\Fortify;
namespace App\Services\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;

@ -0,0 +1,179 @@
<?php
namespace App\Services;
use App\Models\Url;
class KeyGeneratorService
{
/**
* Generate a short string that can be used as a unique key for shortened long
* urls.
*
* @return string A unique string to use as the short url key
*/
public function urlKey(string $value): string
{
// Step 1
$key = $this->generateSimpleString($value);
// Step 2
// If step 1 fail (the string is used or cannot be used), then the generator
// must generate a unique random string until it finds a string that can
// be used as a key
if ($this->assertStringCanBeUsedAsKey($key) === false) {
$key = $this->generateRandomString();
}
return $key;
}
/**
* Take some characters at the end of the string and remove all characters that
* are not in the specified character set.
*/
public function generateSimpleString(string $value): string
{
$length = config('urlhub.hash_length') * -1;
$pattern = '/[^'.config('urlhub.hash_char').']/i';
return substr(preg_replace($pattern, '', $value), $length);
}
/**
* Generate a random string of specified length. The string will only contain
* characters from the specified character set.
*
* @return string The generated random string
*/
public function generateRandomString()
{
$factory = new \RandomLib\Factory;
$generator = $factory->getMediumStrengthGenerator();
$characters = config('urlhub.hash_char');
$length = config('urlhub.hash_length');
do {
$urlKey = $generator->generateString($length, $characters);
} while ($this->assertStringCanBeUsedAsKey($urlKey) === false);
return $urlKey;
}
/**
* Check if string can be used as a keyword.
*
* This function will check under several conditions:
* 1. If the string is already used in the database
* 2. If the string is used as a reserved keyword
* 3. If the string is used as a route path
*
* If any or all of the conditions are true, then the keyword cannot be used.
*/
public function assertStringCanBeUsedAsKey(string $value): bool
{
$route = \Illuminate\Routing\Route::class;
$routeCollection = \Illuminate\Support\Facades\Route::getRoutes()->get();
$routePath = array_map(fn ($route) => $route->uri, $routeCollection);
$isExistsInDb = Url::whereKeyword($value)->exists();
$isReservedKeyword = in_array($value, config('urlhub.reserved_keyword'));
$isRegisteredRoutePath = in_array($value, $routePath);
if ($isExistsInDb || $isReservedKeyword || $isRegisteredRoutePath) {
return false;
}
return true;
}
/*
|--------------------------------------------------------------------------
| Capacity calculation
|--------------------------------------------------------------------------
*/
/**
* Calculate the maximum number of unique random strings that can be
* generated
*/
public function maxCapacity(): int
{
$characters = strlen(config('urlhub.hash_char'));
$length = config('urlhub.hash_length');
// for testing purposes only
// tests\Unit\Middleware\UrlHubLinkCheckerTest.php
if ($length === 0) {
return 0;
}
return (int) pow($characters, $length);
}
/**
* The number of unique random strings that have been used as the key for
* the long url that has been shortened
*
* Formula:
* usedCapacity = randomKey + customKey
*
* The character length and set of characters of `customKey` must be the same
* as `randomKey`.
*/
public function usedCapacity(): int
{
$hashLength = (int) config('urlhub.hash_length');
$regexPattern = '['.config('urlhub.hash_char').']{'.$hashLength.'}';
$randomKey = Url::whereIsCustom(false)
->whereRaw('LENGTH(keyword) = ?', [$hashLength])
->count();
$customKey = Url::whereIsCustom(true)
->whereRaw('LENGTH(keyword) = ?', [$hashLength])
->whereRaw("keyword REGEXP '".$regexPattern."'")
->count();
return $randomKey + $customKey;
}
/**
* Calculate the number of unique random strings that can still be generated.
*/
public function idleCapacity(): int
{
$maxCapacity = $this->maxCapacity();
$usedCapacity = $this->usedCapacity();
// prevent negative values
return max($maxCapacity - $usedCapacity, 0);
}
/**
* Calculate the percentage of the remaining unique random strings that can
* be generated from the total number of unique random strings that can be
* generated (in percent) with the specified precision (in decimal places)
* and return the result as a string.
*/
public function idleCapacityInPercent(int $precision = 2): string
{
$maxCapacity = $this->maxCapacity();
$remaining = $this->idleCapacity();
$result = round(($remaining / $maxCapacity) * 100, $precision);
$lowerBoundInPercent = 1 / (10 ** $precision);
$upperBoundInPercent = 100 - $lowerBoundInPercent;
$lowerBound = $lowerBoundInPercent / 100;
$upperBound = 1 - $lowerBound;
if ($remaining > 0 && $remaining < ($maxCapacity * $lowerBound)) {
$result = $lowerBoundInPercent;
} elseif (($remaining > ($maxCapacity * $upperBound)) && ($remaining < $maxCapacity)) {
$result = $upperBoundInPercent;
}
return $result.'%';
}
}

@ -1,11 +1,11 @@
<?php
namespace App\Actions;
namespace App\Services;
use Endroid\QrCode\Builder\Builder;
use Endroid\QrCode\Writer\Result\ResultInterface;
class QrCodeAction
class QrCodeService
{
const MIN_SIZE = 50;
@ -15,7 +15,7 @@ class QrCodeAction
const SUPPORTED_FORMAT = ['png', 'svg'];
public function process(string $data): ResultInterface
public function execute(string $data): ResultInterface
{
return Builder::create()
->data($data)

@ -0,0 +1,106 @@
<?php
namespace App\Services;
use App\Http\Requests\StoreUrl;
use App\Models\Url;
use Embed\Embed;
use Illuminate\Http\Request;
use Spatie\Url\Url as SpatieUrl;
class UHubLinkService
{
/** @readonly */
public string $new_keyword;
public function __construct(
public KeyGeneratorService $keyGeneratorService,
) {
$this->new_keyword = $keyGeneratorService->generateRandomString();
}
/**
* Create a shortened URL.
*
* @param StoreUrl $request \App\Http\Requests\StoreUrl
*/
public function create(StoreUrl $request): Url
{
return Url::create([
'user_id' => auth()->id(),
'destination' => $request->long_url,
'title' => $this->title($request->long_url),
'keyword' => $this->urlKey($request),
'is_custom' => $this->isCustom($request),
'ip' => $request->ip(),
]);
}
/**
* Update the shortened URL details.
*
* @param Request $request \Illuminate\Http\Request
* @return bool \Illuminate\Database\Eloquent\Model::update()
*/
public function update(Request $request, Url $url)
{
return $url->update([
'destination' => $request->long_url,
'title' => $request->title,
]);
}
/**
* Duplicate the existing URL and create a new shortened URL.
*
* @param string $urlKey A unique key for the shortened URL
* @return bool \Illuminate\Database\Eloquent\Model::save()
*/
public function duplicate(string $urlKey)
{
$shortenedUrl = Url::whereKeyword($urlKey)->first();
$replicate = $shortenedUrl->replicate()->fill([
'user_id' => auth()->id(),
'keyword' => $this->new_keyword,
'is_custom' => false,
]);
return $replicate->save();
}
/**
* Fetch the page title from the web page URL
*
* @throws \Exception
*/
public function title(string $webAddress): string
{
$spatieUrl = SpatieUrl::fromString($webAddress);
$defaultTitle = $spatieUrl->getHost().' - Untitled';
if (config('urlhub.web_title')) {
try {
$title = app(Embed::class)->get($webAddress)->title ?? $defaultTitle;
} catch (\Exception) {
// If failed or not found, then return "{domain_name} - Untitled"
$title = $defaultTitle;
}
return $title;
}
return 'No Title';
}
private function urlKey(StoreUrl $request): string
{
return $request->custom_key ??
$this->keyGeneratorService->urlKey($request->long_url);
}
private function isCustom(StoreUrl $request): bool
{
return $request->custom_key ? true : false;
}
}

@ -0,0 +1,25 @@
<?php
namespace App\Services;
use App\Models\Url;
class UrlRedirection
{
/**
* Execute the HTTP redirect and return the redirect response.
*
* @param Url $url \App\Models\Url
* @return \Illuminate\Http\RedirectResponse
*/
public function execute(Url $url)
{
$statusCode = (int) config('urlhub.redirect_status_code');
$maxAge = (int) config('urlhub.redirect_cache_max_age');
$headers = [
'Cache-Control' => sprintf('private,max-age=%s', $maxAge),
];
return redirect()->away($url->destination, $statusCode, $headers);
}
}

@ -0,0 +1,89 @@
<?php
namespace App\Services;
use App\Models\Url;
use App\Models\Visit;
class VisitorService
{
/**
* Store the visitor data.
*
* @param Url $url \App\Models\Url
* @return void
*/
public function create(Url $url)
{
$logBotVisit = config('urlhub.track_bot_visits');
if ($logBotVisit === false && \Browser::isBot() === true) {
return;
}
Visit::create([
'url_id' => $url->id,
'visitor_id' => $this->visitorId(),
'is_first_click' => $this->isFirstClick($url),
'referer' => request()->header('referer'),
'ip' => request()->ip(),
'browser' => \Browser::browserFamily(),
'browser_version' => \Browser::browserVersion(),
'device' => \Browser::deviceType(),
'os' => \Browser::platformFamily(),
'os_version' => \Browser::platformVersion(),
]);
}
/**
* Generate unique Visitor Id
*/
public function visitorId(): string
{
$visitorId = $this->authVisitorId();
if ($this->isAnonymousVisitor()) {
$visitorId = $this->anonymousVisitorId();
}
return $visitorId;
}
public function authVisitorId(): string
{
return (string) auth()->id();
}
public function anonymousVisitorId(): string
{
$data = [
'ip' => request()->ip(),
'browser' => \Browser::browserFamily(),
'os' => \Browser::platformFamily(),
];
return sha1(implode($data));
}
/**
* Check if the visitor is an anonymous (unauthenticated) visitor.
*/
public function isAnonymousVisitor(): bool
{
return auth()->check() === false;
}
/**
* Check if the visitor has clicked the link before. If the visitor has not
* clicked the link before, return true.
*
* @param Url $url \App\Models\Url
*/
public function isFirstClick(Url $url): bool
{
$hasVisited = Visit::whereUrlId($url->id)
->whereVisitorId($this->visitorId())
->exists();
return $hasVisited ? false : true;
}
}