mirror of https://github.com/realodix/urlhub.git
Major Code Review: Happy New Year 2023 (#877)
parent
bb85b3435b
commit
b59ef9a77e
@ -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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
@ -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.'%';
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|