first commit from hostslim

This commit is contained in:
2026-03-24 21:12:21 +01:00
commit 20fee7784c
252 changed files with 27385 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers;
use App\Models\Alert;
use App\Models\Website;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AlertController extends Controller
{
public function index(Website $website): View
{
$this->authorize('view', $website);
$alerts = $website->alerts()->orderBy('name')->get();
$unreadCount = $website->alerts()
->whereHas('notifications', fn ($q) => $q->where('is_read', false))
->count();
return view('alerts.index', [
'website' => $website,
'alerts' => $alerts,
'unreadCount' => $unreadCount,
]);
}
public function create(Website $website): View
{
$this->authorize('view', $website);
return view('alerts.create', ['website' => $website]);
}
public function store(Request $request, Website $website): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'name' => 'required|string|max:255',
'metric' => 'required|in:' . implode(',', array_keys(Alert::$metrics)),
'condition' => 'required|in:' . implode(',', array_keys(Alert::$conditions)),
'threshold' => 'required|numeric|min:0',
'period' => 'required|in:daily,hourly',
'channel' => 'required|in:email,in_app,both',
]);
$website->alerts()->create($validated);
return redirect()
->route('alerts.index', $website)
->with('success', 'Alert created successfully.');
}
public function edit(Website $website, Alert $alert): View
{
$this->authorize('view', $website);
return view('alerts.edit', [
'website' => $website,
'alert' => $alert,
]);
}
public function update(Request $request, Website $website, Alert $alert): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'name' => 'required|string|max:255',
'metric' => 'required|in:' . implode(',', array_keys(Alert::$metrics)),
'condition' => 'required|in:' . implode(',', array_keys(Alert::$conditions)),
'threshold' => 'required|numeric|min:0',
'period' => 'required|in:daily,hourly',
'channel' => 'required|in:email,in_app,both',
]);
$alert->update($validated);
return redirect()
->route('alerts.index', $website)
->with('success', 'Alert updated successfully.');
}
public function destroy(Website $website, Alert $alert): RedirectResponse
{
$this->authorize('view', $website);
$alert->delete();
return redirect()
->route('alerts.index', $website)
->with('success', 'Alert deleted.');
}
public function notifications(Website $website): View
{
$this->authorize('view', $website);
$notifications = \App\Models\AlertNotification::whereHas('alert', fn ($q) => $q->where('website_id', $website->id))
->with('alert')
->orderByDesc('created_at')
->paginate(20);
// Mark as read
\App\Models\AlertNotification::whereHas('alert', fn ($q) => $q->where('website_id', $website->id))
->where('is_read', false)
->update(['is_read' => true]);
return view('alerts.notifications', [
'website' => $website,
'notifications' => $notifications,
]);
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers;
use App\Models\Annotation;
use App\Models\Website;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AnnotationController extends Controller
{
public function index(Website $website): View
{
$this->authorize('view', $website);
return view('annotations.index', [
'website' => $website,
'annotations' => $website->annotations()->with('user')->orderByDesc('date')->paginate(25),
]);
}
public function store(Request $request, Website $website): RedirectResponse|JsonResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'date' => 'required|date',
'text' => 'required|string|max:500',
'category' => 'required|in:' . implode(',', array_keys(Annotation::$categories)),
]);
$annotation = $website->annotations()->create([
...$validated,
'user_id' => $request->user()->id,
]);
if ($request->expectsJson()) {
return response()->json($annotation->load('user'), 201);
}
return redirect()
->route('annotations.index', $website)
->with('success', 'Annotation added.');
}
public function destroy(Request $request, Website $website, Annotation $annotation): RedirectResponse|JsonResponse
{
$this->authorize('view', $website);
$annotation->delete();
if ($request->expectsJson()) {
return response()->json(['ok' => true]);
}
return redirect()
->route('annotations.index', $website)
->with('success', 'Annotation deleted.');
}
/**
* JSON endpoint for chart overlays — returns annotations within a date range.
*/
public function forRange(Request $request, Website $website): JsonResponse
{
$this->authorize('view', $website);
$request->validate([
'from' => 'required|date',
'to' => 'required|date|after_or_equal:from',
]);
$annotations = $website->annotations()
->with('user:id,name')
->whereBetween('date', [$request->from, $request->to])
->orderBy('date')
->get()
->map(fn (Annotation $a) => [
'id' => $a->id,
'date' => $a->date->toDateString(),
'text' => $a->text,
'category' => $a->category,
'user' => $a->user->name,
]);
return response()->json($annotations);
}
}
@@ -0,0 +1,133 @@
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\V1\EventResource;
use App\Http\Resources\V1\PageResource;
use App\Http\Resources\V1\ReferrerResource;
use App\Http\Resources\V1\StatsResource;
use App\Http\Resources\V1\VisitorBreakdownResource;
use App\Http\Resources\V1\WebsiteResource;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Carbon;
class WebsiteController extends Controller
{
public function index(Request $request): AnonymousResourceCollection
{
$websites = $request->user()->websites()->orderBy('name')->get();
return WebsiteResource::collection($websites);
}
public function stats(Request $request, Website $website): StatsResource
{
$this->authorizeWebsite($request, $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return new StatsResource([
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'metrics' => $service->summary(),
'timeseries' => $service->visitorsOverTime(),
]);
}
public function pages(Request $request, Website $website): AnonymousResourceCollection
{
$this->authorizeWebsite($request, $website);
[$from, $to] = $this->parseDateRange($request);
$limit = $this->parseLimit($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return PageResource::collection($service->topPages($limit));
}
public function referrers(Request $request, Website $website): AnonymousResourceCollection
{
$this->authorizeWebsite($request, $website);
[$from, $to] = $this->parseDateRange($request);
$limit = $this->parseLimit($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return ReferrerResource::collection($service->trafficSources($limit));
}
public function events(Request $request, Website $website): AnonymousResourceCollection
{
$this->authorizeWebsite($request, $website);
[$from, $to] = $this->parseDateRange($request);
$limit = $this->parseLimit($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return EventResource::collection($service->eventsOverview($limit));
}
public function visitors(Request $request, Website $website): VisitorBreakdownResource
{
$this->authorizeWebsite($request, $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return new VisitorBreakdownResource([
'from' => $from->toDateString(),
'to' => $to->toDateString(),
'countries' => $service->visitorsByCountry(20)->map(fn ($r) => [
'country' => $r->country,
'visitors' => $r->visitors,
]),
'browsers' => $service->visitorsByBrowser(10)->map(fn ($r) => [
'browser' => $r->browser,
'visitors' => $r->visitors,
]),
'operating_systems' => $service->visitorsByOS(10)->map(fn ($r) => [
'os' => $r->os,
'visitors' => $r->visitors,
]),
'device_types' => $service->visitorsByDeviceType(5)->map(fn ($r) => [
'device_type' => $r->device_type,
'visitors' => $r->visitors,
]),
'languages' => $service->visitorsByLanguage(10)->map(fn ($r) => [
'language' => $r->language,
'visitors' => $r->visitors,
]),
'new_vs_returning' => $service->newVsReturning(),
]);
}
private function authorizeWebsite(Request $request, Website $website): void
{
if ($website->user_id !== $request->user()->id) {
abort(403, 'You do not own this website.');
}
}
private function parseDateRange(Request $request): array
{
$request->validate([
'from' => 'nullable|date|before_or_equal:to',
'to' => 'nullable|date|after_or_equal:from',
]);
$from = $request->has('from')
? Carbon::parse($request->get('from'))
: Carbon::today()->subDays(6);
$to = $request->has('to')
? Carbon::parse($request->get('to'))
: Carbon::today();
return [$from, $to];
}
private function parseLimit(Request $request): int
{
return min((int) $request->get('limit', 50), 200);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ApiTokenController extends Controller
{
public function index(Request $request): View
{
return view('api-tokens.index', [
'tokens' => $request->user()->tokens()->orderByDesc('created_at')->get(),
]);
}
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => 'required|string|max:255',
]);
$token = $request->user()->createToken($request->name);
return redirect()
->route('api-tokens.index')
->with('newToken', $token->plainTextToken)
->with('success', 'API token created successfully.');
}
public function destroy(Request $request, string $tokenId): RedirectResponse
{
$request->user()->tokens()->where('id', $tokenId)->delete();
return redirect()
->route('api-tokens.index')
->with('success', 'API token revoked.');
}
}
@@ -0,0 +1,47 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function create(): View
{
return view('auth.login');
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: view('auth.verify-email');
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
event(new Registered($user));
Auth::login($user);
return redirect(route('dashboard', absolute: false));
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessTrackingPayload;
use App\Models\Website;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Carbon;
use Jaybizzle\CrawlerDetect\CrawlerDetect;
class CollectController extends Controller
{
public function store(Request $request): JsonResponse
{
if ($this->isBot($request)) {
$this->log("ISBOT");
return response()->json(['ok' => false, 'reason' => 'bot'], 200);
}
$payload = $request->validate([
'tid' => 'required|string',
'vid' => 'required|string|max:64',
'sid' => 'required|string|max:64',
'type' => 'required|in:pageview,event',
'url' => 'nullable|string|max:2048',
'path' => 'nullable|string|max:2048',
'title' => 'nullable|string|max:512',
'referrer' => 'nullable|string|max:2048',
'event_name' => 'nullable|string|max:255',
'event_category' => 'nullable|string|max:255',
'event_action' => 'nullable|string|max:255',
'event_label' => 'nullable|string|max:255',
'event_value' => 'nullable|numeric',
'event_properties' => 'nullable|array',
'sr' => 'nullable|string|max:20',
'lang' => 'nullable|string|max:10',
'ua' => 'nullable|string|max:512',
'utm_source' => 'nullable|string|max:255',
'utm_medium' => 'nullable|string|max:255',
'utm_campaign' => 'nullable|string|max:255',
'utm_term' => 'nullable|string|max:255',
'utm_content' => 'nullable|string|max:255',
'ref_domain' => 'nullable|string|max:255',
'device' => 'nullable|string|max:20',
'browser' => 'nullable|string|max:50',
'browser_version' => 'nullable|string|max:20',
'os' => 'nullable|string|max:50',
'os_version' => 'nullable|string|max:20',
'time_on_page' => 'nullable|integer|min:0',
'is_new_session' => 'nullable|boolean',
]);
if (! $this->isDomainValid($payload)) {
$this->log("Domain is invalid");
return response()->json(['ok' => false, 'reason' => 'domain_mismatch'], 400);
}
$this->log("dispatch job");
ProcessTrackingPayload::dispatch($payload, $request->ip(), Carbon::now());
return response()->json(['ok' => true]);
}
private function log($message)
{
$path = '/home/libremet/domains/libremetrics.com/engine/storage/logs/mihai.log';
$message = date('Y-m-d H:i:s') . "\n" . $message . "\n\n";
file_put_contents($path, $message);
}
/**
* 1x1 transparent GIF tracking pixel for no-JS fallback.
* Usage: <img src="/api/pixel?tid=TA-XXX" width="1" height="1" alt="">
*/
public function pixel(Request $request): Response
{
if ($this->isBot($request)) {
return $this->gifResponse();
}
$tid = $request->get('tid');
if ($tid) {
$payload = [
'tid' => $tid,
'vid' => $request->get('vid', md5($request->ip() . $request->userAgent())),
'sid' => $request->get('sid', md5($request->ip() . $request->userAgent() . date('Y-m-d-H'))),
'type' => 'pageview',
'url' => $request->get('url', $request->header('Referer')),
'path' => $request->get('path', parse_url($request->header('Referer', '/'), PHP_URL_PATH)),
'referrer' => $request->header('Referer'),
];
ProcessTrackingPayload::dispatch($payload, $request->ip(), Carbon::now());
}
return $this->gifResponse();
}
private function isBot(Request $request): bool
{
return (new CrawlerDetect)->isCrawler($request->userAgent());
}
private function isDomainValid(array $payload): bool
{
$url = $payload['url'] ?? null;
if (! $url) {
return true;
}
$host = parse_url($url, PHP_URL_HOST);
if (! $host) {
return true;
}
$website = Website::where('tracking_id', $payload['tid'])->first();
if (! $website) {
return true; // let the job handle the 404
}
$domain = $website->domain;
// Allow exact match or subdomain match (e.g. www.example.com matches example.com)
return $host === $domain || str_ends_with($host, '.' . $domain);
}
private function gifResponse(): Response
{
// 1x1 transparent GIF
$gif = base64_decode('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
return response($gif, 200, [
'Content-Type' => 'image/gif',
'Content-Length' => strlen($gif),
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
]);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Concerns;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
trait ParsesDateRange
{
private function parseDateRange(Request $request): array
{
$range = $request->get('range', '7d');
$to = Carbon::today();
$from = match ($range) {
'today' => Carbon::today(),
'7d' => Carbon::today()->subDays(6),
'30d' => Carbon::today()->subDays(29),
'90d' => Carbon::today()->subDays(89),
'custom' => Carbon::parse($request->get('from', Carbon::today()->subDays(6)->toDateString())),
default => Carbon::today()->subDays(6),
};
if ($range === 'custom' && $request->has('to')) {
$to = Carbon::parse($request->get('to'));
}
if ($from->gt($to)) {
[$from, $to] = [$to, $from];
}
return [$from, $to];
}
private function previousPeriod(Carbon $from, Carbon $to): array
{
$days = $from->diffInDays($to) + 1;
$prevTo = $from->copy()->subDay();
$prevFrom = $prevTo->copy()->subDays($days - 1);
return [$prevFrom, $prevTo];
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Concerns;
use App\Models\Segment;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
trait ParsesSegment
{
private function applySegment(Request $request, Website $website, AnalyticsQueryService $service): AnalyticsQueryService
{
$segmentId = $request->get('segment');
if (! $segmentId) {
return $service;
}
$segment = $website->segments()->find($segmentId);
if (! $segment) {
return $service;
}
return $service->withSegment($segment->matchingVisitorIds());
}
private function activeSegment(Request $request, Website $website): ?Segment
{
$segmentId = $request->get('segment');
if (! $segmentId) {
return null;
}
return $website->segments()->find($segmentId);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\ParsesDateRange;
use App\Models\DashboardWidget;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class CustomDashboardController extends Controller
{
use ParsesDateRange;
public function show(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
$widgets = $website->dashboardWidgets()->orderBy('sort_order')->get();
$widgetData = [];
foreach ($widgets as $widget) {
$widgetData[$widget->id] = $this->resolveWidgetData($widget, $service);
}
return view('custom-dashboard.show', [
'website' => $website,
'widgets' => $widgets,
'widgetData' => $widgetData,
'from' => $from,
'to' => $to,
]);
}
public function manage(Website $website): View
{
$this->authorize('view', $website);
return view('custom-dashboard.manage', [
'website' => $website,
'widgets' => $website->dashboardWidgets()->orderBy('sort_order')->get(),
'types' => DashboardWidget::$types,
'metrics' => DashboardWidget::$metrics,
]);
}
public function store(Request $request, Website $website): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'title' => 'required|string|max:255',
'widget_type' => 'required|string|in:' . implode(',', array_keys(DashboardWidget::$types)),
'size' => 'required|in:full,half,third',
'config' => 'nullable|array',
'config.metric' => 'nullable|string',
'config.limit' => 'nullable|integer|min:1|max:50',
]);
$maxSort = $website->dashboardWidgets()->max('sort_order') ?? 0;
$validated['sort_order'] = $maxSort + 1;
$website->dashboardWidgets()->create($validated);
return redirect()
->route('custom-dashboard.manage', $website)
->with('success', 'Widget added.');
}
public function reorder(Request $request, Website $website): RedirectResponse
{
$this->authorize('view', $website);
$request->validate([
'order' => 'required|array',
'order.*' => 'integer|exists:dashboard_widgets,id',
]);
foreach ($request->order as $position => $widgetId) {
$website->dashboardWidgets()->where('id', $widgetId)->update(['sort_order' => $position]);
}
return redirect()
->route('custom-dashboard.manage', $website)
->with('success', 'Widget order updated.');
}
public function destroy(Website $website, DashboardWidget $widget): RedirectResponse
{
$this->authorize('view', $website);
$widget->delete();
return redirect()
->route('custom-dashboard.manage', $website)
->with('success', 'Widget removed.');
}
private function resolveWidgetData(DashboardWidget $widget, AnalyticsQueryService $service): mixed
{
$config = $widget->config ?? [];
$limit = $config['limit'] ?? 5;
return match ($widget->widget_type) {
'metric' => $service->summary()[$config['metric'] ?? 'page_views'] ?? 0,
'chart' => $service->visitorsOverTime(),
'top_pages' => $service->topPages($limit),
'top_sources' => $service->trafficSources($limit),
'events' => $service->eventsOverview($limit),
'conversions' => $service->conversionsReport(),
'new_vs_returning' => $service->newVsReturning(),
'browsers' => $service->visitorsByBrowser($limit),
'countries' => $service->visitorsByCountry($limit),
default => null,
};
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\ParsesDateRange;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class DashboardController extends Controller
{
use ParsesDateRange;
public function show(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
[$prevFrom, $prevTo] = $this->previousPeriod($from, $to);
$service = AnalyticsQueryService::for($website, $from, $to);
$prevService = AnalyticsQueryService::for($website, $prevFrom, $prevTo);
$metrics = $service->summary();
$prevMetrics = $prevService->summary();
$chartData = $service->visitorsOverTime();
$activeVisitors = AnalyticsQueryService::activeVisitors($website);
$goalConversions = $service->conversionsReport();
$goalChartData = $service->goalCompletionsOverTime();
$topPages = $service->topPages(5);
$topSources = $service->trafficSources(5);
return view('dashboard.show', compact(
'website', 'metrics', 'prevMetrics', 'chartData', 'activeVisitors',
'goalConversions', 'goalChartData', 'from', 'to',
'topPages', 'topSources',
));
}
public function realtime(Website $website): JsonResponse
{
$this->authorize('view', $website);
return response()->json([
'active' => AnalyticsQueryService::activeVisitors($website),
]);
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\ParsesDateRange;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ExportController extends Controller
{
use ParsesDateRange;
public function export(Request $request, Website $website, string $report): StreamedResponse
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
[$headers, $rows] = match ($report) {
'pages' => $this->pagesExport($service),
'landing-pages' => $this->landingPagesExport($service),
'exit-pages' => $this->exitPagesExport($service),
'sources' => $this->sourcesExport($service),
'referrers' => $this->referrersExport($service),
'campaigns' => $this->campaignsExport($service),
'events' => $this->eventsExport($service),
'countries' => $this->countriesExport($service),
'browsers' => $this->browsersExport($service),
'languages' => $this->languagesExport($service),
'summary' => $this->summaryExport($service, $from, $to),
default => abort(404, 'Unknown report type.'),
};
$filename = "{$website->domain}_{$report}_{$from->format('Y-m-d')}_to_{$to->format('Y-m-d')}.csv";
return $this->streamCsv($filename, $headers, $rows);
}
private function streamCsv(string $filename, array $headers, iterable $rows): StreamedResponse
{
return response()->streamDownload(function () use ($headers, $rows) {
$handle = fopen('php://output', 'w');
fputcsv($handle, $headers);
foreach ($rows as $row) {
fputcsv($handle, $row);
}
fclose($handle);
}, $filename, [
'Content-Type' => 'text/csv',
]);
}
private function pagesExport(AnalyticsQueryService $service): array
{
$data = $service->topPages(500);
return [
['Path', 'Views', 'Unique Views', 'Avg Time on Page (s)'],
$data->map(fn ($r) => [
$r->path,
$r->views,
$r->unique_views,
round($r->avg_time ?? 0, 1),
]),
];
}
private function landingPagesExport(AnalyticsQueryService $service): array
{
$data = $service->landingPages(500);
return [
['Path', 'Entrances', 'Bounce Rate (%)'],
$data->map(fn ($r) => [
$r->entry_page ?? $r->path ?? '—',
$r->entrances ?? $r->sessions ?? 0,
$r->bounce_rate ?? 0,
]),
];
}
private function exitPagesExport(AnalyticsQueryService $service): array
{
$data = $service->exitPages(500);
return [
['Path', 'Exits'],
$data->map(fn ($r) => [
$r->exit_page ?? $r->path ?? '—',
$r->exits ?? 0,
]),
];
}
private function sourcesExport(AnalyticsQueryService $service): array
{
$data = $service->trafficSources(500);
return [
['Source', 'Sessions', 'Bounces', 'Avg Duration (s)', 'Avg Pages'],
$data->map(fn ($r) => [
$r->source,
$r->sessions,
$r->bounces,
round($r->avg_duration ?? 0, 1),
round($r->avg_pages ?? 0, 1),
]),
];
}
private function referrersExport(AnalyticsQueryService $service): array
{
$data = $service->referrers(500);
return [
['Referrer Domain', 'Sessions', 'Bounces', 'Avg Duration (s)'],
$data->map(fn ($r) => [
$r->referrer_domain,
$r->sessions,
$r->bounces,
round($r->avg_duration ?? 0, 1),
]),
];
}
private function campaignsExport(AnalyticsQueryService $service): array
{
$data = $service->campaigns(500);
return [
['UTM Source', 'UTM Medium', 'UTM Campaign', 'Sessions', 'Bounces', 'Avg Duration (s)'],
$data->map(fn ($r) => [
$r->utm_source,
$r->utm_medium,
$r->utm_campaign,
$r->sessions,
$r->bounces,
round($r->avg_duration ?? 0, 1),
]),
];
}
private function eventsExport(AnalyticsQueryService $service): array
{
$data = $service->eventsOverview(500);
return [
['Event Name', 'Category', 'Total', 'Unique Visitors', 'Total Value'],
$data->map(fn ($r) => [
$r->name,
$r->category,
$r->total,
$r->unique_visitors,
$r->total_value,
]),
];
}
private function countriesExport(AnalyticsQueryService $service): array
{
$data = $service->visitorsByCountry(500);
return [
['Country', 'Visitors'],
$data->map(fn ($r) => [$r->country, $r->visitors]),
];
}
private function browsersExport(AnalyticsQueryService $service): array
{
$data = $service->visitorsByBrowser(100);
return [
['Browser', 'Visitors'],
$data->map(fn ($r) => [$r->browser, $r->visitors]),
];
}
private function languagesExport(AnalyticsQueryService $service): array
{
$data = $service->visitorsByLanguage(100);
return [
['Language', 'Visitors'],
$data->map(fn ($r) => [$r->language, $r->visitors]),
];
}
private function summaryExport(AnalyticsQueryService $service, $from, $to): array
{
$summary = $service->summary();
return [
['Metric', 'Value'],
collect([
['Period', $from->format('Y-m-d') . ' to ' . $to->format('Y-m-d')],
['Page Views', $summary['page_views']],
['Unique Visitors', $summary['visitors']],
['Sessions', $summary['sessions']],
['Bounce Rate (%)', $summary['bounce_rate']],
['Avg Duration (s)', $summary['avg_duration']],
['Pages / Session', $summary['avg_pages']],
]),
];
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\ParsesDateRange;
use App\Models\Funnel;
use App\Models\Website;
use App\Services\FunnelAnalysisService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class FunnelController extends Controller
{
use ParsesDateRange;
public function index(Website $website): View
{
$this->authorize('view', $website);
return view('funnels.index', [
'website' => $website,
'funnels' => $website->funnels()->orderBy('name')->get(),
]);
}
public function create(Website $website): View
{
$this->authorize('view', $website);
return view('funnels.create', [
'website' => $website,
]);
}
public function store(Request $request, Website $website): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'name' => 'required|string|max:255',
'steps' => 'required|array|min:2',
'steps.*.type' => 'required|in:pageview,event',
'steps.*.value' => 'required|string|max:255',
'steps.*.label' => 'required|string|max:255',
]);
$website->funnels()->create($validated);
return redirect()
->route('funnels.index', $website)
->with('success', 'Funnel created successfully.');
}
public function show(Request $request, Website $website, Funnel $funnel): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = new FunnelAnalysisService($funnel, $from, $to);
return view('funnels.show', [
'website' => $website,
'funnel' => $funnel,
'steps' => $service->analyze(),
'from' => $from,
'to' => $to,
]);
}
public function edit(Website $website, Funnel $funnel): View
{
$this->authorize('view', $website);
return view('funnels.edit', [
'website' => $website,
'funnel' => $funnel,
]);
}
public function update(Request $request, Website $website, Funnel $funnel): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'name' => 'required|string|max:255',
'steps' => 'required|array|min:2',
'steps.*.type' => 'required|in:pageview,event',
'steps.*.value' => 'required|string|max:255',
'steps.*.label' => 'required|string|max:255',
]);
$funnel->update($validated);
return redirect()
->route('funnels.show', [$website, $funnel])
->with('success', 'Funnel updated successfully.');
}
public function destroy(Website $website, Funnel $funnel): RedirectResponse
{
$this->authorize('view', $website);
$funnel->delete();
return redirect()
->route('funnels.index', $website)
->with('success', 'Funnel deleted.');
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreGoalRequest;
use App\Http\Requests\UpdateGoalRequest;
use App\Models\Goal;
use App\Models\Website;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class GoalController extends Controller
{
public function index(Website $website): View
{
$this->authorize('view', $website);
$goals = $website->goals()->latest()->get();
return view('goals.index', compact('website', 'goals'));
}
public function create(Website $website): View
{
$this->authorize('view', $website);
return view('goals.create', compact('website'));
}
public function store(StoreGoalRequest $request, Website $website): RedirectResponse
{
$this->authorize('view', $website);
$data = $request->validated();
$data['is_active'] = $request->boolean('is_active', true);
$website->goals()->create($data);
return redirect()
->route('goals.index', $website)
->with('success', 'Goal created successfully.');
}
public function edit(Website $website, Goal $goal): View
{
$this->authorize('view', $website);
abort_unless($goal->website_id === $website->id, 404);
return view('goals.edit', compact('website', 'goal'));
}
public function update(UpdateGoalRequest $request, Website $website, Goal $goal): RedirectResponse
{
$this->authorize('view', $website);
abort_unless($goal->website_id === $website->id, 404);
$data = $request->validated();
$data['is_active'] = $request->boolean('is_active', true);
$goal->update($data);
return redirect()
->route('goals.index', $website)
->with('success', 'Goal updated successfully.');
}
public function destroy(Website $website, Goal $goal): RedirectResponse
{
$this->authorize('view', $website);
abort_unless($goal->website_id === $website->id, 404);
$goal->delete();
return redirect()
->route('goals.index', $website)
->with('success', 'Goal deleted successfully.');
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}
+214
View File
@@ -0,0 +1,214 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Concerns\ParsesDateRange;
use App\Models\Website;
use App\Services\AnalyticsQueryService;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ReportController extends Controller
{
use ParsesDateRange;
// ---- Audience ----
public function geographic(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return view('reports.audience.geographic', [
'website' => $website,
'from' => $from,
'to' => $to,
'countries' => $service->visitorsByCountry(),
'cities' => $service->visitorsByCity(),
]);
}
public function technology(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return view('reports.audience.technology', [
'website' => $website,
'from' => $from,
'to' => $to,
'browsers' => $service->visitorsByBrowser(),
'operatingSystems' => $service->visitorsByOS(),
'devices' => $service->visitorsByDeviceType(),
'resolutions' => $service->visitorsByScreenResolution(),
]);
}
public function language(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return view('reports.audience.language', [
'website' => $website,
'from' => $from,
'to' => $to,
'languages' => $service->visitorsByLanguage(),
]);
}
public function newVsReturning(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return view('reports.audience.new-vs-returning', [
'website' => $website,
'from' => $from,
'to' => $to,
'data' => $service->newVsReturning(),
]);
}
// ---- Acquisition ----
public function sources(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return view('reports.acquisition.sources', [
'website' => $website,
'from' => $from,
'to' => $to,
'sources' => $service->trafficSources(),
]);
}
public function campaigns(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return view('reports.acquisition.campaigns', [
'website' => $website,
'from' => $from,
'to' => $to,
'campaigns' => $service->campaigns(),
]);
}
public function referrers(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return view('reports.acquisition.referrers', [
'website' => $website,
'from' => $from,
'to' => $to,
'referrers' => $service->referrers(),
]);
}
// ---- Behavior ----
public function pages(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return view('reports.behavior.pages', [
'website' => $website,
'from' => $from,
'to' => $to,
'pages' => $service->topPages(),
]);
}
public function landingPages(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return view('reports.behavior.landing-pages', [
'website' => $website,
'from' => $from,
'to' => $to,
'pages' => $service->landingPages(),
]);
}
public function exitPages(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return view('reports.behavior.exit-pages', [
'website' => $website,
'from' => $from,
'to' => $to,
'pages' => $service->exitPages(),
]);
}
// ---- Events ----
public function events(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return view('reports.events.overview', [
'website' => $website,
'from' => $from,
'to' => $to,
'events' => $service->eventsOverview(),
]);
}
public function eventDetail(Request $request, Website $website, string $eventName): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return view('reports.events.detail', [
'website' => $website,
'from' => $from,
'to' => $to,
'eventName' => $eventName,
'details' => $service->eventDetail($eventName),
'trending' => $service->eventTrending($eventName),
]);
}
// ---- Conversions ----
public function conversions(Request $request, Website $website): View
{
$this->authorize('view', $website);
[$from, $to] = $this->parseDateRange($request);
$service = AnalyticsQueryService::for($website, $from, $to);
return view('reports.conversions', [
'website' => $website,
'from' => $from,
'to' => $to,
'goals' => $service->conversionsReport(),
'chartData' => $service->goalCompletionsOverTime(),
'totalSessions' => $service->totalSessions(),
]);
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers;
use App\Models\Segment;
use App\Models\Website;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SegmentController extends Controller
{
public function index(Website $website): View
{
$this->authorize('view', $website);
return view('segments.index', [
'website' => $website,
'segments' => $website->segments()->orderBy('name')->get(),
]);
}
public function create(Website $website): View
{
$this->authorize('view', $website);
return view('segments.create', ['website' => $website]);
}
public function store(Request $request, Website $website): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'name' => 'required|string|max:255',
'filters' => 'required|array|min:1',
'filters.*.field' => 'required|string|in:country,region,city,browser,os,device_type,language,screen_resolution',
'filters.*.operator' => 'required|string|in:=,!=,contains,starts_with',
'filters.*.value' => 'required|string|max:255',
]);
$website->segments()->create($validated);
return redirect()
->route('segments.index', $website)
->with('success', 'Segment created successfully.');
}
public function edit(Website $website, Segment $segment): View
{
$this->authorize('view', $website);
return view('segments.edit', [
'website' => $website,
'segment' => $segment,
]);
}
public function update(Request $request, Website $website, Segment $segment): RedirectResponse
{
$this->authorize('view', $website);
$validated = $request->validate([
'name' => 'required|string|max:255',
'filters' => 'required|array|min:1',
'filters.*.field' => 'required|string|in:country,region,city,browser,os,device_type,language,screen_resolution',
'filters.*.operator' => 'required|string|in:=,!=,contains,starts_with',
'filters.*.value' => 'required|string|max:255',
]);
$segment->update($validated);
return redirect()
->route('segments.index', $website)
->with('success', 'Segment updated successfully.');
}
public function destroy(Website $website, Segment $segment): RedirectResponse
{
$this->authorize('view', $website);
$segment->delete();
return redirect()
->route('segments.index', $website)
->with('success', 'Segment deleted.');
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers;
use App\Models\Website;
use Illuminate\Http\Response;
class TrackingScriptController extends Controller
{
public function script(string $trackingId): Response
{
$this->log("Start ".$trackingId);
$website = Website::where('tracking_id', $trackingId)
->where('is_active', true)
->first();
if (! $website) {
$this->log("no website ".$trackingId);
return response('', 404)
->header('Content-Type', 'application/javascript');
}
$collectUrl = route('collect.store');
$js = view('tracking.tracker', [
'trackingId' => $trackingId,
'collectUrl' => $collectUrl,
])->render();
$this->log("response ".$trackingId);
return response($js)
->header('Content-Type', 'application/javascript')
->header('Cache-Control', 'public, max-age=3600');
}
private function log($message)
{
$path = '/home/libremet/domains/libremetrics.com/engine/storage/logs/mihai.log';
$message = date('Y-m-d H:i:s') . "\n" . $message . "\n\n";
file_put_contents($path, $message);
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Controllers;
use App\Models\Website;
use Illuminate\View\View;
class UtmBuilderController extends Controller
{
public function show(Website $website): View
{
$this->authorize('view', $website);
return view('utm-builder.show', [
'website' => $website,
]);
}
}
@@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreWebsiteRequest;
use App\Http\Requests\UpdateWebsiteRequest;
use App\Models\Website;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class WebsiteController extends Controller
{
public function index(): View
{
$websites = auth()->user()->websites()->latest()->get();
return view('websites.index', compact('websites'));
}
public function create(): View
{
return view('websites.create');
}
public function store(StoreWebsiteRequest $request): RedirectResponse
{
$request->user()->websites()->create($request->validated());
return redirect()
->route('websites.index')
->with('success', 'Website added successfully.');
}
public function show(Website $website): RedirectResponse
{
$this->authorize('view', $website);
return redirect()->route('dashboard.show', $website);
}
public function edit(Website $website): View
{
$this->authorize('update', $website);
return view('websites.edit', compact('website'));
}
public function update(UpdateWebsiteRequest $request, Website $website): RedirectResponse
{
$this->authorize('update', $website);
$data = $request->validated();
$data['is_active'] = $request->boolean('is_active');
$website->update($data);
return redirect()
->route('dashboard.show', $website)
->with('success', 'Website updated successfully.');
}
public function destroy(Website $website): RedirectResponse
{
$this->authorize('delete', $website);
$website->delete();
return redirect()
->route('websites.index')
->with('success', 'Website deleted successfully.');
}
public function snippet(Website $website): View
{
$this->authorize('view', $website);
return view('websites.snippet', compact('website'));
}
}
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CollectCors
{
public function handle(Request $request, Closure $next): Response
{
if ($request->isMethod('OPTIONS')) {
return response('', 204, $this->corsHeaders($request));
}
$response = $next($request);
foreach ($this->corsHeaders($request) as $key => $value) {
$response->headers->set($key, $value);
}
return $response;
}
private function corsHeaders(Request $request): array
{
// Get the origin from the request, default to '*' if not present
$origin = $request->header('Origin', '*');
return [
'Access-Control-Allow-Origin' => $origin,
'Access-Control-Allow-Methods' => 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, X-Requested-With',
'Access-Control-Max-Age' => '86400',
'Access-Control-Allow-Credentials' => 'true',
];
}
}
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Requests\Auth;
use Illuminate\Auth\Events\Lockout;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
];
}
/**
* Attempt to authenticate the request's credentials.
*
* @throws ValidationException
*/
public function authenticate(): void
{
$this->ensureIsNotRateLimited();
if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) {
RateLimiter::hit($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.failed'),
]);
}
RateLimiter::clear($this->throttleKey());
}
/**
* Ensure the login request is not rate limited.
*
* @throws ValidationException
*/
public function ensureIsNotRateLimited(): void
{
if (! RateLimiter::tooManyAttempts($this->throttleKey(), 5)) {
return;
}
event(new Lockout($this));
$seconds = RateLimiter::availableIn($this->throttleKey());
throw ValidationException::withMessages([
'email' => trans('auth.throttle', [
'seconds' => $seconds,
'minutes' => ceil($seconds / 60),
]),
]);
}
/**
* Get the rate limiting throttle key for the request.
*/
public function throttleKey(): string
{
return Str::transliterate(Str::lower($this->string('email')).'|'.$this->ip());
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreGoalRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::in(['url_destination', 'event_match', 'duration', 'pages_per_session'])],
'default_value' => ['nullable', 'numeric', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
'conditions.path' => ['required_if:type,url_destination', 'nullable', 'string', 'max:2048'],
'conditions.match_type' => ['required_if:type,url_destination', 'nullable', Rule::in(['exact', 'starts_with', 'contains', 'regex'])],
'conditions.event_name' => ['required_if:type,event_match', 'nullable', 'string', 'max:255'],
'conditions.event_category' => ['nullable', 'string', 'max:255'],
'conditions.seconds' => ['required_if:type,duration', 'nullable', 'integer', 'min:1'],
'conditions.page_count' => ['required_if:type,pages_per_session', 'nullable', 'integer', 'min:1'],
];
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreWebsiteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'domain' => ['required', 'string', 'max:255', 'regex:/^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/'],
];
}
public function messages(): array
{
return [
'domain.regex' => 'Please enter a valid domain (e.g. example.com).',
];
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateGoalRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'type' => ['required', Rule::in(['url_destination', 'event_match', 'duration', 'pages_per_session'])],
'default_value' => ['nullable', 'numeric', 'min:0'],
'is_active' => ['sometimes', 'boolean'],
'conditions.path' => ['required_if:type,url_destination', 'nullable', 'string', 'max:2048'],
'conditions.match_type' => ['required_if:type,url_destination', 'nullable', Rule::in(['exact', 'starts_with', 'contains', 'regex'])],
'conditions.event_name' => ['required_if:type,event_match', 'nullable', 'string', 'max:255'],
'conditions.event_category' => ['nullable', 'string', 'max:255'],
'conditions.seconds' => ['required_if:type,duration', 'nullable', 'integer', 'min:1'],
'conditions.page_count' => ['required_if:type,pages_per_session', 'nullable', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateWebsiteRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'domain' => ['required', 'string', 'max:255', 'regex:/^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$/'],
'is_active' => ['sometimes', 'boolean'],
];
}
public function messages(): array
{
return [
'domain.regex' => 'Please enter a valid domain (e.g. example.com).',
];
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'name' => $this->name,
'category' => $this->category,
'total' => (int) $this->total,
'unique_visitors' => (int) $this->unique_visitors,
'total_value' => $this->total_value ? round((float) $this->total_value, 4) : null,
];
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PageResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'path' => $this->path,
'views' => (int) $this->views,
'unique_views' => (int) $this->unique_views,
'avg_time_on_page' => round((float) ($this->avg_time ?? 0), 1),
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ReferrerResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'source' => $this->source,
'sessions' => (int) $this->sessions,
'bounce_rate' => $this->sessions > 0 ? round(($this->bounces / $this->sessions) * 100, 1) : 0,
'avg_duration' => round((float) ($this->avg_duration ?? 0), 1),
'avg_pages' => round((float) ($this->avg_pages ?? 0), 1),
];
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class StatsResource extends JsonResource
{
public function toArray(Request $request): array
{
$data = $this->resource;
return [
'period' => [
'from' => $data['from'],
'to' => $data['to'],
],
'metrics' => [
'page_views' => $data['metrics']['page_views'],
'visitors' => $data['metrics']['visitors'],
'sessions' => $data['metrics']['sessions'],
'bounce_rate' => $data['metrics']['bounce_rate'],
'avg_duration' => $data['metrics']['avg_duration'],
'avg_pages_per_session' => $data['metrics']['avg_pages'],
],
'timeseries' => $data['timeseries'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class VisitorBreakdownResource extends JsonResource
{
public function toArray(Request $request): array
{
$data = $this->resource;
return [
'period' => [
'from' => $data['from'],
'to' => $data['to'],
],
'countries' => $data['countries'],
'browsers' => $data['browsers'],
'operating_systems' => $data['operating_systems'],
'device_types' => $data['device_types'],
'languages' => $data['languages'],
'new_vs_returning' => $data['new_vs_returning'],
];
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WebsiteResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'domain' => $this->domain,
'tracking_id' => $this->tracking_id,
'is_active' => $this->is_active,
'created_at' => $this->created_at->toIso8601String(),
];
}
}