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:
202
app/Http/Controllers/Api/PublicApi/V1/ApplicationController.php
Normal file
202
app/Http/Controllers/Api/PublicApi/V1/ApplicationController.php
Normal file
@@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\PublicApi\V1;
|
||||
|
||||
use App\Enums\ApplicationStatus;
|
||||
use App\Http\Requests\PublicApi\SubmitApplicationRequest;
|
||||
use App\Models\Application;
|
||||
use App\Models\Call;
|
||||
use App\Notifications\ApplicationReceived;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The public site writing back.
|
||||
*
|
||||
* Everything else under /api/public is read-only, which meant a call for
|
||||
* contributors ended at "this is what we need" with nowhere to answer. These
|
||||
* two endpoints close that loop, and they are the only place a stranger can
|
||||
* put a row in the database — so they are also the only place that needs
|
||||
* throttling, a honeypot and an off switch.
|
||||
*/
|
||||
class ApplicationController extends PublicController
|
||||
{
|
||||
/**
|
||||
* POST /api/public/v1/calls/{slug}/apply
|
||||
*
|
||||
* Answering a specific opening. Only an open call accepts applications:
|
||||
* taking someone's time to write a message for a position that is already
|
||||
* filled is worse than showing them no form at all.
|
||||
*/
|
||||
public function apply(SubmitApplicationRequest $request, string $slug): JsonResponse
|
||||
{
|
||||
if ($closed = $this->refusedBecauseClosed()) {
|
||||
return $closed;
|
||||
}
|
||||
|
||||
$call = Call::query()->whereSlug($slug)->open()->firstOrFail();
|
||||
|
||||
return $this->record($request, $call);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/public/v1/contact
|
||||
*
|
||||
* The general form: someone offering time without a call in mind, or an
|
||||
* association asking for help.
|
||||
*/
|
||||
public function contact(SubmitApplicationRequest $request): JsonResponse
|
||||
{
|
||||
if ($closed = $this->refusedBecauseClosed()) {
|
||||
return $closed;
|
||||
}
|
||||
|
||||
return $this->record($request, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the submission, or recognise it as one already stored.
|
||||
*/
|
||||
protected function record(SubmitApplicationRequest $request, ?Call $call): JsonResponse
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
if ($existing = $this->recentDuplicate($request->string('email')->toString(), $call?->id)) {
|
||||
// A double-clicked submit button, an impatient retry, a proxy
|
||||
// replaying the request: none of them is a second person, and
|
||||
// answering with an error would make the visitor try harder.
|
||||
return $this->accepted($existing, duplicate: true);
|
||||
}
|
||||
|
||||
$application = Application::create([
|
||||
'call_id' => $call?->id,
|
||||
'call_title' => $call?->getTranslation('title', $locale),
|
||||
'kind' => $request->kind(),
|
||||
|
||||
// Filed, not discarded. The honeypot is a heuristic, and a real
|
||||
// person whose password manager filled the hidden field can be
|
||||
// rescued from the spam filter; a dropped message cannot.
|
||||
'status' => $request->looksAutomated()
|
||||
? ApplicationStatus::Spam
|
||||
: ApplicationStatus::New,
|
||||
|
||||
'name' => $request->string('name')->trim()->toString(),
|
||||
'email' => $request->string('email')->trim()->lower()->toString(),
|
||||
'organisation' => $request->string('organisation')->trim()->toString() ?: null,
|
||||
'message' => $request->string('message')->trim()->toString(),
|
||||
'hours_per_week' => $request->integer('hours_per_week') ?: null,
|
||||
'links' => $this->links($request),
|
||||
|
||||
// The language they wrote in, so the reply goes out in the same
|
||||
// one. Taken from the negotiated locale, not from the payload.
|
||||
'locale' => $locale,
|
||||
|
||||
'ip_hash' => Application::hashIp($request->ip()),
|
||||
]);
|
||||
|
||||
$this->announce($application);
|
||||
|
||||
return $this->accepted($application);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the panel someone is waiting. An inbox nobody is told about is an
|
||||
* inbox nobody reads.
|
||||
*
|
||||
* Failure here must never reach the sender: their message is already
|
||||
* saved, and a misconfigured mailer is our problem, not theirs.
|
||||
*/
|
||||
protected function announce(Application $application): void
|
||||
{
|
||||
if ($application->status === ApplicationStatus::Spam) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Notification::send(ApplicationReceived::recipients(), new ApplicationReceived($application));
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same person answering the same thing again within the window.
|
||||
*/
|
||||
protected function recentDuplicate(string $email, ?int $callId): ?Application
|
||||
{
|
||||
$minutes = (int) config('site.submissions.deduplicate_minutes', 1440);
|
||||
|
||||
if ($minutes <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Application::query()
|
||||
->where('email', Str::lower(trim($email)))
|
||||
->where('call_id', $callId)
|
||||
->where('created_at', '>=', now()->subMinutes($minutes))
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Named links, capped and with tidied keys — the payload allows an object
|
||||
* like {"github": "..."} and the keys end up on screen in the panel.
|
||||
*
|
||||
* @return array<string, string>|null
|
||||
*/
|
||||
protected function links(SubmitApplicationRequest $request): ?array
|
||||
{
|
||||
$links = collect((array) $request->input('links'))
|
||||
->filter(fn ($url) => filled($url))
|
||||
->take(5)
|
||||
->mapWithKeys(fn (string $url, string|int $key) => [
|
||||
Str::limit(Str::slug((string) $key) ?: 'link', 30, '') => $url,
|
||||
])
|
||||
->all();
|
||||
|
||||
return $links === [] ? null : $links;
|
||||
}
|
||||
|
||||
/**
|
||||
* The frontend needs an id to show a confirmation and nothing else: the
|
||||
* body of a submission is never echoed back, so a guessed id cannot be
|
||||
* used to read somebody else's message.
|
||||
*/
|
||||
protected function accepted(Application $application, bool $duplicate = false): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'id' => $application->id,
|
||||
'kind' => $application->kind->value,
|
||||
'status' => ApplicationStatus::New->value,
|
||||
'duplicate' => $duplicate,
|
||||
],
|
||||
'message' => __('site.applications.received'),
|
||||
], $duplicate ? Response::HTTP_OK : Response::HTTP_CREATED)
|
||||
// Never cacheable, and never revalidated: a shared cache holding
|
||||
// on to one of these would answer the next visitor with somebody
|
||||
// else's confirmation.
|
||||
->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
/**
|
||||
* The kill switch, for when a spam run gets past everything else.
|
||||
*/
|
||||
protected function refusedBecauseClosed(): ?JsonResponse
|
||||
{
|
||||
if (config('site.submissions.enabled', true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => __('site.applications.closed'),
|
||||
'submissions_enabled' => false,
|
||||
], Response::HTTP_SERVICE_UNAVAILABLE)
|
||||
->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Api/PublicApi/V1/CallController.php
Normal file
50
app/Http/Controllers/Api/PublicApi/V1/CallController.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\PublicApi\V1;
|
||||
|
||||
use App\Http\Resources\PublicApi\PublicCallResource;
|
||||
use App\Models\Call;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
/**
|
||||
* Open calls for contributors — the conversion point of the whole site.
|
||||
*/
|
||||
class CallController extends PublicController
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$calls = Call::query()
|
||||
->with(['technologies', 'project.coverMedia'])
|
||||
->unless($this->isPreview($request), fn (Builder $q) => $q->open())
|
||||
// A call attached to an unpublished project must not leak it.
|
||||
->where(fn (Builder $q) => $q
|
||||
->whereNull('project_id')
|
||||
->orWhereHas('project', fn (Builder $p) => $p->published()))
|
||||
->when($request->filled('technology'), fn (Builder $q) => $q
|
||||
->whereHas('technologies', fn (Builder $t) => $t
|
||||
->whereIn('slug', (array) $request->input('technology'))))
|
||||
// "I have four hours a week" is the real filter people apply.
|
||||
->when($request->filled('max_hours_per_week'), fn (Builder $q) => $q
|
||||
->where('hours_per_week', '<=', $request->integer('max_hours_per_week')))
|
||||
->orderByRaw('hours_per_week is null, hours_per_week asc')
|
||||
->paginate($this->perPage($request))
|
||||
->withQueryString();
|
||||
|
||||
return PublicCallResource::collection($calls);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $slug): PublicCallResource
|
||||
{
|
||||
$call = Call::query()
|
||||
->with(['technologies', 'project.technologies', 'project.coverMedia'])
|
||||
->whereSlug($slug)
|
||||
->unless($this->isPreview($request), fn (Builder $q) => $q->open())
|
||||
->firstOrFail();
|
||||
|
||||
return PublicCallResource::make($call);
|
||||
}
|
||||
}
|
||||
52
app/Http/Controllers/Api/PublicApi/V1/MemberController.php
Normal file
52
app/Http/Controllers/Api/PublicApi/V1/MemberController.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\PublicApi\V1;
|
||||
|
||||
use App\Http\Resources\PublicApi\PublicMemberResource;
|
||||
use App\Models\Member;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class MemberController extends PublicController
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$members = Member::query()
|
||||
->with(['technologies', 'avatarMedia'])
|
||||
->withCount('projects')
|
||||
->public()
|
||||
// Former members are kept but hidden by default; the frontend can
|
||||
// ask for them to build an "alumni" section.
|
||||
->unless($request->boolean('include_past'), fn (Builder $q) => $q->current())
|
||||
->when($request->filled('technology'), fn (Builder $q) => $q
|
||||
->whereHas('technologies', fn (Builder $t) => $t
|
||||
->whereIn('slug', (array) $request->input('technology'))))
|
||||
->orderByDesc('is_founder')
|
||||
->orderBy('position')
|
||||
->orderBy('name')
|
||||
->paginate($this->perPage($request, 50))
|
||||
->withQueryString();
|
||||
|
||||
return PublicMemberResource::collection($members);
|
||||
}
|
||||
|
||||
public function show(string $slug): PublicMemberResource
|
||||
{
|
||||
$member = Member::query()
|
||||
->with([
|
||||
'technologies',
|
||||
'avatarMedia',
|
||||
'projects' => fn ($q) => $q->published()
|
||||
->with(['technologies', 'coverMedia'])
|
||||
->withCount(['calls as open_calls_count' => fn ($c) => $c->open()]),
|
||||
])
|
||||
->public()
|
||||
->where('slug', $slug)
|
||||
->firstOrFail();
|
||||
|
||||
return PublicMemberResource::make($member);
|
||||
}
|
||||
}
|
||||
41
app/Http/Controllers/Api/PublicApi/V1/PageController.php
Normal file
41
app/Http/Controllers/Api/PublicApi/V1/PageController.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\PublicApi\V1;
|
||||
|
||||
use App\Http\Resources\PublicApi\PublicPageResource;
|
||||
use App\Models\Page;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class PageController extends PublicController
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$pages = Page::query()
|
||||
->with(['category', 'featuredMedia'])
|
||||
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
|
||||
->when($request->filled('category'), fn (Builder $q) => $q
|
||||
->whereHas('category', fn (Builder $c) => $c
|
||||
->where('slug->'.app()->getLocale(), $request->string('category'))))
|
||||
->when($request->boolean('featured'), fn (Builder $q) => $q->where('is_featured', true))
|
||||
->orderByDesc('published_at')
|
||||
->paginate($this->perPage($request))
|
||||
->withQueryString();
|
||||
|
||||
return PublicPageResource::collection($pages);
|
||||
}
|
||||
|
||||
public function show(Request $request, string $slug): PublicPageResource
|
||||
{
|
||||
$page = Page::query()
|
||||
->with(['category', 'featuredMedia'])
|
||||
->whereSlug($slug)
|
||||
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
|
||||
->firstOrFail();
|
||||
|
||||
return PublicPageResource::make($page);
|
||||
}
|
||||
}
|
||||
86
app/Http/Controllers/Api/PublicApi/V1/ProjectController.php
Normal file
86
app/Http/Controllers/Api/PublicApi/V1/ProjectController.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\PublicApi\V1;
|
||||
|
||||
use App\Http\Resources\PublicApi\PublicProjectResource;
|
||||
use App\Models\Project;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class ProjectController extends PublicController
|
||||
{
|
||||
/**
|
||||
* GET /api/public/v1/projects
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$projects = Project::query()
|
||||
// `coverMedia` carries the dimensions and responsive versions of
|
||||
// the cover; loaded here so a listing costs one query, not one per
|
||||
// project.
|
||||
->with(['technologies', 'lead', 'repositories', 'coverMedia', 'lead.avatarMedia', 'client.logoMedia'])
|
||||
->withCount([
|
||||
'members',
|
||||
// Powers the `needs_help` flag without loading every call.
|
||||
'calls as open_calls_count' => fn (Builder $q) => $q->open(),
|
||||
])
|
||||
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
|
||||
->when($request->filled('status'), fn (Builder $q) => $q
|
||||
->whereIn('status', (array) $request->input('status')))
|
||||
->when($request->boolean('featured'), fn (Builder $q) => $q->where('is_featured', true))
|
||||
->when($request->boolean('needs_help'), fn (Builder $q) => $q->needingHelp())
|
||||
->when($request->filled('technology'), fn (Builder $q) => $q
|
||||
->whereHas('technologies', fn (Builder $t) => $t
|
||||
->whereIn('slug', (array) $request->input('technology'))))
|
||||
->when($request->filled('member'), fn (Builder $q) => $q
|
||||
->whereHas('members', fn (Builder $m) => $m
|
||||
->where('slug', $request->string('member'))))
|
||||
// Only projects that actually exist in this language, so the
|
||||
// frontend never renders a listing of fallback content.
|
||||
->when($request->boolean('translated'), fn (Builder $q) => $q
|
||||
->whereNotNull("title->{$locale}"))
|
||||
->orderByDesc('is_featured')
|
||||
->orderBy('position')
|
||||
->orderByDesc('published_at')
|
||||
->paginate($this->perPage($request))
|
||||
->withQueryString();
|
||||
|
||||
return PublicProjectResource::collection($projects);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/public/v1/projects/{slug}
|
||||
*
|
||||
* Resolution is by slug within the active locale — the access pattern an
|
||||
* SSR frontend actually has, since it receives a URL, not an id.
|
||||
*/
|
||||
public function show(Request $request, string $slug): PublicProjectResource
|
||||
{
|
||||
$project = Project::query()
|
||||
->with([
|
||||
'technologies',
|
||||
'coverMedia',
|
||||
'images.media',
|
||||
'client.logoMedia',
|
||||
'services',
|
||||
'testimonials' => fn ($q) => $q->public()->with('avatarMedia')->orderBy('position'),
|
||||
'talks' => fn ($q) => $q->public()->with('member')->recentFirst(),
|
||||
'lead',
|
||||
'lead.avatarMedia',
|
||||
'members.technologies',
|
||||
'members.avatarMedia',
|
||||
'repositories.goodFirstIssues',
|
||||
'calls' => fn ($q) => $q->open()->with('technologies'),
|
||||
])
|
||||
->whereSlug($slug)
|
||||
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
|
||||
->firstOrFail();
|
||||
|
||||
return PublicProjectResource::make($project)->detailed();
|
||||
}
|
||||
}
|
||||
36
app/Http/Controllers/Api/PublicApi/V1/PublicController.php
Normal file
36
app/Http/Controllers/Api/PublicApi/V1/PublicController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\PublicApi\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Shared behaviour for the public, unauthenticated endpoints.
|
||||
*
|
||||
* These endpoints serve the public frontend. They are deliberately readable by
|
||||
* anyone: everything they return is already published on the public website,
|
||||
* so requiring a token would add ceremony without adding protection. What is
|
||||
* *not* published is gated by the preview secret instead.
|
||||
*/
|
||||
abstract class PublicController extends Controller
|
||||
{
|
||||
/**
|
||||
* Whether this request may see unpublished content, as decided by
|
||||
* App\Http\Middleware\AllowPreview.
|
||||
*/
|
||||
protected function isPreview(Request $request): bool
|
||||
{
|
||||
return (bool) $request->attributes->get('preview', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Page size, capped so a public caller cannot ask for everything at once.
|
||||
*/
|
||||
protected function perPage(Request $request, int $default = 20, int $max = 100): int
|
||||
{
|
||||
return min(max($request->integer('per_page', $default), 1), $max);
|
||||
}
|
||||
}
|
||||
124
app/Http/Controllers/Api/PublicApi/V1/ShowcaseController.php
Normal file
124
app/Http/Controllers/Api/PublicApi/V1/ShowcaseController.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\PublicApi\V1;
|
||||
|
||||
use App\Http\Resources\PublicApi\PublicClientResource;
|
||||
use App\Http\Resources\PublicApi\PublicServiceResource;
|
||||
use App\Http\Resources\PublicApi\PublicTalkResource;
|
||||
use App\Http\Resources\PublicApi\PublicTestimonialResource;
|
||||
use App\Models\Client;
|
||||
use App\Models\Service;
|
||||
use App\Models\Talk;
|
||||
use App\Models\Testimonial;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
/**
|
||||
* The pieces a landing page is made of.
|
||||
*
|
||||
* Four collections rather than one "home page" payload, and no ordering of
|
||||
* sections anywhere in here: what gets composed, and in what order, is the
|
||||
* frontend's business. This side only knows what exists.
|
||||
*/
|
||||
class ShowcaseController extends PublicController
|
||||
{
|
||||
/**
|
||||
* GET /api/public/v1/services
|
||||
*/
|
||||
public function services(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$services = Service::query()
|
||||
->with(['technologies'])
|
||||
->withCount('projects')
|
||||
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
|
||||
->when($request->boolean('featured'), fn (Builder $q) => $q->where('is_featured', true))
|
||||
->orderBy('position')
|
||||
->get();
|
||||
|
||||
return PublicServiceResource::collection($services);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/public/v1/services/{slug}
|
||||
*
|
||||
* Resolved by slug within the active locale, like every other record here.
|
||||
*/
|
||||
public function service(Request $request, string $slug): PublicServiceResource
|
||||
{
|
||||
$service = Service::query()
|
||||
->with([
|
||||
// The work that backs the offer, and what people said about
|
||||
// it: a service page with neither is a promise.
|
||||
'projects' => fn ($q) => $q->published()->with(['technologies', 'coverMedia']),
|
||||
'technologies',
|
||||
'testimonials' => fn ($q) => $q->public()->with(['client.logoMedia', 'avatarMedia'])->orderBy('position'),
|
||||
])
|
||||
->whereSlug($slug)
|
||||
->unless($this->isPreview($request), fn (Builder $q) => $q->published())
|
||||
->firstOrFail();
|
||||
|
||||
return PublicServiceResource::make($service)->detailed();
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/public/v1/clients
|
||||
*
|
||||
* The wall of logos. Organisations that asked not to appear are absent
|
||||
* whatever else is true of them.
|
||||
*/
|
||||
public function clients(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$clients = Client::query()
|
||||
->with('logoMedia')
|
||||
->withCount(['projects' => fn (Builder $q) => $q->published()])
|
||||
->public()
|
||||
// A client with nothing published behind it would be a logo the
|
||||
// site cannot justify showing.
|
||||
->when($request->boolean('with_projects', true), fn (Builder $q) => $q
|
||||
->whereHas('projects', fn (Builder $p) => $p->published()))
|
||||
->orderBy('position')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return PublicClientResource::collection($clients);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/public/v1/testimonials
|
||||
*/
|
||||
public function testimonials(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$testimonials = Testimonial::query()
|
||||
->with(['client.logoMedia', 'avatarMedia'])
|
||||
->public()
|
||||
->when($request->filled('client'), fn (Builder $q) => $q
|
||||
->whereHas('client', fn (Builder $c) => $c->where('slug', $request->string('client'))))
|
||||
->when($request->filled('service'), fn (Builder $q) => $q
|
||||
->whereHas('service', fn (Builder $s) => $s
|
||||
->where('slug->'.app()->getLocale(), $request->string('service'))))
|
||||
->orderBy('position')
|
||||
->orderByDesc('given_at')
|
||||
->get();
|
||||
|
||||
return PublicTestimonialResource::collection($testimonials);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/public/v1/talks
|
||||
*/
|
||||
public function talks(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$talks = Talk::query()
|
||||
->with('member')
|
||||
->public()
|
||||
->when($request->boolean('with_video'), fn (Builder $q) => $q->whereNotNull('video_url'))
|
||||
->recentFirst()
|
||||
->limit(min($request->integer('limit', 50), 100))
|
||||
->get();
|
||||
|
||||
return PublicTalkResource::collection($talks);
|
||||
}
|
||||
}
|
||||
230
app/Http/Controllers/Api/PublicApi/V1/SiteController.php
Normal file
230
app/Http/Controllers/Api/PublicApi/V1/SiteController.php
Normal file
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\PublicApi\V1;
|
||||
|
||||
use App\Http\Resources\PublicApi\PublicIssueResource;
|
||||
use App\Http\Resources\PublicApi\PublicTechnologyResource;
|
||||
use App\Models\Call;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Page;
|
||||
use App\Models\Project;
|
||||
use App\Models\Redirect;
|
||||
use App\Models\Repository;
|
||||
use App\Models\RepositoryIssue;
|
||||
use App\Models\Service;
|
||||
use App\Models\Technology;
|
||||
use App\Support\ContentVersion;
|
||||
use App\Support\SiteUrls;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
/**
|
||||
* Site-wide endpoints that do not belong to a single content type.
|
||||
*/
|
||||
class SiteController extends PublicController
|
||||
{
|
||||
/**
|
||||
* GET /api/public/v1/site
|
||||
*
|
||||
* What the frontend needs once, on any page: which languages exist, and
|
||||
* the headline numbers a collective's home page tends to show.
|
||||
*/
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'locales' => collect(config('locales.supported'))
|
||||
->map(fn (array $meta, string $code) => [
|
||||
'code' => $code,
|
||||
'name' => $meta['native'] ?? $code,
|
||||
'flag' => $meta['flag'] ?? null,
|
||||
'is_default' => $code === config('locales.default'),
|
||||
])
|
||||
->values(),
|
||||
|
||||
'default_locale' => config('locales.default'),
|
||||
|
||||
/*
|
||||
* A number that moves whenever published content does. Keep it
|
||||
* as part of your cache key and everything downstream becomes
|
||||
* stale the moment the site really changed, instead of when a
|
||||
* timer says so.
|
||||
*/
|
||||
'content_version' => ContentVersion::current(),
|
||||
|
||||
// So the frontend hides its forms when the inbox is closed,
|
||||
// rather than rendering one that would be refused on submit.
|
||||
'submissions_enabled' => (bool) config('site.submissions.enabled', true),
|
||||
|
||||
'stats' => [
|
||||
'projects' => Project::query()->published()->count(),
|
||||
'active_projects' => Project::query()->published()->where('status', 'active')->count(),
|
||||
'members' => Member::query()->public()->current()->count(),
|
||||
'open_calls' => Call::query()->open()->count(),
|
||||
'services' => Service::query()->published()->count(),
|
||||
'clients' => Client::query()->public()->whereHas('projects', fn ($q) => $q->published())->count(),
|
||||
'good_first_issues' => RepositoryIssue::query()
|
||||
->where('is_good_first_issue', true)
|
||||
->whereNull('assignee')
|
||||
->whereHas('repository', fn ($q) => $q->publiclyVisible())
|
||||
->count(),
|
||||
'stars' => (int) Repository::query()->publiclyVisible()->sum('stars'),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/public/v1/slugs
|
||||
*
|
||||
* Every published URL, grouped by type and locale. This is what the
|
||||
* frontend builds its sitemap and static paths from — one request instead
|
||||
* of paging through every collection.
|
||||
*/
|
||||
public function slugs(): JsonResponse
|
||||
{
|
||||
$entries = [];
|
||||
|
||||
foreach (Page::query()->published()->get(['id', 'slug', 'updated_at']) as $page) {
|
||||
$entries['pages'][] = $this->entry($page, 'page');
|
||||
}
|
||||
|
||||
foreach (Project::query()->published()->get(['id', 'slug', 'updated_at']) as $project) {
|
||||
$entries['projects'][] = $this->entry($project, 'project');
|
||||
}
|
||||
|
||||
foreach (Call::query()->open()->get(['id', 'slug', 'updated_at']) as $call) {
|
||||
$entries['calls'][] = $this->entry($call, 'call');
|
||||
}
|
||||
|
||||
foreach (Service::query()->published()->get(['id', 'slug', 'updated_at']) as $service) {
|
||||
$entries['services'][] = $this->entry($service, 'service');
|
||||
}
|
||||
|
||||
foreach (Member::query()->public()->current()->get(['id', 'slug', 'updated_at']) as $member) {
|
||||
$entries['members'][] = [
|
||||
'id' => $member->id,
|
||||
'updated_at' => $member->updated_at?->toIso8601String(),
|
||||
'alternates' => collect(array_keys(config('locales.supported')))
|
||||
->mapWithKeys(fn (string $l) => [$l => SiteUrls::path('member', $l, $member->slug)])
|
||||
->all(),
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json(['data' => $entries]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/public/v1/redirects
|
||||
*
|
||||
* Every URL that used to work and where it goes now, so the frontend can
|
||||
* answer a 301 instead of a 404. Most of these were written by the panel
|
||||
* itself the moment somebody renamed a slug — the only moment at which the
|
||||
* old address was still known.
|
||||
*
|
||||
* Returned whole rather than looked up one at a time: there are never many,
|
||||
* and an SSR frontend would otherwise ask this API on every 404 it serves.
|
||||
*/
|
||||
public function redirects(): JsonResponse
|
||||
{
|
||||
$redirects = Redirect::query()
|
||||
->enabled()
|
||||
->orderBy('from')
|
||||
->get(['from', 'to', 'status', 'locale']);
|
||||
|
||||
return response()->json([
|
||||
'data' => $redirects->map(fn (Redirect $redirect) => [
|
||||
'from' => $redirect->from,
|
||||
'to' => $redirect->to,
|
||||
'status' => $redirect->status,
|
||||
|
||||
// Null means it applies whatever language the visitor is
|
||||
// browsing in.
|
||||
'locale' => $redirect->locale,
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/public/v1/radar
|
||||
*
|
||||
* The tech radar: what the collective adopts, is trialling, is assessing
|
||||
* and is holding off on. Highly shareable and cheap to maintain.
|
||||
*/
|
||||
public function radar(): AnonymousResourceCollection
|
||||
{
|
||||
$technologies = Technology::query()
|
||||
->onRadar()
|
||||
->with('logoMedia')
|
||||
->withCount(['projects', 'members'])
|
||||
->orderBy('category')
|
||||
->orderBy('position')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return PublicTechnologyResource::collection($technologies);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/public/v1/technologies
|
||||
*/
|
||||
public function technologies(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$technologies = Technology::query()
|
||||
->with('logoMedia')
|
||||
->withCount(['projects', 'members'])
|
||||
->when($request->filled('category'), fn ($q) => $q
|
||||
->whereIn('category', (array) $request->input('category')))
|
||||
// By default only technologies actually in use, so the list is not
|
||||
// padded with tags nobody applied.
|
||||
->when($request->boolean('in_use', true), fn ($q) => $q->has('projects'))
|
||||
->orderBy('position')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return PublicTechnologyResource::collection($technologies);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/public/v1/good-first-issues
|
||||
*
|
||||
* The single most effective thing on a collective's site: it turns a
|
||||
* visitor who was only browsing into a contributor in one click.
|
||||
*/
|
||||
public function goodFirstIssues(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$issues = RepositoryIssue::query()
|
||||
->with(['repository.project'])
|
||||
->where(fn ($q) => $q
|
||||
->where('is_good_first_issue', true)
|
||||
->orWhere('is_help_wanted', $request->boolean('include_help_wanted', true)))
|
||||
// An issue somebody already took is not an invitation.
|
||||
->available()
|
||||
->whereHas('repository', fn ($q) => $q->publiclyVisible()->where('is_archived', false))
|
||||
->when($request->filled('language'), fn ($q) => $q
|
||||
->whereHas('repository', fn ($r) => $r
|
||||
->where('primary_language', $request->string('language'))))
|
||||
->orderByDesc('issue_created_at')
|
||||
->limit(min($request->integer('limit', 20), 100))
|
||||
->get();
|
||||
|
||||
return PublicIssueResource::collection($issues);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function entry(Model $record, string $type): array
|
||||
{
|
||||
return [
|
||||
'id' => $record->id,
|
||||
'updated_at' => $record->updated_at?->toIso8601String(),
|
||||
'alternates' => SiteUrls::alternates($record, $type),
|
||||
];
|
||||
}
|
||||
}
|
||||
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