diff --git a/laravel/app/Http/Controllers/Link_interactionController.php b/laravel/app/Http/Controllers/Link_interactionController.php new file mode 100644 index 0000000..e94d4ae --- /dev/null +++ b/laravel/app/Http/Controllers/Link_interactionController.php @@ -0,0 +1,26 @@ +getRecords($id); + return $link_interactions; + } + + public static function access(Request $request) { + $link_interaction = new Link_interaction(); + $link_interaction->create($request->id, $request->ip()); + } + + public static function getCountryArray(string $id) { + $link_interaction = new Link_interaction(); + $country_array = $link_interaction->getCountryArray($id); + return $country_array; + } +} diff --git a/laravel/app/Http/Controllers/RegisterController.php b/laravel/app/Http/Controllers/RegisterController.php index 8fcf21f..b45cd89 100644 --- a/laravel/app/Http/Controllers/RegisterController.php +++ b/laravel/app/Http/Controllers/RegisterController.php @@ -19,7 +19,7 @@ class RegisterController extends Controller { auth()->login($user); - return redirect('/profile'); + return redirect('/home'); } catch (ValidationException $e) { return redirect()->back()->withInput($request->input())->withErrors($e->errors()); } diff --git a/laravel/app/Http/Controllers/ShortlinkController.php b/laravel/app/Http/Controllers/ShortlinkController.php index 132e94b..77426c4 100644 --- a/laravel/app/Http/Controllers/ShortlinkController.php +++ b/laravel/app/Http/Controllers/ShortlinkController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\Link_interaction; use Illuminate\Http\Request; use App\Models\Shortlink; use GuzzleHttp\Client; @@ -45,14 +46,75 @@ class ShortlinkController extends Controller } } - public function goto(Request $request, $id) { + public static function goto(Request $request, $id) { try { $shortlink = (new Shortlink())->get($id); // check if the link is expired or if it has reached the max clicks + if ($shortlink->expires_at != null && strtotime($shortlink->expires_at) < time()) { + return response()->json(['error' => 'This link has expired'], 404); + } + + if ($shortlink->max_clicks != null && $shortlink->max_clicks <= (new Link_interaction())->getTotalInteractions($id)) { + return response()->json(['error' => 'This link has reached the maximum number of clicks'], 404); + } + // log the interaction + [Link_interactionController::class, 'access']($request); + return redirect($shortlink->destination); } catch (\Exception $e) { return response()->json(['error' => $e->getMessage()], 404); } } + + public function getDetails(Request $request, $id) { + try { + $shortlink = (new Shortlink())->get($id); + if ($shortlink->user_id != auth()->id()) { + return response()->json(['error' => 'Unauthorized'], 401); + } + $countrylist = (new Link_interactionController)->getCountryArray($id); + return view('details', ['shortlink' => $shortlink, 'countrylist' => $countrylist]); + } catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 404); + } + } + + public function update(Request $request, $id) { + try { + $shortlink = (new Shortlink())->get($id); + if ($shortlink->user_id != auth()->id()) { + return response()->json(['error' => 'Unauthorized'], 401); + } + $request['expiry-toggle'] = $request->input('expiry-toggle') == 'on'; + $data = $request->validate([ + 'maxclicks' => 'required|integer|min:0', + 'expiry-toggle' => 'required|boolean' + ]); + if ($request->input('expiry-toggle')) { + $request->validate([ + 'expiry-date' => 'date_format:Y-m-d|required', + 'expiry-hour' => 'date_format:H|min:0|max:23|required', + 'expiry-minute' => 'date_format:i|min:0|max:59|required' + ]); + $request->expires_at = \DateTime::createFromFormat('Y-m-d H:i', $request['expiry-date'] . ' ' . $request['expiry-hour'] . ':' . $request['expiry-minute']); + } else { + $request->expires_at = null; + } + $shortlink->modify($request->maxclicks, $request->expires_at); + return back(); + } catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 400); + } + } + + public function delete(Request $request, $id) { + try { + $shortlink = (new Shortlink())->get($id); + $shortlink->delete(); + return redirect('profile'); + } catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 404); + } + } } diff --git a/laravel/app/Models/Link_interaction.php b/laravel/app/Models/Link_interaction.php new file mode 100644 index 0000000..c65c8f6 --- /dev/null +++ b/laravel/app/Models/Link_interaction.php @@ -0,0 +1,93 @@ +link = $link; + $this->ip = $ip; + + $url = "http://ip-api.com/json/$ip?fields=49347"; + $curl = curl_init($url); + curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); + $response = curl_exec($curl); + $data = json_decode($response, true); + + if ($data['status'] == 'fail') { + $this->latitude = null; + $this->longitude = null; + $this->country = null; + $this->country_code = null; + $this->save(); + return $this; + } + + $this->latitude = $data['lat'] ?? null; + $this->longitude = $data['lon'] ?? null; + $this->country = $data['country'] ?? null; + $this->country_code = $data['countryCode'] ?? null; + + $this->save(); + return $this; + } + + public function getRecords(string $link) { + $link_interaction = []; + $link_interaction = Link_interaction::where('link', $link)->get()->toArray(); + return $link_interaction; + } + + public function getCount(string $link) { + $link_interaction = []; + $link_interaction = Link_interaction::where('link', $link)->count(); + return $link_interaction; + } + + public function getCountryArray(string $link) { + $link_interaction = []; + $link_interaction = Link_interaction::where('link', $link)->select('country', Link_interaction::raw('count(*) as total'), 'country_code')->groupBy('country', 'country_code')->get()->toArray(); + $country_list = []; + foreach ($link_interaction as $country) { + if ($country['country_code'] == null) { + $country['emoji'] = '❓'; + } else { + $country['emoji'] = preg_replace_callback('/./', static fn (array $letter) => mb_chr(ord($letter[0]) % 32 + 0x1F1E5), $country['country_code']); + } + array_push($country_list, $country); + } + return $country_list; + } + + public function getTotalInteractions(string $link) : int { + $link_interaction = Link_interaction::where('link', $link)->count(); + return $link_interaction; + } + + public function getTimes(string $link) { + $link_interaction = []; + $link_interaction = Link_interaction::where('link', $link)->select('created_at')->get()->toArray(); + return $link_interaction; + } + + public function getCoordinates(string $link) { + $link_interaction = []; + $link_interaction = Link_interaction::where('link', $link)->select('latitude', 'longitude')->get()->toArray(); + return $link_interaction; + } + +} diff --git a/laravel/app/Models/Shortlink.php b/laravel/app/Models/Shortlink.php index fe5fe02..c25e9df 100644 --- a/laravel/app/Models/Shortlink.php +++ b/laravel/app/Models/Shortlink.php @@ -10,11 +10,13 @@ class Shortlink extends Model use HasFactory; protected $fillable = [ + 'id', 'shortid', 'destination', 'user_id', 'max_clicks', 'expires_at', + 'deleted' ]; public function create(string $url, int $user_id): Shortlink { @@ -23,6 +25,7 @@ class Shortlink extends Model $this->user_id = $user_id; $this->max_clicks = 0; $this->expires_at = null; + $this->deleted = false; $this->save(); return $this; } diff --git a/laravel/database/migrations/2024_06_10_010339_create_shortlinks_table.php b/laravel/database/migrations/2024_06_10_010339_create_shortlinks_table.php index a43a2ed..0026167 100644 --- a/laravel/database/migrations/2024_06_10_010339_create_shortlinks_table.php +++ b/laravel/database/migrations/2024_06_10_010339_create_shortlinks_table.php @@ -12,10 +12,12 @@ return new class extends Migration public function up(): void { Schema::create('shortlinks', function (Blueprint $table) { - $table->string('shortid')->unique()->primary(); + $table->id(); + $table->string('shortid')->unique(); $table->string('destination'); $table->foreignId('user_id')->references('id')->on('users'); $table->integer('max_clicks')->default(0); + $table->boolean('deleted')->default(false); $table->timestamp('expires_at')->nullable(); $table->timestamps(); }); @@ -25,6 +27,10 @@ return new class extends Migration $table->string('link'); $table->foreign('link')->references('shortid')->on('shortlinks'); $table->string('ip'); + $table->decimal('latitude', 10, 8)->nullable(); + $table->decimal('longitude', 11, 8)->nullable(); + $table->string('country')->nullable(); + $table->string('country_code')->nullable(); $table->timestamps(); }); } diff --git a/laravel/public/css/default.css b/laravel/public/css/default.css index 4df7210..0f53747 100644 --- a/laravel/public/css/default.css +++ b/laravel/public/css/default.css @@ -19,6 +19,15 @@ input:focus { outline: none; } +a { + color: var(--primary); + text-decoration: none; +} + +h2 { + margin: 20px 0 0 10px; +} + header { background-color: var(--foreground); padding: 8px; @@ -26,6 +35,15 @@ header { align-items: center; font-size: 1.4em; height: 60px; + box-shadow: 0px 3px 10px 0 #0008; + position: relative; + z-index: 2; +} + +header .container { + display: flex; + justify-content: space-between; + align-items: center; } header a { @@ -57,9 +75,6 @@ header nav a { .container { width: 1100px; margin: auto; - display: flex; - justify-content: space-between; - align-items: center; } .form-container { @@ -95,7 +110,7 @@ header nav a { margin-left: 2px; } -.form-container form button { +.form-container button, button[type="submit"] { margin-block: 10px; padding: 10px 20px; border: none; @@ -106,11 +121,11 @@ header nav a { transition: box-shadow 0.3s ease-out, background-color 0.3s ease-out; } -.form-container button:hover { +.form-container button:hover, button[type="submit"]:hover { box-shadow: 0px 0px 10px 0 #ff005080; } -.form-container button:active { +.form-container button:active, button[type="submit"]:active { background-color: #cc0035; } @@ -122,7 +137,7 @@ header nav a { padding: 10px; } -#shortener-container { +#banner-container { display: flex; flex-direction: column; align-items: center; @@ -132,12 +147,17 @@ header nav a { gap: 40px; } -#shortener-container h1 { +#banner-container a { + color: white; + text-decoration: underline; +} + +#banner-container h1 { font-size: 2.5em; color: white; } -#shortener-container input { +#banner-container input { color: black; background-color: #fff; border-radius: 5px; @@ -149,7 +169,7 @@ header nav a { box-shadow: 2px 2px 5px 0 #0004; } -#shortener-container button { +#banner-container button { font-size: 1.3em; padding: 10px 20px; border: none; @@ -159,6 +179,188 @@ header nav a { background-color: var(--foreground); } -#shortener-container input::placeholder { +#banner-container input::placeholder { color: #aaa; +} + +#banner-container.container { + gap: 0; +} + +#banner-container.container p, #banner-container.container h1 { + margin: 0; +} + +#info-container { + display: grid; + grid-template-columns: 1fr 4fr; + font-size: 1.4em; + gap: 10px; + padding: 10px; +} + +#info-container label { + grid-column: 1; +} + +#info-container p, #info-container a { + grid-column: 2 / span 2; + width: fit-content; +} + + + +#info-container #maxclicks { + width: 80px; + height: 24px; + text-align: right; + font-size: 1em; + background-color: #0000; + color: white; + border: none; + border-bottom: 1px solid var(--primary); +} + +#info-container label { + align-self: center; +} + +#info-container #expiry-toggle { + display: inline; + grid-row: 5; + grid-column: 2; + width: 20px; + height: 20px; + align-self: center; +} + +#info-container #expiry-date { + grid-column: 2; + width: 150px; + grid-row: 5; + margin-left: 95px; +} + +#info-container #date-label { + grid-column: 2; + grid-row: 5; + align-self: center; + color: #aaa; + margin-left: 40px; +} + +#info-container #time-label { + grid-column: 2; + grid-row: 5; + align-self: center; + color: #aaa; + margin-left: 270px; +} + +#info-container #expiry-hour, #info-container #expiry-minute { + margin-left: 330px; + grid-column: 2; + grid-row: 5; + width: 50px; +} + +.info { + height: 25px; + width: 25px; + align-self: center; + margin-left: auto; + margin-right: 50px; +} + +#maxclicks-label { + grid-column: 1; + grid-row: 4; + +} + +#maxclicks-info { + grid-column: 1; + grid-row: 4; +} + +#expiry-label { + grid-column: 1; + grid-row: 5; +} + +#expiry-info { + grid-column: 1; + grid-row: 5; +} + +#info-container #expiry-minute { + margin-left: 390px; +} + +#info-container #time-separator { + font-size: 30px; + grid-column: 2; + grid-row: 5; + color: #aaa; + margin-left: 381px; +} + +#info-container #created { + margin: 0; + display: flex; + flex-direction: column; + grid-column: 2; + grid-row: 1; + &::before { + content: 'Created: '; + font-size: 0.6em; + color: #aaa; + } +} + +#info-container #updated { + margin: 0; + display: flex; + flex-direction: column; + grid-column: 2; + margin-left: 360px; + grid-row: 1; + &::before { + content: 'Updated: '; + font-size: 0.6em; + color: #aaa; + } +} + +#info-container #timestamp-label { + grid-column: 1; + grid-row: 1; + align-self: end; +} + +#info-container #expiry-date, #info-container #expiry-hour, #info-container #expiry-minute { + font-size: 1em; +} + +#info-container button { + grid-row: 6; +} + +#timestamp-label, #destination-label, #URL-label, #maxclicks-label { + &::after { + content: ''; + display: flex; + height: 1px; + background-color: #888; + width: 500%; + transform: translateY(5px); + } +} + +a button { + background: none; + border: none; + align-self: center; + font-size: 0.8em; + cursor: pointer; } \ No newline at end of file diff --git a/laravel/public/img/icons/ainfo.svg b/laravel/public/img/icons/ainfo.svg new file mode 100644 index 0000000..5c44373 --- /dev/null +++ b/laravel/public/img/icons/ainfo.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/laravel/public/img/icons/info.svg b/laravel/public/img/icons/info.svg new file mode 100644 index 0000000..cbc252d --- /dev/null +++ b/laravel/public/img/icons/info.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/laravel/public/js/details.js b/laravel/public/js/details.js new file mode 100644 index 0000000..c046163 --- /dev/null +++ b/laravel/public/js/details.js @@ -0,0 +1,40 @@ +const expiry_checkbox = document.getElementById('expiry-toggle'); + +function toggleExpiryElements() { + const expirydate = document.getElementById('expiry-date'); + const expiryhour = document.getElementById('expiry-hour'); + const expiryminute = document.getElementById('expiry-minute'); + + if (expiry_checkbox.checked) { + expirydate.disabled = false; + expiryhour.disabled = false; + expiryminute.disabled = false; + } else { + expirydate.disabled = true; + expiryhour.disabled = true; + expiryminute.disabled = true; + } +} + +expiry_checkbox.addEventListener('change', toggleExpiryElements); + +toggleExpiryElements(); + +const destination_button = document.getElementById('destination_clipboard'); +const url_button = document.getElementById('url_clipboard'); + +function clipboardButton(event) { + event.preventDefault(); + let button = event.target; + let parent = button.parentElement; + let url = parent.getAttribute('href'); + navigator.clipboard.writeText(url).then(() => { + button.textContent = '✔️'; + setTimeout(() => { + button.textContent = '🔗'; + }, 2000); + }); +} + +destination_button.addEventListener('click', event => clipboardButton(event)); +url_button.addEventListener('click', event => clipboardButton(event)); \ No newline at end of file diff --git a/laravel/resources/views/default.blade.php b/laravel/resources/views/default.blade.php index e1dd455..b72966e 100644 --- a/laravel/resources/views/default.blade.php +++ b/laravel/resources/views/default.blade.php @@ -9,11 +9,11 @@
- + @if (Auth::check()) @else