lots of progress

This commit is contained in:
Doar Mihai
2026-01-17 13:46:20 +02:00
parent ab5d3cae98
commit 030a105992
77 changed files with 8507 additions and 20 deletions
@@ -0,0 +1,64 @@
<?php
namespace App\Console\Commands;
use App\Jobs\CheckBrokenLinks;
use App\Models\Site;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Bus;
class MonitorLinksCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'monitor:links';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Check for broken links on all enabled sites';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Starting broken link monitoring...');
// Fetch all sites with monitoring enabled
$sites = Site::where('monitoring_enabled', true)->get();
if ($sites->isEmpty()) {
$this->warn('No enabled sites found for broken link monitoring.');
return Command::SUCCESS;
}
$this->info("Found {$sites->count()} enabled site(s) for broken link monitoring.");
// Create a batch of jobs to dispatch in parallel
$jobs = $sites->map(function (Site $site) {
return new CheckBrokenLinks($site);
});
// Dispatch jobs using Laravel's bus with batch processing
Bus::chain([
function () use ($jobs) {
Bus::batch($jobs->toArray())
->name('Broken Link Monitoring')
->allowFailures()
->onQueue('monitoring')
->dispatch();
}
])->dispatch();
$this->info("Dispatched {$jobs->count()} broken link check job(s).");
$this->comment('Jobs are being processed in the queue...');
return Command::SUCCESS;
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Console\Commands;
use App\Jobs\CheckSslCertificate;
use App\Models\Site;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Bus;
class MonitorSslCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'monitor:ssl';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Check SSL certificates for all enabled sites';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Starting SSL certificate monitoring...');
// Fetch all sites with monitoring enabled
$sites = Site::where('monitoring_enabled', true)->get();
if ($sites->isEmpty()) {
$this->warn('No enabled sites found for SSL monitoring.');
return Command::SUCCESS;
}
$this->info("Found {$sites->count()} enabled site(s) for SSL monitoring.");
// Create a batch of jobs to dispatch in parallel
$jobs = $sites->map(function (Site $site) {
return new CheckSslCertificate($site);
});
// Dispatch jobs using Laravel's bus with batch processing
Bus::chain([
function () use ($jobs) {
Bus::batch($jobs->toArray())
->name('SSL Certificate Monitoring')
->allowFailures()
->onQueue('monitoring')
->dispatch();
}
])->dispatch();
$this->info("Dispatched {$jobs->count()} SSL certificate check job(s).");
$this->comment('Jobs are being processed in the queue...');
return Command::SUCCESS;
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Console\Commands;
use App\Jobs\CheckUptime;
use App\Models\Site;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Bus;
class MonitorUptimeCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'monitor:uptime';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Check uptime for all enabled sites';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Starting uptime monitoring...');
// Fetch all sites with monitoring enabled
$sites = Site::where('monitoring_enabled', true)->get();
if ($sites->isEmpty()) {
$this->warn('No enabled sites found for monitoring.');
return Command::SUCCESS;
}
$this->info("Found {$sites->count()} enabled site(s) to monitor.");
// Create a batch of jobs to dispatch in parallel
$jobs = $sites->map(function (Site $site) {
return new CheckUptime($site);
});
// Dispatch jobs using Laravel's bus with concurrency limit
Bus::chain([
function () use ($jobs) {
// Using bus batch for parallel processing
Bus::batch($jobs->toArray())
->name('Uptime Monitoring')
->allowFailures()
->onQueue('monitoring')
->dispatch();
}
])->dispatch();
$this->info("Dispatched {$jobs->count()} uptime check job(s).");
$this->comment('Jobs are being processed in the queue...');
return Command::SUCCESS;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers;
use App\Models\Notification;
use Illuminate\Http\Request;
class AlertController extends Controller
{
public function index()
{
$notifications = auth()->user()->notifications()
->orderBy('created_at', 'desc')
->paginate(20);
$unreadCount = auth()->user()->notifications()
->where('is_read', false)
->count();
$readCount = auth()->user()->notifications()
->where('is_read', true)
->count();
return view('alerts.index', compact('notifications', 'unreadCount', 'readCount'));
}
public function markAsRead(Notification $notification)
{
$this->authorize('view', $notification);
$notification->update(['is_read' => true]);
return redirect()->route('alerts.index')
->with('success', 'Alert marked as read.');
}
public function markAllAsRead()
{
auth()->user()->notifications()
->where('is_read', false)
->update(['is_read' => true]);
return redirect()->route('alerts.index')
->with('success', 'All alerts marked as read.');
}
public function destroy(Notification $notification)
{
$this->authorize('view', $notification);
$notification->delete();
return redirect()->route('alerts.index')
->with('success', 'Alert deleted.');
}
public function deleteRead()
{
auth()->user()->notifications()
->where('is_read', true)
->delete();
return redirect()->route('alerts.index')
->with('success', 'All read alerts deleted.');
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ConfirmsPasswords;
class ConfirmPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Confirm Password Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password confirmations and
| uses a simple trait to include the behavior. You're free to explore
| this trait and override any functions that require customization.
|
*/
use ConfirmsPasswords;
/**
* Where to redirect users when the intended url fails.
*
* @var string
*/
protected $redirectTo = '/home';
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\SendsPasswordResetEmails;
class ForgotPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset emails and
| includes a trait which assists in sending these notifications from
| your application to your users. Feel free to explore this trait.
|
*/
use SendsPasswordResetEmails;
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
class LoginController extends Controller
{
/*
|--------------------------------------------------------------------------
| Login Controller
|--------------------------------------------------------------------------
|
| This controller handles authenticating users for the application and
| redirecting them to your home screen. The controller uses a trait
| to conveniently provide its functionality to your applications.
|
*/
use AuthenticatesUsers;
/**
* Where to redirect users after login.
*
* @var string
*/
protected $redirectTo = '/dashboard';
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest')->except('logout');
$this->middleware('auth')->only('logout');
}
}
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
class RegisterController extends Controller
{
/*
|--------------------------------------------------------------------------
| Register Controller
|--------------------------------------------------------------------------
|
| This controller handles the registration of new users as well as their
| validation and creation. By default this controller uses a trait to
| provide this functionality without requiring any additional code.
|
*/
use RegistersUsers;
/**
* Where to redirect users after registration.
*
* @var string
*/
protected $redirectTo = '/dashboard';
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('guest');
}
/**
* Get a validator for an incoming registration request.
*
* @param array $data
* @return \Illuminate\Contracts\Validation\Validator
*/
protected function validator(array $data)
{
return Validator::make($data, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
}
/**
* Create a new user instance after a valid registration.
*
* @param array $data
* @return \App\Models\User
*/
protected function create(array $data)
{
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => Hash::make($data['password']),
]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
class ResetPasswordController extends Controller
{
/*
|--------------------------------------------------------------------------
| Password Reset Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling password reset requests
| and uses a simple trait to include this behavior. You're free to
| explore this trait and override any methods you wish to tweak.
|
*/
use ResetsPasswords;
/**
* Where to redirect users after resetting their password.
*
* @var string
*/
protected $redirectTo = '/dashboard';
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\VerifiesEmails;
class VerificationController extends Controller
{
/*
|--------------------------------------------------------------------------
| Email Verification Controller
|--------------------------------------------------------------------------
|
| This controller is responsible for handling email verification for any
| user that recently registered with the application. Emails may also
| be re-sent if the user didn't receive the original email message.
|
*/
use VerifiesEmails;
/**
* Where to redirect users after verification.
*
* @var string
*/
protected $redirectTo = '/dashboard';
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
$this->middleware('signed')->only('verify');
$this->middleware('throttle:6,1')->only('verify', 'resend');
}
}
+6 -2
View File
@@ -2,7 +2,11 @@
namespace App\Http\Controllers;
abstract class Controller
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
class Controller extends BaseController
{
//
use AuthorizesRequests, ValidatesRequests;
}
@@ -0,0 +1,142 @@
<?php
namespace App\Http\Controllers;
use App\Models\Site;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function index()
{
$sites = auth()->user()->sites()
->with(['uptimeChecks' => function($query) {
$query->latest('checked_at')->limit(1);
}, 'sslCertificates' => function($query) {
$query->latest('checked_at')->limit(1);
}])
->withCount('brokenLinks')
->get();
// Calculate stats for each site
$sites->each(function($site) {
$checks = $site->uptimeChecks()
->where('checked_at', '>=', now()->subDays(30))
->get();
$totalChecks = $checks->count();
$successfulChecks = $checks->where('status_code', '>=', 200)
->where('status_code', '<', 400)
->count();
$site->uptime_percentage = $totalChecks > 0 ? ($successfulChecks / $totalChecks) * 100 : 0;
$site->avg_response_time = $checks->avg('response_time_ms');
});
// Calculate overall stats
$totalSites = $sites->count();
$sitesUp = $sites->filter(function($site) {
$latestCheck = $site->uptimeChecks->first();
return $latestCheck && $latestCheck->status_code >= 200 && $latestCheck->status_code < 400;
})->count();
$sitesDown = $sites->filter(function($site) {
$latestCheck = $site->uptimeChecks->first();
return $latestCheck && ($latestCheck->status_code < 200 || $latestCheck->status_code >= 400);
})->count();
$avgUptime = $sites->avg('uptime_percentage') ?? 0;
// Prepare chart data for last 7 days
$chartData = null;
if ($sites->isNotEmpty()) {
$labels = [];
for ($i = 6; $i >= 0; $i--) {
$labels[] = now()->subDays($i)->format('M d');
}
$datasets = $sites->map(function($site) use ($labels) {
$data = [];
for ($i = 6; $i >= 0; $i--) {
$date = now()->subDays($i)->startOfDay();
$checks = $site->uptimeChecks()
->whereDate('checked_at', $date)
->get();
$totalChecks = $checks->count();
$successfulChecks = $checks->where('status_code', '>=', 200)
->where('status_code', '<', 400)
->count();
$data[] = $totalChecks > 0 ? round(($successfulChecks / $totalChecks) * 100, 2) : null;
}
return [
'label' => $site->name,
'data' => $data,
'color' => '#' . substr(md5($site->id), 0, 6)
];
})->toArray();
$chartData = [
'labels' => $labels,
'datasets' => $datasets
];
}
return view('dashboard', compact(
'sites',
'totalSites',
'sitesUp',
'sitesDown',
'avgUptime',
'chartData'
));
}
public function show(Site $site)
{
$this->authorize('view', $site);
// Get uptime checks for the last 30 days
$uptimeChecks = $site->uptimeChecks()
->where('checked_at', '>=', now()->subDays(30))
->orderBy('checked_at', 'asc')
->get();
// Get latest SSL certificate
$latestSslCert = $site->sslCertificates()
->latest('checked_at')
->first();
// Get broken links
$brokenLinks = $site->brokenLinks()
->orderBy('last_checked_at', 'desc')
->paginate(20);
// Calculate uptime percentage
$totalChecks = $uptimeChecks->count();
$successfulChecks = $uptimeChecks->where('status_code', '>=', 200)
->where('status_code', '<', 400)
->count();
$uptimePercentage = $totalChecks > 0 ? round(($successfulChecks / $totalChecks) * 100, 2) : 0;
// Calculate average response time
$avgResponseTime = $uptimeChecks->avg('response_time_ms');
// Prepare chart data
$chartLabels = $uptimeChecks->pluck('checked_at')->map(fn($date) => $date->format('M d H:i'))->toArray();
$chartData = $uptimeChecks->pluck('response_time_ms')->toArray();
$chartStatusCodes = $uptimeChecks->pluck('status_code')->toArray();
return view('dashboard.show', compact(
'site',
'uptimeChecks',
'latestSslCert',
'brokenLinks',
'uptimePercentage',
'avgResponseTime',
'chartLabels',
'chartData',
'chartStatusCodes'
));
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class HomeController extends Controller
{
/**
* Create a new controller instance.
*
* @return void
*/
public function __construct()
{
$this->middleware('auth');
}
/**
* Show the application dashboard.
*
* @return \Illuminate\Contracts\Support\Renderable
*/
public function index()
{
return view('home');
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class SettingsController extends Controller
{
public function index()
{
$preferences = auth()->user()->notification_preferences ?? [
'uptime' => ['mail'],
'ssl' => ['mail'],
'broken_links' => ['mail']
];
$slackWebhook = env('SLACK_WEBHOOK_URL', '');
return view('settings', compact('preferences', 'slackWebhook'));
}
public function updateNotifications(Request $request)
{
$request->validate([
'uptime' => 'nullable|array',
'uptime.*' => 'in:mail,slack',
'ssl' => 'nullable|array',
'ssl.*' => 'in:mail,slack',
'broken_links' => 'nullable|array',
'broken_links.*' => 'in:mail,slack',
]);
$preferences = [
'uptime' => $request->input('uptime', []),
'ssl' => $request->input('ssl', []),
'broken_links' => $request->input('broken_links', []),
];
auth()->user()->update([
'notification_preferences' => $preferences
]);
return redirect()->route('settings')
->with('success', 'Notification preferences updated successfully.');
}
public function updateSlack(Request $request)
{
$request->validate([
'slack_webhook' => 'nullable|url'
]);
// Note: In a production app, you would store this in the user's settings or env
// For now, we'll just return a message
return redirect()->route('settings')
->with('success', 'Slack webhook URL saved. Please add it to your .env file as SLACK_WEBHOOK_URL.');
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers;
use App\Models\Site;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class SiteController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$sites = Auth::user()->sites()
->withCount(['uptimeChecks', 'sslCertificates', 'brokenLinks'])
->latest()
->get();
// Get latest uptime check for each site
$sites->each(function ($site) {
$site->latestUptimeCheck = $site->uptimeChecks()->latest('checked_at')->first();
$site->latestSslCertificate = $site->sslCertificates()->latest('checked_at')->first();
});
return view('sites.index', compact('sites'));
}
/**
* Show the form for creating a new resource.
*/
public function create()
{
return view('sites.create');
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'url' => 'required|url|max:255',
'monitoring_enabled' => 'sometimes|nullable|in:on,off',
]);
$site = Auth::user()->sites()->create([
'name' => $validated['name'],
'url' => $validated['url'],
'monitoring_enabled' => $request->has('monitoring_enabled'),
]);
return redirect()->route('sites.index')
->with('success', 'Site added successfully!');
}
/**
* Display the specified resource.
*/
public function show(Site $site)
{
$this->authorize('view', $site);
return redirect()->route('dashboard.show', $site);
}
/**
* Show the form for editing the specified resource.
*/
public function edit(Site $site)
{
$this->authorize('update', $site);
return view('sites.edit', compact('site'));
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Site $site)
{
$this->authorize('update', $site);
$validated = $request->validate([
'name' => 'required|string|max:255',
'url' => 'required|url|max:255',
'monitoring_enabled' => 'boolean',
]);
$site->update([
'name' => $validated['name'],
'url' => $validated['url'],
'monitoring_enabled' => $request->has('monitoring_enabled'),
]);
return redirect()->route('sites.index')
->with('success', 'Site updated successfully!');
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Site $site)
{
$this->authorize('delete', $site);
$site->delete();
return redirect()->route('sites.index')
->with('success', 'Site deleted successfully!');
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
namespace App\Jobs;
use App\Models\BrokenLink;
use App\Models\Site;
use App\Notifications\BrokenLinkAlert;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use Spatie\Crawler\Crawler;
use Spatie\Crawler\CrawlObservers\CrawlObserver;
class CheckBrokenLinks implements ShouldQueue
{
use Queueable, Batchable;
public $timeout = 300; // 5 minutes timeout for crawling
/**
* Create a new job instance.
*/
public function __construct(
public Site $site
) {
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
Log::info("Starting broken link check for site {$this->site->id} ({$this->site->url})");
// Parse the base URL to get the domain
$parsedUrl = parse_url($this->site->url);
if (!isset($parsedUrl['host'])) {
Log::warning("Invalid URL for broken link check on site {$this->site->id}: {$this->site->url}");
return;
}
$baseDomain = $parsedUrl['host'];
// Create a crawler observer to handle broken links
$observer = new class($this->site, $baseDomain) extends CrawlObserver {
private Site $site;
private string $baseDomain;
public function __construct(Site $site, string $baseDomain)
{
$this->site = $site;
$this->baseDomain = $baseDomain;
}
public function crawled(
UriInterface $url,
ResponseInterface $response,
?UriInterface $foundOnUrl = null,
?string $linkText = null
): void {
$statusCode = $response->getStatusCode();
// Check if the link is broken (4xx or 5xx)
if ($statusCode >= 400) {
// Only log internal links (same domain)
$linkHost = $url->getHost();
if ($linkHost === $this->baseDomain) {
BrokenLink::updateOrCreate(
[
'site_id' => $this->site->id,
'url' => (string) $url,
],
[
'status_code' => $statusCode,
'last_checked_at' => now(),
]
);
Log::info("Broken link detected on site {$this->site->id}: {$url} (HTTP {$statusCode})");
}
}
}
public function crawlFailed(
UriInterface $url,
RequestException $requestException,
?UriInterface $foundOnUrl = null,
?string $linkText = null
): void {
// Only log internal links (same domain)
$linkHost = $url->getHost();
if ($linkHost === $this->baseDomain) {
$statusCode = $requestException->getResponse()
? $requestException->getResponse()->getStatusCode()
: null;
BrokenLink::updateOrCreate(
[
'site_id' => $this->site->id,
'url' => (string) $url,
],
[
'status_code' => $statusCode,
'last_checked_at' => now(),
]
);
Log::info("Crawl failed for site {$this->site->id}: {$url}");
}
}
};
// Configure and start the crawler
Crawler::create()
->setConcurrency(3)
->setMaximumDepth(3)
->setMaximumCrawlCount(50)
->ignoreRobots()
->acceptNofollowLinks()
->setCrawlObserver($observer)
->startCrawling($this->site->url);
Log::info("Completed broken link check for site {$this->site->id}");
// Get all broken links for this site from current scan
$brokenLinks = BrokenLink::where('site_id', $this->site->id)
->where('last_checked_at', '>=', now()->subMinutes(10))
->get()
->map(fn($link) => [
'url' => $link->url,
'status_code' => $link->status_code,
])
->toArray();
// Send notification if broken links were found
if (!empty($brokenLinks)) {
$this->site->user->notify(new BrokenLinkAlert(
$this->site,
$brokenLinks
));
Log::info("Broken link alert sent for site {$this->site->id}: " . count($brokenLinks) . " broken links found");
}
} catch (\Exception $e) {
Log::error("Broken link check failed for site {$this->site->id}: {$e->getMessage()}");
}
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
namespace App\Jobs;
use App\Models\Notification;
use App\Models\Site;
use App\Models\SslCertificate;
use App\Notifications\SslExpiryAlert;
use Carbon\Carbon;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
class CheckSslCertificate implements ShouldQueue
{
use Queueable, Batchable;
/**
* Create a new job instance.
*/
public function __construct(
public Site $site
) {
}
/**
* Execute the job.
*/
public function handle(): void
{
try {
// Parse URL to get host
$parsedUrl = parse_url($this->site->url);
if (!isset($parsedUrl['host'])) {
Log::warning("Invalid URL for SSL check on site {$this->site->id}: {$this->site->url}");
return;
}
$host = $parsedUrl['host'];
$port = $parsedUrl['scheme'] === 'https' ? 443 : 443;
// Create SSL context
$context = stream_context_create([
'ssl' => [
'capture_peer_cert' => true,
'verify_peer' => false,
'verify_peer_name' => false,
],
]);
// Connect to the server and get SSL certificate
$client = @stream_socket_client(
"ssl://{$host}:{$port}",
$errno,
$errstr,
10,
STREAM_CLIENT_CONNECT,
$context
);
if ($client === false) {
Log::warning("Failed to connect to {$host}:{$port} for SSL check: {$errstr}");
return;
}
$params = stream_context_get_params($client);
fclose($client);
if (!isset($params['options']['ssl']['peer_certificate'])) {
Log::warning("No SSL certificate found for site {$this->site->id}");
return;
}
$cert = openssl_x509_parse($params['options']['ssl']['peer_certificate']);
if ($cert === false) {
Log::warning("Failed to parse SSL certificate for site {$this->site->id}");
return;
}
// Extract certificate information
$issuer = $cert['issuer']['O'] ?? $cert['issuer']['CN'] ?? 'Unknown';
$expiresAt = Carbon::createFromTimestamp($cert['validTo_time_t']);
$checkedAt = now();
// Store the SSL certificate check result
SslCertificate::create([
'site_id' => $this->site->id,
'issuer' => $issuer,
'expires_at' => $expiresAt,
'checked_at' => $checkedAt,
]);
// Check if certificate expires within 30 days
$daysUntilExpiry = $checkedAt->diffInDays($expiresAt, false);
if ($daysUntilExpiry < 30 && $daysUntilExpiry >= 0) {
// Send notification to user
$this->site->user->notify(new SslExpiryAlert(
$this->site,
$issuer,
$expiresAt,
(int) $daysUntilExpiry,
false
));
Log::warning("SSL certificate expiring soon for site {$this->site->id}: {$daysUntilExpiry} days remaining");
} elseif ($daysUntilExpiry < 0) {
// Certificate already expired
$this->site->user->notify(new SslExpiryAlert(
$this->site,
$issuer,
$expiresAt,
(int) $daysUntilExpiry,
true
));
Log::error("SSL certificate expired for site {$this->site->id}");
}
Log::info("SSL check completed for site {$this->site->id} ({$this->site->url}): Issuer={$issuer}, Expires={$expiresAt->format('Y-m-d')}");
} catch (\Exception $e) {
Log::error("SSL check failed for site {$this->site->id}: {$e->getMessage()}");
}
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Jobs;
use App\Models\Site;
use App\Models\UptimeCheck;
use App\Notifications\UptimeAlert;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use Illuminate\Bus\Batchable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
class CheckUptime implements ShouldQueue
{
use Queueable, Batchable;
/**
* Create a new job instance.
*/
public function __construct(
public Site $site
) {
}
/**
* Execute the job.
*/
public function handle(): void
{
$client = new Client([
'timeout' => 5,
'connect_timeout' => 5,
'allow_redirects' => [
'max' => 5,
'track_redirects' => true,
],
'http_errors' => false, // Don't throw exceptions on 4xx/5xx responses
]);
$statusCode = null;
$responseTime = null;
$startTime = microtime(true);
try {
// Validate URL format
if (!filter_var($this->site->url, FILTER_VALIDATE_URL)) {
Log::warning("Invalid URL format for site {$this->site->id}: {$this->site->url}");
$statusCode = null;
$responseTime = null;
} else {
// Send HEAD request for faster checks
$response = $client->head($this->site->url);
$endTime = microtime(true);
$responseTime = (int) round(($endTime - $startTime) * 1000); // Convert to milliseconds
$statusCode = $response->getStatusCode();
}
} catch (ConnectException $e) {
// Connection timeout or DNS failure
Log::info("Connection failed for site {$this->site->id}: {$e->getMessage()}");
$statusCode = null;
$responseTime = null;
} catch (RequestException $e) {
// HTTP error occurred
$endTime = microtime(true);
$responseTime = (int) round(($endTime - $startTime) * 1000);
$statusCode = $e->getResponse() ? $e->getResponse()->getStatusCode() : null;
Log::info("Request failed for site {$this->site->id}: HTTP {$statusCode}");
} catch (\Exception $e) {
// Any other exception
Log::error("Unexpected error checking site {$this->site->id}: {$e->getMessage()}");
$statusCode = null;
$responseTime = null;
}
// Store the uptime check result
UptimeCheck::create([
'site_id' => $this->site->id,
'status_code' => $statusCode,
'response_time_ms' => $responseTime,
'checked_at' => now(),
]);
// Send notification if site is down (status >= 400 or unreachable)
if ($statusCode === null || $statusCode >= 400) {
$this->site->user->notify(new UptimeAlert(
$this->site,
$statusCode,
$responseTime
));
Log::warning("Downtime alert sent for site {$this->site->id}: HTTP {$statusCode}");
}
Log::info("Uptime check completed for site {$this->site->id} ({$this->site->url}): HTTP {$statusCode}, {$responseTime}ms");
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BrokenLink extends Model
{
const UPDATED_AT = null;
protected $fillable = [
'site_id',
'url',
'status_code',
'last_checked_at',
];
protected $casts = [
'last_checked_at' => 'datetime',
];
public function site(): BelongsTo
{
return $this->belongsTo(Site::class);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Notification extends Model
{
const UPDATED_AT = null;
protected $fillable = [
'user_id',
'type',
'message',
'is_read',
];
protected $casts = [
'is_read' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Site extends Model
{
protected $fillable = [
'user_id',
'url',
'name',
'monitoring_enabled',
];
protected $casts = [
'monitoring_enabled' => 'boolean',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function uptimeChecks(): HasMany
{
return $this->hasMany(UptimeCheck::class);
}
public function sslCertificates(): HasMany
{
return $this->hasMany(SslCertificate::class);
}
public function brokenLinks(): HasMany
{
return $this->hasMany(BrokenLink::class);
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SslCertificate extends Model
{
const UPDATED_AT = null;
protected $fillable = [
'site_id',
'issuer',
'expires_at',
'checked_at',
];
protected $casts = [
'expires_at' => 'datetime',
'checked_at' => 'datetime',
];
public function site(): BelongsTo
{
return $this->belongsTo(Site::class);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UptimeCheck extends Model
{
const UPDATED_AT = null;
protected $fillable = [
'site_id',
'status_code',
'response_time_ms',
'checked_at',
];
protected $casts = [
'checked_at' => 'datetime',
];
public function site(): BelongsTo
{
return $this->belongsTo(Site::class);
}
}
+13
View File
@@ -4,6 +4,7 @@ namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -21,6 +22,7 @@ class User extends Authenticatable
'name',
'email',
'password',
'notification_preferences',
];
/**
@@ -43,6 +45,17 @@ class User extends Authenticatable
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'notification_preferences' => 'array',
];
}
public function sites(): HasMany
{
return $this->hasMany(Site::class);
}
public function notifications(): HasMany
{
return $this->hasMany(Notification::class);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Notifications;
use App\Models\Site;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class BrokenLinkAlert extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public Site $site,
public array $brokenLinks
) {
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
$preferences = $notifiable->notification_preferences ?? [];
$channels = $preferences['broken_links'] ?? ['mail'];
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$brokenLinkCount = count($this->brokenLinks);
$message = (new MailMessage)
->warning()
->subject("⚠️ Broken Links Detected: {$this->site->name}")
->line("We detected **{$brokenLinkCount}** broken link(s) on **{$this->site->name}**.")
->line("Site URL: {$this->site->url}")
->line('')
->line('**Broken Links:**');
// Add up to 10 broken links to the email
$displayLimit = min(10, $brokenLinkCount);
foreach (array_slice($this->brokenLinks, 0, $displayLimit) as $link) {
$message->line("• {$link['url']} (HTTP {$link['status_code']})");
}
if ($brokenLinkCount > $displayLimit) {
$remaining = $brokenLinkCount - $displayLimit;
$message->line("... and {$remaining} more");
}
$message->line('')
->action('View All Broken Links', url("/sites/{$this->site->id}/broken-links"))
->line('Please fix these broken links to improve user experience and SEO.');
return $message;
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'site_id' => $this->site->id,
'site_name' => $this->site->name,
'site_url' => $this->site->url,
'broken_link_count' => count($this->brokenLinks),
'broken_links' => $this->brokenLinks,
];
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace App\Notifications;
use App\Models\Site;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Messages\SlackMessage;
use Illuminate\Notifications\Notification;
class SslExpiryAlert extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public Site $site,
public string $issuer,
public Carbon $expiresAt,
public int $daysRemaining,
public bool $isExpired = false
) {
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
$preferences = $notifiable->notification_preferences ?? [];
$channels = $preferences['ssl'] ?? ['mail'];
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$message = (new MailMessage)
->subject($this->isExpired
? "🔴 SSL Certificate Expired: {$this->site->name}"
: "⚠️ SSL Certificate Expiring Soon: {$this->site->name}")
->line($this->isExpired
? "The SSL certificate for **{$this->site->name}** has **expired**."
: "The SSL certificate for **{$this->site->name}** is expiring soon.")
->line("URL: {$this->site->url}")
->line("Issuer: {$this->issuer}")
->line($this->isExpired
? "Expired on: {$this->expiresAt->format('Y-m-d')}"
: "Expires on: {$this->expiresAt->format('Y-m-d')} ({$this->daysRemaining} days remaining)")
->action('View Site Details', url("/sites/{$this->site->id}"));
if ($this->isExpired) {
$message->error();
$message->line('⚠️ Your site visitors may see security warnings. Please renew the certificate immediately.');
} else {
$message->warning();
$message->line('Please renew the certificate before it expires to avoid service disruption.');
}
return $message;
}
/**
* Get the Slack representation of the notification.
*/
public function toSlack(object $notifiable): SlackMessage
{
$color = $this->isExpired ? 'danger' : 'warning';
$emoji = $this->isExpired ? '🔴' : '⚠️';
return (new SlackMessage)
->from('Site Monitor')
->to(config('services.slack.channel'))
->content($this->isExpired
? "{$emoji} SSL Certificate Expired!"
: "{$emoji} SSL Certificate Expiring Soon!")
->attachment(function ($attachment) use ($color) {
$attachment
->color($color)
->title($this->site->name, url("/sites/{$this->site->id}"))
->fields([
'URL' => $this->site->url,
'Issuer' => $this->issuer,
'Expires' => $this->expiresAt->format('Y-m-d'),
'Status' => $this->isExpired
? 'EXPIRED'
: "{$this->daysRemaining} days remaining",
]);
});
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'site_id' => $this->site->id,
'site_name' => $this->site->name,
'site_url' => $this->site->url,
'issuer' => $this->issuer,
'expires_at' => $this->expiresAt->toDateTimeString(),
'days_remaining' => $this->daysRemaining,
'is_expired' => $this->isExpired,
];
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
namespace App\Notifications;
use App\Models\Site;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class UptimeAlert extends Notification implements ShouldQueue
{
use Queueable;
/**
* Create a new notification instance.
*/
public function __construct(
public Site $site,
public ?int $statusCode,
public ?int $responseTime
) {
}
/**
* Get the notification's delivery channels.
*
* @return array<int, string>
*/
public function via(object $notifiable): array
{
$preferences = $notifiable->notification_preferences ?? [];
$channels = $preferences['uptime'] ?? ['mail'];
return $channels;
}
/**
* Get the mail representation of the notification.
*/
public function toMail(object $notifiable): MailMessage
{
$status = $this->statusCode ? "returned HTTP {$this->statusCode}" : "is unreachable";
return (new MailMessage)
->error()
->subject("⚠️ Site Down Alert: {$this->site->name}")
->line("Your site **{$this->site->name}** is experiencing downtime.")
->line("URL: {$this->site->url}")
->line("Status: {$status}")
->line("Response Time: " . ($this->responseTime ? "{$this->responseTime}ms" : "N/A"))
->line("Detected at: " . now()->format('Y-m-d H:i:s'))
->action('View Site Details', url("/sites/{$this->site->id}"))
->line('Please investigate the issue as soon as possible.');
}
/**
* Get the array representation of the notification.
*
* @return array<string, mixed>
*/
public function toArray(object $notifiable): array
{
return [
'site_id' => $this->site->id,
'site_name' => $this->site->name,
'site_url' => $this->site->url,
'status_code' => $this->statusCode,
'response_time' => $this->responseTime,
];
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Policies;
use App\Models\Notification;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class NotificationPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return false;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Notification $notification): bool
{
return $user->id === $notification->user_id;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Notification $notification): bool
{
return false;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Notification $notification): bool
{
return false;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Notification $notification): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Notification $notification): bool
{
return false;
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Policies;
use App\Models\Site;
use App\Models\User;
use Illuminate\Auth\Access\Response;
class SitePolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return true;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Site $site): bool
{
return $user->id === $site->user_id;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return true;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Site $site): bool
{
return $user->id === $site->user_id;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Site $site): bool
{
return $user->id === $site->user_id;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Site $site): bool
{
return $user->id === $site->user_id;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Site $site): bool
{
return $user->id === $site->user_id;
}
}