Compare commits
13 Commits
bugfixes/e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c68531139a | |||
| da4bdc6bf4 | |||
| a80945bf0b | |||
| 03b06632f9 | |||
| f7c867e13c | |||
| 2f2b7866c6 | |||
| f85b596a66 | |||
| 9b8c273c21 | |||
| bbfc64031f | |||
| fdc10a0acb | |||
| c60738b532 | |||
| 38943743aa | |||
| 866118a86f |
123
README.md
123
README.md
@@ -1,79 +1,106 @@
|
||||
# Interactieve Laravel CV Applicatie
|
||||
# CV Roberto
|
||||
|
||||
Deze Laravel-applicatie is gebouwd als mijn interactieve en dynamische cv. In plaats van een statisch pdf-bestand, kun je hier real-time mijn:
|
||||
Dit project is mijn interactieve CV en tegelijk een voorbeeld van hoe ik een Laravel-app opzet.
|
||||
|
||||
- **Vaardigheden** (met beoordeling en iconen)
|
||||
- **Werkervaring**
|
||||
- **Opleidingen**
|
||||
- **Persoonlijke gegevens**
|
||||
- **Tags & kernkwaliteiten**
|
||||
Live: [cv.robert.ooo](https://cv.robert.ooo)
|
||||
|
||||
zien — inclusief slimme automatisering, logging en Telegram-notificaties voor recruiterinteracties.
|
||||
## Wat zit erin
|
||||
|
||||
---
|
||||
- Publieke CV-pagina met werkervaring, opleidingen, skills en personalia.
|
||||
- Adminomgeving om CV-data te beheren.
|
||||
- Afbeeldingsuploads via Spatie Media Library.
|
||||
- Contactformulier met queue job voor Telegram-notificaties.
|
||||
- Klikbare verborgen personalia, zodat bots de waarde niet direct in de HTML zien.
|
||||
- Feature tests voor de belangrijkste controllerflows.
|
||||
- CI-checks voor tests, Larastan en formatting.
|
||||
|
||||
## 🧰 Techniek & Stack
|
||||
## Stack
|
||||
|
||||
- **Framework:** Laravel 12
|
||||
- **Frontend:** Tailwind CSS, Blade
|
||||
- **DevOps-integraties:** Telegram alerts, Healthchecks, custom logging
|
||||
- **CI/CD-ready:** Ondersteuning voor deploy hooks en jobs
|
||||
- **Overige tools:** Docker, Nginx, Git, Redis, Cron, Promtail, Grafana
|
||||
- Laravel 12
|
||||
- PHP 8.2+
|
||||
- Blade
|
||||
- Tailwind CSS
|
||||
- Pest
|
||||
- Larastan level 7
|
||||
- Laravel Pint
|
||||
- Blade Formatter
|
||||
- Docker / Laravel Sail
|
||||
|
||||
---
|
||||
## Kwaliteitschecks
|
||||
|
||||
## ⚙️ Installatie
|
||||
|
||||
1. **Clone deze repo:**
|
||||
Deze checks horen groen te zijn voordat een merge logisch is:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/roberto-guagliardo/cv-app.git
|
||||
cd cv-app
|
||||
composer test
|
||||
composer analyse
|
||||
composer format:check
|
||||
npm run format:check
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. **Installeer dependencies:**
|
||||
In de workflow worden dezelfde checks afgedwongen:
|
||||
|
||||
```bash
|
||||
composer install
|
||||
npm install && npm run build
|
||||
```
|
||||
- PHPUnit/Pest feature tests
|
||||
- Larastan op level 7
|
||||
- PHP formatting via Pint
|
||||
- Blade formatting via Blade Formatter
|
||||
|
||||
3. **Maak .env aan en configureer je database, storage en Telegram:**
|
||||
Skipped tests zijn alleen acceptabel als dat bewust is, zoals disabled registratieflows. Larastan en formatting moeten gewoon groen zijn.
|
||||
|
||||
## Lokaal draaien
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
composer install
|
||||
npm ci
|
||||
php artisan key:generate
|
||||
```
|
||||
|
||||
4. **Voer migraties & seeders uit:**
|
||||
|
||||
```bash
|
||||
php artisan migrate --seed
|
||||
```
|
||||
|
||||
5. **Geniet van de app:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
php artisan serve
|
||||
```
|
||||
|
||||
---
|
||||
Met Sail:
|
||||
|
||||
## 🌐 Live Demo
|
||||
```bash
|
||||
cp .env.example .env
|
||||
./vendor/bin/sail up -d
|
||||
./vendor/bin/sail artisan key:generate
|
||||
./vendor/bin/sail artisan migrate --seed
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
Wil je zien hoe het eruitziet? Bekijk mijn live cv op:
|
||||
Voor de admin login kun je in `.env` deze waardes zetten en daarna opnieuw seeden:
|
||||
|
||||
➡️ [cv.robert.ooo](https://cv.robert.ooo)
|
||||
```env
|
||||
ADMIN_NAME="Admin"
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=changeme123
|
||||
```
|
||||
|
||||
---
|
||||
## Development
|
||||
|
||||
## 📊 Bijdragen
|
||||
PHP formatter:
|
||||
|
||||
Deze applicatie is persoonlijk en niet bedoeld voor publieke bijdragen, maar voel je vrij om de structuur of ideeën te gebruiken voor je eigen showcase-app.
|
||||
```bash
|
||||
composer format
|
||||
composer format:check
|
||||
```
|
||||
|
||||
---
|
||||
Blade formatter:
|
||||
|
||||
## 💌 Contact
|
||||
```bash
|
||||
npm run format
|
||||
npm run format:check
|
||||
```
|
||||
|
||||
Wil je mij benaderen voor een samenwerking of opdracht?
|
||||
Gebruik het contactformulier op de site of stuur direct een bericht via [Telegram](https://t.me/robertguagliardo).
|
||||
Tests en analyse:
|
||||
|
||||
```bash
|
||||
composer test
|
||||
composer analyse
|
||||
```
|
||||
|
||||
## Contact
|
||||
|
||||
Gebruik het contactformulier op de site of stuur me een bericht via [Telegram](https://t.me/robertguagliardo).
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\PageVisitRequest;
|
||||
use App\Jobs\NotifyTelegramAboutContactMessage;
|
||||
use App\Jobs\NotifyTelegramAboutPageVisit;
|
||||
use App\Jobs\NotifyTelegramAboutPersonaliaClick;
|
||||
use App\Models\Education;
|
||||
use App\Models\Personalia;
|
||||
@@ -30,7 +32,8 @@ class FrontendController extends Controller
|
||||
NotifyTelegramAboutPersonaliaClick::dispatch(
|
||||
$personalia,
|
||||
request()->ip(),
|
||||
request()->userAgent()
|
||||
request()->userAgent(),
|
||||
$this->ipHeaders(request())
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
@@ -53,9 +56,36 @@ class FrontendController extends Controller
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
$validated['email'] ?? null,
|
||||
$validated['phone'] ?? null
|
||||
$validated['phone'] ?? null,
|
||||
$this->ipHeaders($request)
|
||||
);
|
||||
|
||||
return response()->json(['status' => 'success']);
|
||||
}
|
||||
|
||||
public function pageVisit(PageVisitRequest $request): JsonResponse
|
||||
{
|
||||
NotifyTelegramAboutPageVisit::dispatch(
|
||||
$request->validated(),
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
$request->header('Accept-Language'),
|
||||
$this->ipHeaders($request)
|
||||
);
|
||||
|
||||
return response()->json(['status' => 'queued']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string|null>
|
||||
*/
|
||||
protected function ipHeaders(Request $request): array
|
||||
{
|
||||
return [
|
||||
'CF-Connecting-IP' => $request->header('CF-Connecting-IP'),
|
||||
'X-Forwarded-For' => $request->header('X-Forwarded-For'),
|
||||
'X-Real-IP' => $request->header('X-Real-IP'),
|
||||
'Forwarded' => $request->header('Forwarded'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
43
app/Http/Requests/PageVisitRequest.php
Normal file
43
app/Http/Requests/PageVisitRequest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PageVisitRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'visitor_id' => ['required', 'string', 'max:100'],
|
||||
'url' => ['required', 'url', 'max:2048'],
|
||||
'path' => ['nullable', 'string', 'max:2048'],
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'referrer' => ['nullable', 'string', 'max:2048'],
|
||||
'language' => ['nullable', 'string', 'max:50'],
|
||||
'timezone' => ['nullable', 'string', 'max:100'],
|
||||
'screen' => ['nullable', 'array'],
|
||||
'screen.width' => ['nullable', 'integer', 'min:0', 'max:100000'],
|
||||
'screen.height' => ['nullable', 'integer', 'min:0', 'max:100000'],
|
||||
'viewport' => ['nullable', 'array'],
|
||||
'viewport.width' => ['nullable', 'integer', 'min:0', 'max:100000'],
|
||||
'viewport.height' => ['nullable', 'integer', 'min:0', 'max:100000'],
|
||||
'device_pixel_ratio' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
'color_depth' => ['nullable', 'integer', 'min:0', 'max:1000'],
|
||||
'platform' => ['nullable', 'string', 'max:255'],
|
||||
'vendor' => ['nullable', 'string', 'max:255'],
|
||||
'hardware_concurrency' => ['nullable', 'integer', 'min:0', 'max:1024'],
|
||||
'device_memory' => ['nullable', 'numeric', 'min:0', 'max:1024'],
|
||||
'cookies_enabled' => ['nullable', 'boolean'],
|
||||
'online' => ['nullable', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\TelegramNotificationService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class NotifyTelegramAboutContactMessage implements ShouldQueue
|
||||
{
|
||||
@@ -25,8 +25,18 @@ class NotifyTelegramAboutContactMessage implements ShouldQueue
|
||||
|
||||
protected ?string $phone;
|
||||
|
||||
public function __construct(string $name, string $message, string $ip, string $userAgent, ?string $email = null, ?string $phone = null)
|
||||
{
|
||||
/**
|
||||
* @param array<string, string|null> $ipHeaders
|
||||
*/
|
||||
public function __construct(
|
||||
string $name,
|
||||
string $message,
|
||||
string $ip,
|
||||
string $userAgent,
|
||||
?string $email = null,
|
||||
?string $phone = null,
|
||||
protected array $ipHeaders = []
|
||||
) {
|
||||
$this->name = $name;
|
||||
$this->message = $message;
|
||||
$this->ip = $ip;
|
||||
@@ -35,30 +45,16 @@ class NotifyTelegramAboutContactMessage implements ShouldQueue
|
||||
$this->phone = $phone;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
public function handle(TelegramNotificationService $telegram): void
|
||||
{
|
||||
$email = $this->email ?? '–';
|
||||
$phone = $this->phone ?? '–';
|
||||
|
||||
$text = <<<TEXT
|
||||
📩 *Nieuw contactbericht ontvangen*
|
||||
|
||||
👤 Naam: *{$this->name}*
|
||||
|
||||
💬 Bericht:
|
||||
{$this->message}
|
||||
📧 Email: {$email}
|
||||
📱 Telefoon: {$phone}
|
||||
🌐 IP: {$this->ip}
|
||||
🧭 User Agent: `{$this->userAgent}`
|
||||
|
||||
🕒 Tijdstip: *{now()->format('d-m-Y H:i')}*
|
||||
TEXT;
|
||||
|
||||
Http::post('https://api.telegram.org/bot'.config('services.telegram.bot_token').'/sendMessage', [
|
||||
'chat_id' => config('services.telegram.chat_id'),
|
||||
'text' => $text,
|
||||
'parse_mode' => 'Markdown',
|
||||
]);
|
||||
$telegram->notifyContactMessage(
|
||||
$this->name,
|
||||
$this->message,
|
||||
$this->ip,
|
||||
$this->userAgent,
|
||||
$this->email,
|
||||
$this->phone,
|
||||
$this->ipHeaders
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
32
app/Jobs/NotifyTelegramAboutPageVisit.php
Normal file
32
app/Jobs/NotifyTelegramAboutPageVisit.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\TelegramNotificationService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class NotifyTelegramAboutPageVisit implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $visit
|
||||
* @param array<string, string|null> $ipHeaders
|
||||
*/
|
||||
public function __construct(
|
||||
protected array $visit,
|
||||
protected ?string $ip,
|
||||
protected ?string $userAgent,
|
||||
protected ?string $acceptLanguage,
|
||||
protected array $ipHeaders = []
|
||||
) {}
|
||||
|
||||
public function handle(TelegramNotificationService $telegram): void
|
||||
{
|
||||
$telegram->notifyPageVisit($this->visit, $this->ip, $this->userAgent, $this->acceptLanguage, $this->ipHeaders);
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Personalia;
|
||||
use App\Services\TelegramNotificationService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class NotifyTelegramAboutPersonaliaClick implements ShouldQueue
|
||||
{
|
||||
@@ -20,32 +20,18 @@ class NotifyTelegramAboutPersonaliaClick implements ShouldQueue
|
||||
|
||||
protected ?string $userAgent;
|
||||
|
||||
public function __construct(Personalia $personalia, ?string $ip, ?string $userAgent)
|
||||
/**
|
||||
* @param array<string, string|null> $ipHeaders
|
||||
*/
|
||||
public function __construct(Personalia $personalia, ?string $ip, ?string $userAgent, protected array $ipHeaders = [])
|
||||
{
|
||||
$this->personalia = $personalia;
|
||||
$this->ip = $ip;
|
||||
$this->userAgent = $userAgent;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
public function handle(TelegramNotificationService $telegram): void
|
||||
{
|
||||
$ip = $this->ip ?? '–';
|
||||
$userAgent = $this->userAgent ?? '–';
|
||||
|
||||
$message = <<<TEXT
|
||||
👤 *Persoonlijke gegevens bekeken*
|
||||
|
||||
Naam: {$this->personalia->value}
|
||||
IP: {$ip}
|
||||
User Agent: `{$userAgent}`
|
||||
|
||||
📅 Tijdstip: *{now()->format('d-m-Y H:i')}*
|
||||
TEXT;
|
||||
|
||||
Http::post('https://api.telegram.org/bot'.config('services.telegram.bot_token').'/sendMessage', [
|
||||
'chat_id' => config('services.telegram.chat_id'),
|
||||
'text' => $message,
|
||||
'parse_mode' => 'Markdown',
|
||||
]);
|
||||
$telegram->notifyPersonaliaClick($this->personalia, $this->ip, $this->userAgent, $this->ipHeaders);
|
||||
}
|
||||
}
|
||||
|
||||
177
app/Services/TelegramNotificationService.php
Normal file
177
app/Services/TelegramNotificationService.php
Normal file
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Personalia;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class TelegramNotificationService
|
||||
{
|
||||
/**
|
||||
* @param array<string, string|null> $ipHeaders
|
||||
*/
|
||||
public function notifyContactMessage(
|
||||
string $name,
|
||||
string $message,
|
||||
string $ip,
|
||||
string $userAgent,
|
||||
?string $email = null,
|
||||
?string $phone = null,
|
||||
array $ipHeaders = []
|
||||
): void {
|
||||
$email ??= '-';
|
||||
$phone ??= '-';
|
||||
$ipHeaderText = $this->formatIpHeaders($ipHeaders);
|
||||
|
||||
$text = <<<TEXT
|
||||
Nieuw contactbericht ontvangen
|
||||
|
||||
Naam: {$name}
|
||||
Email: {$email}
|
||||
Telefoon: {$phone}
|
||||
IP: {$ip}
|
||||
{$ipHeaderText}
|
||||
User Agent: {$userAgent}
|
||||
|
||||
Bericht:
|
||||
{$message}
|
||||
|
||||
Tijdstip: {now()->format('d-m-Y H:i')}
|
||||
TEXT;
|
||||
|
||||
$this->send($text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|null> $ipHeaders
|
||||
*/
|
||||
public function notifyPersonaliaClick(Personalia $personalia, ?string $ip, ?string $userAgent, array $ipHeaders = []): void
|
||||
{
|
||||
$ip ??= '-';
|
||||
$userAgent ??= '-';
|
||||
$ipHeaderText = $this->formatIpHeaders($ipHeaders);
|
||||
|
||||
$message = <<<TEXT
|
||||
Persoonlijke gegevens bekeken
|
||||
|
||||
Naam: {$personalia->value}
|
||||
IP: {$ip}
|
||||
{$ipHeaderText}
|
||||
User Agent: {$userAgent}
|
||||
|
||||
Tijdstip: {now()->format('d-m-Y H:i')}
|
||||
TEXT;
|
||||
|
||||
$this->send($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $visit
|
||||
* @param array<string, string|null> $ipHeaders
|
||||
*/
|
||||
public function notifyPageVisit(array $visit, ?string $ip, ?string $userAgent, ?string $acceptLanguage, array $ipHeaders = []): void
|
||||
{
|
||||
$screen = $this->formatDimensions($visit['screen'] ?? null);
|
||||
$viewport = $this->formatDimensions($visit['viewport'] ?? null);
|
||||
$ipHeaderText = $this->formatIpHeaders($ipHeaders);
|
||||
|
||||
$message = <<<TEXT
|
||||
Pagina bezocht
|
||||
|
||||
URL: {$this->value($visit['url'] ?? null)}
|
||||
Pad: {$this->value($visit['path'] ?? null)}
|
||||
Titel: {$this->value($visit['title'] ?? null)}
|
||||
Referrer: {$this->value($visit['referrer'] ?? null)}
|
||||
|
||||
Visitor ID: {$this->value($visit['visitor_id'] ?? null)}
|
||||
IP: {$this->value($ip)}
|
||||
{$ipHeaderText}
|
||||
User Agent: {$this->value($userAgent)}
|
||||
Accept-Language: {$this->value($acceptLanguage)}
|
||||
Browser taal: {$this->value($visit['language'] ?? null)}
|
||||
Timezone: {$this->value($visit['timezone'] ?? null)}
|
||||
Platform: {$this->value($visit['platform'] ?? null)}
|
||||
Vendor: {$this->value($visit['vendor'] ?? null)}
|
||||
Scherm: {$screen}
|
||||
Viewport: {$viewport}
|
||||
DPR: {$this->value($visit['device_pixel_ratio'] ?? null)}
|
||||
Color depth: {$this->value($visit['color_depth'] ?? null)}
|
||||
CPU cores: {$this->value($visit['hardware_concurrency'] ?? null)}
|
||||
Device memory: {$this->value($visit['device_memory'] ?? null)}
|
||||
Cookies: {$this->boolean($visit['cookies_enabled'] ?? null)}
|
||||
Online: {$this->boolean($visit['online'] ?? null)}
|
||||
|
||||
Tijdstip: {now()->format('d-m-Y H:i')}
|
||||
TEXT;
|
||||
|
||||
$this->send($message);
|
||||
}
|
||||
|
||||
protected function send(string $text): void
|
||||
{
|
||||
$botToken = config('services.telegram.bot_token');
|
||||
$chatId = config('services.telegram.chat_id');
|
||||
|
||||
if (! is_string($botToken) || $botToken === '' || ! is_string($chatId) || $chatId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
Http::post("https://api.telegram.org/bot{$botToken}/sendMessage", [
|
||||
'chat_id' => $chatId,
|
||||
'text' => $text,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string|null> $ipHeaders
|
||||
*/
|
||||
protected function formatIpHeaders(array $ipHeaders): string
|
||||
{
|
||||
$headers = [
|
||||
'CF-Connecting-IP',
|
||||
'X-Forwarded-For',
|
||||
'X-Real-IP',
|
||||
'Forwarded',
|
||||
];
|
||||
|
||||
return collect($headers)
|
||||
->map(fn (string $header): string => "{$header}: {$this->value($ipHeaders[$header] ?? null)}")
|
||||
->implode("\n");
|
||||
}
|
||||
|
||||
protected function value(mixed $value): string
|
||||
{
|
||||
if (is_bool($value)) {
|
||||
return $this->boolean($value);
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
$value = trim((string) $value);
|
||||
|
||||
return $value !== '' ? $value : '-';
|
||||
}
|
||||
|
||||
return '-';
|
||||
}
|
||||
|
||||
protected function boolean(mixed $value): string
|
||||
{
|
||||
return match ($value) {
|
||||
true => 'ja',
|
||||
false => 'nee',
|
||||
default => '-',
|
||||
};
|
||||
}
|
||||
|
||||
protected function formatDimensions(mixed $dimensions): string
|
||||
{
|
||||
if (! is_array($dimensions)) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
$width = $this->value($dimensions['width'] ?? null);
|
||||
$height = $this->value($dimensions['height'] ?? null);
|
||||
|
||||
return "{$width}x{$height}";
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,21 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const cursor = document.getElementById('custom-cursor');
|
||||
window.addEventListener('load', () => {
|
||||
const cursor = document.getElementById('custom-cursor');
|
||||
const gsapInstance = window.gsap;
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
gsap.to(cursor, {
|
||||
if (!cursor || !gsapInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
gsapInstance.to(cursor, {
|
||||
duration: 0.2,
|
||||
x: e.clientX + 20,
|
||||
y: e.clientY - 15,
|
||||
ease: 'power2.out'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>Laravel</title>
|
||||
<title>{{ config('app.name') }}</title>
|
||||
|
||||
<!-- Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
@@ -170,6 +170,63 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
if (!csrfToken || !window.fetch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visitorKey = 'cv_visitor_id';
|
||||
const createVisitorId = () => window.crypto?.randomUUID?.() ??
|
||||
`${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
let visitorId = createVisitorId();
|
||||
|
||||
try {
|
||||
visitorId = window.localStorage.getItem(visitorKey) || visitorId;
|
||||
window.localStorage.setItem(visitorKey, visitorId);
|
||||
} catch {
|
||||
// Tracking should never break the page when storage is blocked.
|
||||
}
|
||||
|
||||
window.fetch('{{ route('page-visits.store') }}', {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
visitor_id: visitorId,
|
||||
url: window.location.href,
|
||||
path: window.location.pathname,
|
||||
title: document.title,
|
||||
referrer: document.referrer,
|
||||
language: navigator.language,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
screen: {
|
||||
width: window.screen?.width,
|
||||
height: window.screen?.height,
|
||||
},
|
||||
viewport: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
},
|
||||
device_pixel_ratio: window.devicePixelRatio,
|
||||
color_depth: window.screen?.colorDepth,
|
||||
platform: navigator.platform,
|
||||
vendor: navigator.vendor,
|
||||
hardware_concurrency: navigator.hardwareConcurrency,
|
||||
device_memory: navigator.deviceMemory,
|
||||
cookies_enabled: navigator.cookieEnabled,
|
||||
online: navigator.onLine,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@stack('scripts')
|
||||
|
||||
</body>
|
||||
|
||||
@@ -13,6 +13,7 @@ Route::get('/dashboard', function (): \Illuminate\View\View {
|
||||
})->middleware(['auth', 'verified'])->name('dashboard');
|
||||
Route::get('/getPersonalia/{personalia}', [FrontendController::class, 'getPersonalia'])->name('personalia');
|
||||
Route::post('/contact', [FrontendController::class, 'message'])->name('contact');
|
||||
Route::post('/page-visits', [FrontendController::class, 'pageVisit'])->name('page-visits.store');
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\NotifyTelegramAboutContactMessage;
|
||||
use App\Jobs\NotifyTelegramAboutPageVisit;
|
||||
use App\Jobs\NotifyTelegramAboutPersonaliaClick;
|
||||
use App\Models\Education;
|
||||
use App\Models\Personalia;
|
||||
@@ -99,3 +100,55 @@ test('a contact message requires a name and message', function () {
|
||||
|
||||
Queue::assertNotPushed(NotifyTelegramAboutContactMessage::class);
|
||||
});
|
||||
|
||||
test('a page visit can be submitted and is queued for notification', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->withHeaders([
|
||||
'User-Agent' => 'Pest Browser',
|
||||
'Accept-Language' => 'nl-NL,nl;q=0.9',
|
||||
])->postJson(route('page-visits.store'), [
|
||||
'visitor_id' => 'visitor-123',
|
||||
'url' => 'https://cv.robert.ooo/',
|
||||
'path' => '/',
|
||||
'title' => 'CV Roberto',
|
||||
'referrer' => 'https://example.com',
|
||||
'language' => 'nl-NL',
|
||||
'timezone' => 'Europe/Amsterdam',
|
||||
'screen' => [
|
||||
'width' => 1920,
|
||||
'height' => 1080,
|
||||
],
|
||||
'viewport' => [
|
||||
'width' => 1440,
|
||||
'height' => 900,
|
||||
],
|
||||
'device_pixel_ratio' => 1,
|
||||
'color_depth' => 24,
|
||||
'platform' => 'Linux x86_64',
|
||||
'vendor' => 'Google Inc.',
|
||||
'hardware_concurrency' => 8,
|
||||
'device_memory' => 8,
|
||||
'cookies_enabled' => true,
|
||||
'online' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'status' => 'queued',
|
||||
]);
|
||||
|
||||
Queue::assertPushed(NotifyTelegramAboutPageVisit::class);
|
||||
});
|
||||
|
||||
test('a page visit requires a visitor id and valid url', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->postJson(route('page-visits.store'), [
|
||||
'visitor_id' => '',
|
||||
'url' => 'not-a-url',
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['visitor_id', 'url']);
|
||||
|
||||
Queue::assertNotPushed(NotifyTelegramAboutPageVisit::class);
|
||||
});
|
||||
|
||||
92
tests/Feature/TelegramNotificationServiceTest.php
Normal file
92
tests/Feature/TelegramNotificationServiceTest.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Personalia;
|
||||
use App\Services\TelegramNotificationService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
test('telegram service sends a contact message notification', function () {
|
||||
config([
|
||||
'services.telegram.bot_token' => 'telegram-token',
|
||||
'services.telegram.chat_id' => 'telegram-chat',
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
app(TelegramNotificationService::class)->notifyContactMessage(
|
||||
name: 'Roberto',
|
||||
message: 'Hoi, ik wil graag contact opnemen.',
|
||||
ip: '127.0.0.1',
|
||||
userAgent: 'Pest Browser',
|
||||
email: 'roberto@example.com',
|
||||
phone: '+31612345678',
|
||||
ipHeaders: [
|
||||
'CF-Connecting-IP' => '203.0.113.10',
|
||||
'X-Forwarded-For' => '203.0.113.10, 198.51.100.20',
|
||||
],
|
||||
);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->url() === 'https://api.telegram.org/bottelegram-token/sendMessage'
|
||||
&& $request['chat_id'] === 'telegram-chat'
|
||||
&& str_contains($request['text'], 'Nieuw contactbericht ontvangen')
|
||||
&& str_contains($request['text'], 'roberto@example.com')
|
||||
&& str_contains($request['text'], 'CF-Connecting-IP: 203.0.113.10')
|
||||
&& str_contains($request['text'], 'X-Forwarded-For: 203.0.113.10, 198.51.100.20'));
|
||||
});
|
||||
|
||||
test('telegram service sends a personalia click notification', function () {
|
||||
config([
|
||||
'services.telegram.bot_token' => 'telegram-token',
|
||||
'services.telegram.chat_id' => 'telegram-chat',
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
$personalia = Personalia::factory()->create([
|
||||
'value' => 'roberto@example.com',
|
||||
]);
|
||||
|
||||
app(TelegramNotificationService::class)->notifyPersonaliaClick($personalia, '127.0.0.1', 'Pest Browser', [
|
||||
'CF-Connecting-IP' => '203.0.113.10',
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request['text'], 'Persoonlijke gegevens bekeken')
|
||||
&& str_contains($request['text'], 'roberto@example.com')
|
||||
&& str_contains($request['text'], 'CF-Connecting-IP: 203.0.113.10'));
|
||||
});
|
||||
|
||||
test('telegram service sends a page visit notification', function () {
|
||||
config([
|
||||
'services.telegram.bot_token' => 'telegram-token',
|
||||
'services.telegram.chat_id' => 'telegram-chat',
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
|
||||
app(TelegramNotificationService::class)->notifyPageVisit([
|
||||
'visitor_id' => 'visitor-123',
|
||||
'url' => 'https://cv.robert.ooo/',
|
||||
'path' => '/',
|
||||
'title' => 'CV Roberto',
|
||||
'referrer' => 'https://example.com',
|
||||
'language' => 'nl-NL',
|
||||
'timezone' => 'Europe/Amsterdam',
|
||||
'screen' => [
|
||||
'width' => 1920,
|
||||
'height' => 1080,
|
||||
],
|
||||
'viewport' => [
|
||||
'width' => 1440,
|
||||
'height' => 900,
|
||||
],
|
||||
], '127.0.0.1', 'Pest Browser', 'nl-NL,nl;q=0.9', [
|
||||
'CF-Connecting-IP' => '203.0.113.10',
|
||||
'X-Real-IP' => '198.51.100.30',
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request['text'], 'Pagina bezocht')
|
||||
&& str_contains($request['text'], 'Visitor ID: visitor-123')
|
||||
&& str_contains($request['text'], 'Scherm: 1920x1080')
|
||||
&& str_contains($request['text'], 'Viewport: 1440x900')
|
||||
&& str_contains($request['text'], 'CF-Connecting-IP: 203.0.113.10')
|
||||
&& str_contains($request['text'], 'X-Real-IP: 198.51.100.30'));
|
||||
});
|
||||
Reference in New Issue
Block a user