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>
143 lines
5.2 KiB
PHP
143 lines
5.2 KiB
PHP
<?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;
|
|
}
|
|
}
|