Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
142
app/Http/Controllers/Api/PublicApi/V1/TelemetryController.php
Normal file
142
app/Http/Controllers/Api/PublicApi/V1/TelemetryController.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\PublicApi\V1;
|
||||
|
||||
use App\Http\Requests\PublicApi\ReportMissingUrlRequest;
|
||||
use App\Http\Requests\PublicApi\ReportViewsRequest;
|
||||
use App\Models\ContentView;
|
||||
use App\Models\MissingUrl;
|
||||
use App\Models\Redirect;
|
||||
use App\Support\ContentTypes;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* The frontend reporting back.
|
||||
*
|
||||
* Everything else this API does is answer questions. These two endpoints are
|
||||
* the direction nothing else travels in: the site telling the panel what
|
||||
* happened out there, which is the half of every editorial decision that has
|
||||
* been missing.
|
||||
*
|
||||
* Two things are worth knowing and neither can be discovered from here: which
|
||||
* addresses failed, because a broken link only ever fails in front of a
|
||||
* visitor, and which records were read, because the panel can say what is
|
||||
* published and never whether anybody opened it.
|
||||
*
|
||||
* What is deliberately not collected in either: anything about a person. No
|
||||
* address, no session, no user agent, no time of day. A failed URL and a daily
|
||||
* tally per record answer the questions; a visitor log would answer different
|
||||
* ones nobody here asked.
|
||||
*/
|
||||
class TelemetryController extends PublicController
|
||||
{
|
||||
/**
|
||||
* POST /api/public/v1/telemetry/not-found
|
||||
*
|
||||
* Answers 202 rather than 201: nothing here is a resource the caller owns,
|
||||
* and reports that were merged into an existing count or dropped as noise
|
||||
* are still perfectly successful. The count of what was actually recorded
|
||||
* comes back so the frontend can tell whether its filter matches ours.
|
||||
*/
|
||||
public function notFound(ReportMissingUrlRequest $request): JsonResponse
|
||||
{
|
||||
$recorded = 0;
|
||||
|
||||
foreach ($request->hits() as $hit) {
|
||||
// Normalised exactly as a redirect is, because that is what these
|
||||
// are eventually compared against: "/x/" and "/x?ref=1" are the
|
||||
// same broken address and must not become two rows.
|
||||
$path = Redirect::normalise((string) $hit['path']);
|
||||
|
||||
if ($this->isNoise($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
MissingUrl::report(
|
||||
$path,
|
||||
// The frontend usually knows which language it was rendering.
|
||||
// When it does not, the negotiated locale is the same answer:
|
||||
// it comes from the Accept-Language the frontend forwarded.
|
||||
filled($hit['locale'] ?? null) ? (string) $hit['locale'] : app()->getLocale(),
|
||||
filled($hit['referrer'] ?? null) ? (string) $hit['referrer'] : null,
|
||||
);
|
||||
|
||||
$recorded++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'received' => count($request->hits()),
|
||||
'recorded' => $recorded,
|
||||
],
|
||||
], Response::HTTP_ACCEPTED)
|
||||
->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/public/v1/telemetry/views
|
||||
*
|
||||
* What was read. The panel has always known what exists and what is
|
||||
* published, and never whether any of it gets opened — which is the fact
|
||||
* behind most editorial decisions: what to translate next, whether the
|
||||
* featured project is one anybody looks at, whether a page earns the work
|
||||
* of keeping it accurate.
|
||||
*
|
||||
* There is no visitor in any of this. A report says a record was rendered
|
||||
* in a language; what is stored is a daily total per record. No address,
|
||||
* no session, no user agent, no time of day — nothing that could be
|
||||
* assembled back into a person.
|
||||
*/
|
||||
public function views(ReportViewsRequest $request): JsonResponse
|
||||
{
|
||||
$recorded = 0;
|
||||
|
||||
foreach ($request->views() as $view) {
|
||||
$locale = filled($view['locale'] ?? null)
|
||||
? (string) $view['locale']
|
||||
: app()->getLocale();
|
||||
|
||||
$record = ContentTypes::resolve((string) $view['type'], (string) $view['slug'], $locale);
|
||||
|
||||
// A slug that no longer resolves is a 404, and the endpoint next
|
||||
// door exists to hear about those. Counting it here would attach
|
||||
// somebody's reading to nothing.
|
||||
if ($record === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
ContentView::record($record, $locale, (int) ($view['count'] ?? 1));
|
||||
|
||||
$recorded++;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'received' => count($request->views()),
|
||||
'recorded' => $recorded,
|
||||
],
|
||||
], Response::HTTP_ACCEPTED)
|
||||
->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
/**
|
||||
* A scan for something that was never here.
|
||||
*
|
||||
* Dropped rather than stored, and dropped on this side rather than left to
|
||||
* the frontend: the list is only useful while a person is willing to read
|
||||
* it, and one afternoon of a bot looking for WordPress would end that.
|
||||
*/
|
||||
protected function isNoise(string $path): bool
|
||||
{
|
||||
foreach ((array) config('site.telemetry.ignore', []) as $pattern) {
|
||||
if (fnmatch((string) $pattern, $path, FNM_CASEFOLD)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user