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;
|
||||
}
|
||||
}
|
||||
74
app/Http/Controllers/Api/V1/CategoryController.php
Normal file
74
app/Http/Controllers/Api/V1/CategoryController.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\CategoryRequest;
|
||||
use App\Http\Resources\CategoryResource;
|
||||
use App\Models\Category;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('viewAny', Category::class);
|
||||
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$categories = Category::query()
|
||||
->withCount('pages')
|
||||
->when($request->has('is_visible'), fn ($q) => $q->where('is_visible', $request->boolean('is_visible')))
|
||||
->when($request->filled('search'), fn ($q) => $q->where("name->{$locale}", 'like', '%'.$request->string('search').'%'))
|
||||
->orderBy('position')
|
||||
->orderBy("name->{$locale}")
|
||||
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||
->withQueryString();
|
||||
|
||||
return CategoryResource::collection($categories);
|
||||
}
|
||||
|
||||
public function store(CategoryRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Category::class);
|
||||
|
||||
$category = new Category;
|
||||
$category->fill(TranslatablePayload::merge($category, $request->validated(), $category->translatable));
|
||||
$category->save();
|
||||
|
||||
return CategoryResource::make($category)->response()->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Category $category): CategoryResource
|
||||
{
|
||||
$this->authorize('view', $category);
|
||||
|
||||
return CategoryResource::make($category->loadCount('pages'));
|
||||
}
|
||||
|
||||
public function update(CategoryRequest $request, Category $category): CategoryResource
|
||||
{
|
||||
$this->authorize('update', $category);
|
||||
|
||||
$category->fill(TranslatablePayload::merge($category, $request->validated(), $category->translatable));
|
||||
$category->save();
|
||||
|
||||
return CategoryResource::make($category->loadCount('pages'));
|
||||
}
|
||||
|
||||
public function destroy(Category $category): JsonResponse
|
||||
{
|
||||
// CategoryPolicy refuses while the category still holds pages, so this
|
||||
// answers 403 rather than orphaning content.
|
||||
$this->authorize('delete', $category);
|
||||
|
||||
$category->delete();
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
}
|
||||
26
app/Http/Controllers/Api/V1/MeController.php
Normal file
26
app/Http/Controllers/Api/V1/MeController.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\UserResource;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* GET /api/v1/me — who the bearer token belongs to, and what they may do here.
|
||||
*
|
||||
* The identity part mirrors the OAuth server's /api/user. The `permissions`
|
||||
* array is this application's own answer, and is what a client should drive its
|
||||
* UI from: the identity server does not know about local roles.
|
||||
*/
|
||||
class MeController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): UserResource
|
||||
{
|
||||
return UserResource::make(
|
||||
$request->user()->load('roles')
|
||||
);
|
||||
}
|
||||
}
|
||||
138
app/Http/Controllers/Api/V1/PageController.php
Normal file
138
app/Http/Controllers/Api/V1/PageController.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\PageStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\V1\PageRequest;
|
||||
use App\Http\Resources\PageResource;
|
||||
use App\Models\Page;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class PageController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/v1/pages
|
||||
*
|
||||
* Filters: status, category_id, is_featured, search, translated_in, trashed
|
||||
* Sorting: sort=-published_at (prefix with "-" for descending)
|
||||
*/
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('viewAny', Page::class);
|
||||
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$pages = Page::query()
|
||||
->with(['category', 'author'])
|
||||
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||
->when($request->filled('category_id'), fn ($q) => $q->where('category_id', $request->integer('category_id')))
|
||||
->when($request->has('is_featured'), fn ($q) => $q->where('is_featured', $request->boolean('is_featured')))
|
||||
// Search inside the active locale's translation rather than across
|
||||
// the whole JSON blob, so a Spanish query cannot match English text.
|
||||
->when($request->filled('search'), fn ($q) => $q->where(
|
||||
fn ($sub) => $sub
|
||||
->where("title->{$locale}", 'like', '%'.$request->string('search').'%')
|
||||
->orWhere("excerpt->{$locale}", 'like', '%'.$request->string('search').'%')
|
||||
))
|
||||
->when($request->filled('translated_in'), fn ($q) => $q->translatedIn((string) $request->string('translated_in')))
|
||||
->when($request->boolean('trashed'), fn ($q) => $q->onlyTrashed())
|
||||
// Callers without the publish permission only ever see live content.
|
||||
->unless($request->user()->can('pages.publish'), fn ($q) => $q->published())
|
||||
->tap(fn ($q) => $this->applySort($q, $request))
|
||||
->paginate(perPage: min($request->integer('per_page', 15), 100))
|
||||
->withQueryString();
|
||||
|
||||
return PageResource::collection($pages);
|
||||
}
|
||||
|
||||
public function store(PageRequest $request): JsonResponse
|
||||
{
|
||||
$this->authorize('create', Page::class);
|
||||
|
||||
$page = new Page;
|
||||
$page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable));
|
||||
$page->author_id ??= $request->user()->id;
|
||||
|
||||
// A page created as published with no explicit date goes live now.
|
||||
if ($page->status === PageStatus::Published && $page->published_at === null) {
|
||||
$page->published_at = now();
|
||||
}
|
||||
|
||||
$page->save();
|
||||
|
||||
return PageResource::make($page->load(['category', 'author']))
|
||||
->response()
|
||||
->setStatusCode(201);
|
||||
}
|
||||
|
||||
public function show(Request $request, Page $page): PageResource
|
||||
{
|
||||
$this->authorize('view', $page);
|
||||
|
||||
return PageResource::make($page->load(['category', 'author']));
|
||||
}
|
||||
|
||||
public function update(PageRequest $request, Page $page): PageResource
|
||||
{
|
||||
$this->authorize('update', $page);
|
||||
|
||||
$page->fill(TranslatablePayload::merge($page, $request->validated(), $page->translatable));
|
||||
|
||||
if ($page->status === PageStatus::Published && $page->published_at === null) {
|
||||
$page->published_at = now();
|
||||
}
|
||||
|
||||
$page->save();
|
||||
|
||||
return PageResource::make($page->load(['category', 'author']));
|
||||
}
|
||||
|
||||
public function destroy(Page $page): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $page);
|
||||
|
||||
$page->delete();
|
||||
|
||||
return response()->json(status: 204);
|
||||
}
|
||||
|
||||
public function restore(Page $page): PageResource
|
||||
{
|
||||
$this->authorize('restore', $page);
|
||||
|
||||
$page->restore();
|
||||
|
||||
return PageResource::make($page->load(['category', 'author']));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitelist of sortable columns. Anything else is ignored rather than
|
||||
* passed to the query builder.
|
||||
*
|
||||
* @param Builder<Page> $query
|
||||
*/
|
||||
protected function applySort(Builder $query, Request $request): void
|
||||
{
|
||||
$sort = (string) $request->string('sort', '-created_at');
|
||||
$direction = str_starts_with($sort, '-') ? 'desc' : 'asc';
|
||||
$column = ltrim($sort, '-');
|
||||
|
||||
$sortable = ['created_at', 'updated_at', 'published_at', 'position', 'status'];
|
||||
|
||||
// Sorting by a translatable column targets the active locale's key.
|
||||
if ($column === 'title') {
|
||||
$query->orderBy('title->'.app()->getLocale(), $direction);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$query->orderBy(in_array($column, $sortable, true) ? $column : 'created_at', $direction);
|
||||
}
|
||||
}
|
||||
50
app/Http/Controllers/Api/V1/RoleController.php
Normal file
50
app/Http/Controllers/Api/V1/RoleController.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\RoleResource;
|
||||
use App\Support\Permissions;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
/**
|
||||
* Read-only view of the local authorisation model, so clients can render
|
||||
* permission-aware UI without hard-coding role names.
|
||||
*/
|
||||
class RoleController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('viewAny', Role::class);
|
||||
|
||||
$roles = Role::query()
|
||||
->with('permissions')
|
||||
->withCount('users')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return RoleResource::collection($roles);
|
||||
}
|
||||
|
||||
public function show(Role $role): RoleResource
|
||||
{
|
||||
$this->authorize('view', $role);
|
||||
|
||||
return RoleResource::make($role->load('permissions')->loadCount('users'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The full permission catalogue, grouped by resource — useful for building
|
||||
* a role editor outside the panel.
|
||||
*/
|
||||
public function catalogue(): JsonResponse
|
||||
{
|
||||
$this->authorize('viewAny', Role::class);
|
||||
|
||||
return response()->json(['data' => Permissions::grouped()]);
|
||||
}
|
||||
}
|
||||
97
app/Http/Controllers/Api/V1/TokenController.php
Normal file
97
app/Http/Controllers/Api/V1/TokenController.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Actions\Auth\SyncPireonUser;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Pireon\PireonClient;
|
||||
use App\Services\Pireon\PireonException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/token
|
||||
*
|
||||
* A thin, rate-limited proxy over the identity server's password and
|
||||
* refresh_token grants, so first-party clients (mobile apps, test suites) can
|
||||
* obtain a token without embedding the OAuth server's URL and client secret.
|
||||
*
|
||||
* The panel's sign-in form uses the same grant directly, so
|
||||
* `PIREON_PASSWORD_GRANT=false` closes both doors at once.
|
||||
*/
|
||||
class TokenController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PireonClient $client,
|
||||
protected SyncPireonUser $sync,
|
||||
) {}
|
||||
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'grant_type' => ['required', 'in:password,refresh_token'],
|
||||
'username' => ['required_if:grant_type,password', 'string'],
|
||||
'password' => ['required_if:grant_type,password', 'string'],
|
||||
'refresh_token' => ['required_if:grant_type,refresh_token', 'string'],
|
||||
'scope' => ['sometimes', 'string'],
|
||||
]);
|
||||
|
||||
$scopes = isset($data['scope'])
|
||||
? array_values(array_filter(explode(' ', $data['scope'])))
|
||||
: null;
|
||||
|
||||
try {
|
||||
$token = $data['grant_type'] === 'password'
|
||||
? $this->client->requestPasswordToken($data['username'], $data['password'], $scopes)
|
||||
: $this->client->refresh($data['refresh_token']);
|
||||
|
||||
$identity = $this->client->identity($token->accessToken);
|
||||
} catch (PireonException $e) {
|
||||
// An identity server that failed to answer has said nothing about
|
||||
// these credentials, so it must not be reported as though it had.
|
||||
if (! $e->isRejection()) {
|
||||
return response()->json([
|
||||
'message' => __('pireon.server_unavailable'),
|
||||
], Response::HTTP_SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// Otherwise a validation error rather than the upstream body, so
|
||||
// we never echo details of the identity server back to a caller
|
||||
// probing for valid accounts.
|
||||
throw ValidationException::withMessages([
|
||||
'username' => [__('auth.failed')],
|
||||
])->status($e->status === 401 ? 401 : 422);
|
||||
}
|
||||
|
||||
if (! $identity->isUsable()) {
|
||||
return response()->json([
|
||||
'message' => $identity->isApproved()
|
||||
? __('pireon.inactive')
|
||||
: __('pireon.pending_approval'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
// Provision the local account now, so the very first API call after
|
||||
// this one already has roles and a user row to authorise against.
|
||||
$user = $this->sync->handle($identity);
|
||||
|
||||
return response()->json([
|
||||
'access_token' => $token->accessToken,
|
||||
'refresh_token' => $token->refreshToken,
|
||||
'token_type' => $token->tokenType,
|
||||
'expires_at' => $token->expiresAt?->toIso8601String(),
|
||||
'scopes' => $token->scopes,
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'roles' => $user->roles->pluck('name')->values(),
|
||||
'permissions' => $user->getAllPermissions()->pluck('name')->values(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
83
app/Http/Controllers/Api/V1/UserController.php
Normal file
83
app/Http/Controllers/Api/V1/UserController.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
/**
|
||||
* Local user administration.
|
||||
*
|
||||
* Only fields this application owns are writable: the local role assignment,
|
||||
* the active flag, and the UI locale. Names and emails belong to the identity
|
||||
* server and are refreshed from it on every login, so editing them here would
|
||||
* be undone — change those at oauth.pireon.dev instead.
|
||||
*/
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('viewAny', User::class);
|
||||
|
||||
$users = User::query()
|
||||
->with('roles')
|
||||
->when($request->filled('search'), fn ($q) => $q->where(
|
||||
fn ($sub) => $sub
|
||||
->where('name', 'like', '%'.$request->string('search').'%')
|
||||
->orWhere('email', 'like', '%'.$request->string('search').'%')
|
||||
))
|
||||
->when($request->has('is_active'), fn ($q) => $q->where('is_active', $request->boolean('is_active')))
|
||||
->when($request->filled('role'), fn ($q) => $q->whereHas(
|
||||
'roles',
|
||||
fn ($sub) => $sub->where('name', $request->string('role'))
|
||||
))
|
||||
->orderBy('name')
|
||||
->paginate(perPage: min($request->integer('per_page', 25), 100))
|
||||
->withQueryString();
|
||||
|
||||
return UserResource::collection($users);
|
||||
}
|
||||
|
||||
public function show(User $user): UserResource
|
||||
{
|
||||
$this->authorize('view', $user);
|
||||
|
||||
return UserResource::make($user->load('roles'));
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user): UserResource
|
||||
{
|
||||
$this->authorize('update', $user);
|
||||
|
||||
$data = $request->validate([
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'locale' => ['sometimes', 'nullable', Rule::in(array_keys(config('locales.supported')))],
|
||||
'roles' => ['sometimes', 'array'],
|
||||
'roles.*' => [Rule::exists('roles', 'name')],
|
||||
]);
|
||||
|
||||
if (array_key_exists('roles', $data)) {
|
||||
$this->authorize('update', Role::class);
|
||||
|
||||
// Only a super admin may hand out super_admin, and nobody may
|
||||
// strip their own privileges out from under themselves.
|
||||
if (in_array('super_admin', $data['roles'], true) && ! $request->user()->hasRole('super_admin')) {
|
||||
abort(403, __('pireon.cannot_grant_super_admin'));
|
||||
}
|
||||
|
||||
$user->syncRoles($data['roles']);
|
||||
unset($data['roles']);
|
||||
}
|
||||
|
||||
$user->fill($data)->save();
|
||||
|
||||
return UserResource::make($user->fresh()->load('roles'));
|
||||
}
|
||||
}
|
||||
132
app/Http/Controllers/Auth/PireonAuthController.php
Normal file
132
app/Http/Controllers/Auth/PireonAuthController.php
Normal file
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Actions\Auth\SyncPireonUser;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Pireon\PireonClient;
|
||||
use App\Services\Pireon\PireonException;
|
||||
use App\Services\Pireon\TokenStore;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Authorization Code + PKCE flow against the Pireon identity server.
|
||||
*
|
||||
* The browser never sees a password belonging to this application, and the
|
||||
* authorization code alone is useless without the verifier we keep server-side.
|
||||
*/
|
||||
class PireonAuthController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PireonClient $client,
|
||||
protected TokenStore $tokens,
|
||||
protected SyncPireonUser $sync,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Step 1 — hand the browser off to the identity server.
|
||||
*/
|
||||
public function redirect(Request $request): RedirectResponse
|
||||
{
|
||||
if (Auth::check()) {
|
||||
return redirect()->intended(filament()->getUrl());
|
||||
}
|
||||
|
||||
try {
|
||||
['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = $this->client->authorizationUrl();
|
||||
} catch (PireonException $e) {
|
||||
return $this->fail($e, route('login'));
|
||||
}
|
||||
|
||||
$request->session()->put('pireon.state', $state);
|
||||
$request->session()->put('pireon.code_verifier', $verifier);
|
||||
|
||||
// Remember where the user was headed before the redirect, so the
|
||||
// callback can send them back there.
|
||||
if ($target = $request->query('intended')) {
|
||||
$request->session()->put('url.intended', $target);
|
||||
}
|
||||
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 — the identity server sends the browser back with a code.
|
||||
*/
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
// The user pressed "deny", or the server refused the request.
|
||||
if ($request->filled('error')) {
|
||||
return redirect()->route('login')->with('error', __('pireon.denied', [
|
||||
'reason' => (string) $request->query('error_description', $request->query('error')),
|
||||
]));
|
||||
}
|
||||
|
||||
$state = $request->session()->pull('pireon.state');
|
||||
$verifier = $request->session()->pull('pireon.code_verifier');
|
||||
|
||||
// CSRF protection for the callback: a code arriving with a state we
|
||||
// did not issue is not the result of a login we started.
|
||||
if (blank($state) || ! hash_equals((string) $state, (string) $request->query('state'))) {
|
||||
return $this->fail(PireonException::invalidState(), route('login'));
|
||||
}
|
||||
|
||||
if (blank($verifier) || blank($request->query('code'))) {
|
||||
return $this->fail(PireonException::invalidState(), route('login'));
|
||||
}
|
||||
|
||||
try {
|
||||
$token = $this->client->exchangeCode((string) $request->query('code'), (string) $verifier);
|
||||
$identity = $this->client->identity($token->accessToken);
|
||||
} catch (PireonException $e) {
|
||||
return $this->fail($e, route('login'));
|
||||
}
|
||||
|
||||
if (! $identity->isUsable()) {
|
||||
return redirect()->route('login')->with('error', $identity->isApproved()
|
||||
? __('pireon.inactive')
|
||||
: __('pireon.pending_approval'));
|
||||
}
|
||||
|
||||
$user = $this->sync->handle($identity);
|
||||
|
||||
// Rotate the session id so a fixated pre-login session cannot be
|
||||
// reused now that it carries an authenticated identity.
|
||||
$request->session()->regenerate();
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$this->tokens->put($token);
|
||||
|
||||
if ($user->locale) {
|
||||
$request->session()->put(config('locales.session_key'), $user->locale);
|
||||
}
|
||||
|
||||
return redirect()->intended(filament()->getUrl());
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
$this->tokens->forget();
|
||||
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
protected function fail(PireonException $e, string $to): RedirectResponse
|
||||
{
|
||||
Log::warning('Pireon login failed', [
|
||||
'message' => $e->getMessage(),
|
||||
'status' => $e->status,
|
||||
]);
|
||||
|
||||
return redirect()->to($to)->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
14
app/Http/Controllers/Controller.php
Normal file
14
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
// Gives controllers $this->authorize(), so the REST API enforces the same
|
||||
// policies the Filament panel does.
|
||||
use AuthorizesRequests;
|
||||
}
|
||||
46
app/Http/Controllers/DocsController.php
Normal file
46
app/Http/Controllers/DocsController.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
/**
|
||||
* Browsable documentation for both APIs.
|
||||
*
|
||||
* The specification itself has always been there, as a file
|
||||
* ([`public/openapi.yaml`](public/openapi.yaml)); what was missing was
|
||||
* somewhere to read it. The people who need it most write the public frontend
|
||||
* and have no reason to hold a panel account, so this is readable without
|
||||
* signing in — the specification describes endpoints, not secrets, and it was
|
||||
* already served at a fixed public path.
|
||||
*/
|
||||
class DocsController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): View
|
||||
{
|
||||
if (! file_exists(public_path('openapi.yaml'))) {
|
||||
throw new NotFoundHttpException('The API specification has not been published.');
|
||||
}
|
||||
|
||||
return view('docs.api', [
|
||||
'spec' => asset('openapi.yaml'),
|
||||
|
||||
// Beside the specification because it is the same contract, and
|
||||
// whoever is here is the person who needs it: generated with
|
||||
// `php artisan api:types`, absent if it was never generated.
|
||||
'types' => file_exists(public_path('api-types.d.ts')) ? asset('api-types.d.ts') : null,
|
||||
|
||||
// "Try it out" must call the installation you are actually
|
||||
// looking at, not the canonical one in the configuration — the
|
||||
// same page is opened on a laptop, on staging and in production.
|
||||
'apiBase' => url('/api'),
|
||||
|
||||
'server' => $request->getHost(),
|
||||
'environment' => (string) config('app.env'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
28
app/Http/Controllers/LocaleController.php
Normal file
28
app/Http/Controllers/LocaleController.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Switches the active locale from the panel's language menu, persisting the
|
||||
* choice on the user record so it survives into their next session.
|
||||
*/
|
||||
class LocaleController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, string $locale): RedirectResponse
|
||||
{
|
||||
abort_unless(array_key_exists($locale, config('locales.supported')), 404);
|
||||
|
||||
$request->session()->put(config('locales.session_key'), $locale);
|
||||
|
||||
if ($user = $request->user()) {
|
||||
$user->forceFill(['locale' => $locale])->save();
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
81
app/Http/Middleware/AllowPreview.php
Normal file
81
app/Http/Middleware/AllowPreview.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\PreviewLink;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Lets the frontend render unpublished content when it presents the preview
|
||||
* secret.
|
||||
*
|
||||
* Because the frontend renders on the server, this is simple: the preview route
|
||||
* forwards the secret on its own fetch, and nothing about it reaches the
|
||||
* visitor's browser. The flag is read by the public controllers to decide
|
||||
* whether to apply the `published` scope.
|
||||
*/
|
||||
class AllowPreview
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$request->attributes->set('preview', $this->isPreview($request));
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
protected function isPreview(Request $request): bool
|
||||
{
|
||||
return $this->hasSharedSecret($request) || $this->hasSignedLink($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared secret: one value, held by the frontend, that unlocks
|
||||
* everything unpublished.
|
||||
*/
|
||||
protected function hasSharedSecret(Request $request): bool
|
||||
{
|
||||
$secret = (string) config('site.preview_token');
|
||||
|
||||
// An unset secret must never mean "everyone gets preview access".
|
||||
if (blank($secret)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$presented = $request->header('X-Preview-Token')
|
||||
?? $request->query('preview_token');
|
||||
|
||||
return is_string($presented) && hash_equals($secret, $presented);
|
||||
}
|
||||
|
||||
/**
|
||||
* A link generated for one record.
|
||||
*
|
||||
* Signed and short-lived, and it says which record it is for — so one
|
||||
* forwarded to the wrong person leaks a single draft rather than every
|
||||
* unpublished thing on the site.
|
||||
*/
|
||||
protected function hasSignedLink(Request $request): bool
|
||||
{
|
||||
$token = $request->header('X-Preview-Link') ?? $request->query('preview');
|
||||
|
||||
if (! is_string($token) || blank($token)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = PreviewLink::open($token);
|
||||
|
||||
if ($payload === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handed to the controllers so a link for one draft cannot be used to
|
||||
// read another.
|
||||
$request->attributes->set('preview_scope', $payload);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
89
app/Http/Middleware/CachePublicResponse.php
Normal file
89
app/Http/Middleware/CachePublicResponse.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* HTTP caching for the public API.
|
||||
*
|
||||
* With an SSR frontend this API sits in the critical path of every page view,
|
||||
* so these headers are not a nicety: they are what lets a CDN or the frontend
|
||||
* server avoid a round trip per visitor.
|
||||
*
|
||||
* `stale-while-revalidate` matters most — it means a brief outage or a slow
|
||||
* response here degrades into slightly stale content rather than a broken page.
|
||||
*/
|
||||
class CachePublicResponse
|
||||
{
|
||||
public function handle(Request $request, Closure $next, int $seconds = 60): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
// Never cache a preview: the whole point is seeing unpublished work
|
||||
// the moment it changes. Nor anything that was not a read — a
|
||||
// submission's confirmation belongs to one visitor only.
|
||||
if ($request->attributes->get('preview', false)
|
||||
|| ! $request->isMethodCacheable()
|
||||
|| $response->getStatusCode() >= 400) {
|
||||
$response->headers->set('Cache-Control', 'no-store, private');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
$response->headers->set(
|
||||
'Cache-Control',
|
||||
"public, max-age=0, s-maxage={$seconds}, stale-while-revalidate=".($seconds * 10),
|
||||
);
|
||||
|
||||
// Responses vary by language, and the language is negotiated from the
|
||||
// header as well as the query string. Without this a shared cache
|
||||
// would serve Catalan to a Spanish visitor.
|
||||
$response->headers->set('Vary', 'Accept-Language, Accept, Origin');
|
||||
|
||||
$this->applyEntityTag($request, $response);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* A content-derived ETag, so an unchanged payload costs the caller a 304
|
||||
* with an empty body instead of the full JSON.
|
||||
*/
|
||||
protected function applyEntityTag(Request $request, Response $response): void
|
||||
{
|
||||
if (! $response->getContent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$etag = '"'.hash('xxh128', $response->getContent()).'"';
|
||||
$response->headers->set('ETag', $etag);
|
||||
|
||||
$ifNoneMatch = $request->headers->get('If-None-Match');
|
||||
|
||||
if ($ifNoneMatch !== null && $this->matches($ifNoneMatch, $etag)) {
|
||||
$response->setNotModified();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `If-None-Match` may carry several tags, and a proxy may have weakened
|
||||
* ours with a `W/` prefix along the way.
|
||||
*/
|
||||
protected function matches(string $ifNoneMatch, string $etag): bool
|
||||
{
|
||||
foreach (explode(',', $ifNoneMatch) as $candidate) {
|
||||
$candidate = ltrim(trim($candidate), 'W/');
|
||||
|
||||
if ($candidate === '*' || hash_equals($etag, $candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
61
app/Http/Middleware/EnsureSiteIsAvailable.php
Normal file
61
app/Http/Middleware/EnsureSiteIsAvailable.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\Maintenance;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Answers 503 on the public API while the site is in maintenance.
|
||||
*
|
||||
* Applied only to `/api/public/*`, never to the panel or the admin API: the
|
||||
* collective must be able to keep working — and to fix whatever caused the
|
||||
* maintenance — while visitors see the notice.
|
||||
*
|
||||
* The body carries the translated message so the frontend can render a proper
|
||||
* maintenance page from it rather than having to hardcode one.
|
||||
*/
|
||||
class EnsureSiteIsAvailable
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! Maintenance::isEnabled() || $this->mayBypass($request)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$until = Maintenance::until();
|
||||
|
||||
return response()->json([
|
||||
'message' => Maintenance::message(),
|
||||
'maintenance' => true,
|
||||
'locale' => app()->getLocale(),
|
||||
'since' => Maintenance::startedAt()?->toIso8601String(),
|
||||
'until' => $until?->toIso8601String(),
|
||||
], Response::HTTP_SERVICE_UNAVAILABLE, [
|
||||
'Retry-After' => (string) Maintenance::retryAfter(),
|
||||
// 503 must never be cached, or the notice would outlive the
|
||||
// maintenance itself.
|
||||
'Cache-Control' => 'no-store, private',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The team still sees the real site.
|
||||
*
|
||||
* The preview secret already means "I am the frontend acting for someone
|
||||
* trusted", and the frontend's own address is equally trusted — so both
|
||||
* pass straight through and can verify the fix before lifting the flag.
|
||||
*/
|
||||
protected function mayBypass(Request $request): bool
|
||||
{
|
||||
if ($request->attributes->get('preview', false)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($request->ip(), (array) config('site.trusted_ips'), true);
|
||||
}
|
||||
}
|
||||
93
app/Http/Middleware/SetLocale.php
Normal file
93
app/Http/Middleware/SetLocale.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Resolves the locale for the request and applies it to the framework, to
|
||||
* Carbon, and to translatable model attributes.
|
||||
*
|
||||
* Precedence, most specific first:
|
||||
*
|
||||
* 1. ?lang=xx — an explicit, single-request override (API friendly)
|
||||
* 2. session — what the signed-in visitor picked in the switcher
|
||||
* 3. users.locale — their stored preference
|
||||
* 4. Accept-Language — what their client asked for
|
||||
* 5. locales.default
|
||||
*/
|
||||
class SetLocale
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$locale = $this->resolve($request);
|
||||
|
||||
app()->setLocale($locale);
|
||||
|
||||
// Dates rendered in the panel and serialised by the API follow suit.
|
||||
CarbonImmutable::setLocale($locale);
|
||||
Carbon::setLocale($locale);
|
||||
|
||||
// Translatable attributes read this locale, and fall back to
|
||||
// locales.fallback when a record has no translation for it.
|
||||
app()->setFallbackLocale((string) config('locales.fallback'));
|
||||
|
||||
$response = $next($request);
|
||||
|
||||
// Lets caches and clients see which language they actually received.
|
||||
$response->headers->set('Content-Language', $locale);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
protected function resolve(Request $request): string
|
||||
{
|
||||
$supported = array_keys(config('locales.supported'));
|
||||
|
||||
$candidates = [
|
||||
$request->query(config('locales.query_parameter')),
|
||||
// The API stack is stateless, so there is no session to consult.
|
||||
// hasSession() is the safe check — session() throws when unset.
|
||||
$request->hasSession()
|
||||
? $request->session()->get(config('locales.session_key'))
|
||||
: null,
|
||||
$request->user()?->locale,
|
||||
$this->fromAcceptLanguage($request, $supported),
|
||||
config('locales.default'),
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_string($candidate) && in_array($candidate, $supported, true)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return (string) config('locales.default');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best supported locale from the Accept-Language header, honouring
|
||||
* its quality values. "ca-ES" matches the "ca" we support.
|
||||
*
|
||||
* @param array<int, string> $supported
|
||||
*/
|
||||
protected function fromAcceptLanguage(Request $request, array $supported): ?string
|
||||
{
|
||||
foreach ($request->getLanguages() as $language) {
|
||||
$base = strtolower(str_replace('_', '-', $language));
|
||||
$base = explode('-', $base)[0];
|
||||
|
||||
if (in_array($base, $supported, true)) {
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
47
app/Http/Middleware/VerifyIngestToken.php
Normal file
47
app/Http/Middleware/VerifyIngestToken.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Guards the endpoints the frontend writes to on its own behalf.
|
||||
*
|
||||
* Everything else under /api/public is either read-only or written by a
|
||||
* visitor filling in a form. This is neither: it is one server talking to
|
||||
* another, so it can hold a secret, and it should — an open write endpoint is
|
||||
* an invitation to fill a table with someone else's idea of a URL.
|
||||
*
|
||||
* The secret travels in a header rather than the query string so it stays out
|
||||
* of access logs. An unset token disables the endpoint rather than opening it,
|
||||
* the same rule the preview secret follows.
|
||||
*/
|
||||
class VerifyIngestToken
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$secret = (string) config('site.telemetry.token');
|
||||
|
||||
if (blank($secret)) {
|
||||
return $this->refuse(__('site.telemetry.disabled'));
|
||||
}
|
||||
|
||||
$presented = $request->header('X-Ingest-Token');
|
||||
|
||||
if (! is_string($presented) || ! hash_equals($secret, $presented)) {
|
||||
return $this->refuse(__('site.telemetry.forbidden'));
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
protected function refuse(string $message): Response
|
||||
{
|
||||
return response()->json(['message' => $message], Response::HTTP_FORBIDDEN)
|
||||
->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
}
|
||||
42
app/Http/Requests/Api/V1/CategoryRequest.php
Normal file
42
app/Http/Requests/Api/V1/CategoryRequest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Rules\Translatable;
|
||||
use App\Rules\UniqueTranslatedSlug;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CategoryRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$category = $this->route('category');
|
||||
$creating = ! $category instanceof Category;
|
||||
$sometimes = $creating ? 'required' : 'sometimes';
|
||||
|
||||
return [
|
||||
'name' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
|
||||
'slug' => [
|
||||
$sometimes,
|
||||
new Translatable(requireDefaultLocale: $creating, max: 255),
|
||||
new UniqueTranslatedSlug(Category::class, $category?->id),
|
||||
],
|
||||
'description' => ['sometimes', 'nullable', new Translatable(max: 1000)],
|
||||
|
||||
'color' => ['sometimes', 'nullable', 'string', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
||||
'position' => ['sometimes', 'integer', 'min:0'],
|
||||
'is_visible' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
73
app/Http/Requests/Api/V1/PageRequest.php
Normal file
73
app/Http/Requests/Api/V1/PageRequest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Api\V1;
|
||||
|
||||
use App\Enums\PageStatus;
|
||||
use App\Models\Page;
|
||||
use App\Rules\Translatable;
|
||||
use App\Rules\UniqueTranslatedSlug;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
/**
|
||||
* Shared validation for creating and updating pages. On update every field is
|
||||
* optional (PATCH semantics) but keeps the same shape rules.
|
||||
*/
|
||||
class PageRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Authorisation is handled by the controller's policy calls, which produce
|
||||
* clearer 403s than a boolean here would.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$page = $this->route('page');
|
||||
$creating = ! $page instanceof Page;
|
||||
$sometimes = $creating ? 'required' : 'sometimes';
|
||||
|
||||
return [
|
||||
'title' => [$sometimes, new Translatable(requireDefaultLocale: $creating, max: 255)],
|
||||
'slug' => [
|
||||
$sometimes,
|
||||
new Translatable(requireDefaultLocale: $creating, max: 255),
|
||||
new UniqueTranslatedSlug(Page::class, $page?->id),
|
||||
],
|
||||
'excerpt' => ['sometimes', 'nullable', new Translatable(max: 500)],
|
||||
'body' => ['sometimes', 'nullable', new Translatable],
|
||||
'meta_title' => ['sometimes', 'nullable', new Translatable(max: 60)],
|
||||
'meta_description' => ['sometimes', 'nullable', new Translatable(max: 160)],
|
||||
|
||||
'category_id' => ['sometimes', 'nullable', 'integer', Rule::exists('categories', 'id')->whereNull('deleted_at')],
|
||||
'status' => ['sometimes', Rule::enum(PageStatus::class)],
|
||||
'featured_image' => ['sometimes', 'nullable', 'string', 'max:2048'],
|
||||
'featured_image_alt' => ['sometimes', 'nullable', new Translatable(max: 255)],
|
||||
'is_featured' => ['sometimes', 'boolean'],
|
||||
'published_at' => ['sometimes', 'nullable', 'date'],
|
||||
'unpublish_at' => ['sometimes', 'nullable', 'date', 'after:published_at'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(Validator $validator): void
|
||||
{
|
||||
$validator->after(function (Validator $validator): void {
|
||||
// Publishing is a distinct permission from editing, so a caller
|
||||
// who may write content still cannot push it live on their own.
|
||||
$status = $this->input('status');
|
||||
|
||||
if ($status === PageStatus::Published->value && $this->user()?->cannot('pages.publish')) {
|
||||
$validator->errors()->add('status', __('rules.pages.cannot_publish'));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
73
app/Http/Requests/PublicApi/ReportMissingUrlRequest.php
Normal file
73
app/Http/Requests/PublicApi/ReportMissingUrlRequest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\PublicApi;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* What the frontend could not resolve.
|
||||
*
|
||||
* Two shapes are accepted, because both are natural on the other side: a
|
||||
* single report, which is what one rendered 404 produces, and a batch, which
|
||||
* is what a queue on the frontend flushes. The single form is folded into a
|
||||
* batch of one before validation, so there is one code path from here on.
|
||||
*/
|
||||
class ReportMissingUrlRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
// The shared secret is the authorisation, and the middleware has
|
||||
// already checked it.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept `{"path": "..."}` as `{"hits": [{"path": "..."}]}`.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('path') && ! $this->has('hits')) {
|
||||
$this->merge(['hits' => [$this->only(['path', 'locale', 'referrer'])]]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'hits' => ['required', 'array', 'min:1', 'max:'.$this->maxBatch()],
|
||||
|
||||
// Paths, not URLs: the frontend knows its own origin, and a report
|
||||
// carrying a full address would let a caller file a 404 against
|
||||
// somebody else's domain.
|
||||
'hits.*.path' => ['required', 'string', 'max:500', 'starts_with:/'],
|
||||
|
||||
'hits.*.locale' => [
|
||||
'sometimes', 'nullable', 'string',
|
||||
Rule::in(array_keys((array) config('locales.supported'))),
|
||||
],
|
||||
|
||||
// Where the visitor came from, verbatim from the Referer header,
|
||||
// so it can be a URL on anybody's domain.
|
||||
'hits.*.referrer' => ['sometimes', 'nullable', 'string', 'url:http,https', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, string|null>>
|
||||
*/
|
||||
public function hits(): array
|
||||
{
|
||||
return array_values((array) $this->validated('hits'));
|
||||
}
|
||||
|
||||
public function maxBatch(): int
|
||||
{
|
||||
return max(1, (int) config('site.telemetry.max_batch', 50));
|
||||
}
|
||||
}
|
||||
74
app/Http/Requests/PublicApi/ReportViewsRequest.php
Normal file
74
app/Http/Requests/PublicApi/ReportViewsRequest.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\PublicApi;
|
||||
|
||||
use App\Support\ContentTypes;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* What the frontend rendered.
|
||||
*
|
||||
* Addressed the way the frontend already thinks: it fetched
|
||||
* `/public/v1/projects/mi-web` to draw the page, so it knows the type and the
|
||||
* slug without having to parse its own URL back apart. That also means a
|
||||
* report can only ever name something this API serves.
|
||||
*/
|
||||
class ReportViewsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept one report the same as a batch of one.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->has('type') && ! $this->has('views')) {
|
||||
$this->merge(['views' => [$this->only(['type', 'slug', 'locale', 'count'])]]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'views' => ['required', 'array', 'min:1', 'max:'.$this->maxBatch()],
|
||||
|
||||
'views.*.type' => ['required', 'string', Rule::in(ContentTypes::keys())],
|
||||
'views.*.slug' => ['required', 'string', 'max:200'],
|
||||
|
||||
'views.*.locale' => [
|
||||
'sometimes', 'nullable', 'string',
|
||||
Rule::in(array_keys((array) config('locales.supported'))),
|
||||
],
|
||||
|
||||
/*
|
||||
* Optional, because the obvious client sends one report per page
|
||||
* view. Capped because the frontend is trusted to count its own
|
||||
* renders and not to invent an audience: a number nobody can
|
||||
* check is a number worth bounding.
|
||||
*/
|
||||
'views.*.count' => ['sometimes', 'integer', 'min:1', 'max:1000'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function views(): array
|
||||
{
|
||||
return array_values((array) $this->validated('views'));
|
||||
}
|
||||
|
||||
public function maxBatch(): int
|
||||
{
|
||||
return max(1, (int) config('site.telemetry.max_batch', 50));
|
||||
}
|
||||
}
|
||||
115
app/Http/Requests/PublicApi/SubmitApplicationRequest.php
Normal file
115
app/Http/Requests/PublicApi/SubmitApplicationRequest.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\PublicApi;
|
||||
|
||||
use App\Enums\ApplicationKind;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* The only request in the application whose author is a stranger.
|
||||
*
|
||||
* Everything the sender may set is listed here; anything else about the
|
||||
* submission — its locale, its status, which call it belongs to — is decided
|
||||
* on our side, never taken from the payload.
|
||||
*/
|
||||
class SubmitApplicationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'min:2', 'max:120'],
|
||||
'email' => ['required', 'string', 'email:rfc', 'max:255'],
|
||||
|
||||
// A minimum length is worth more than it looks: it costs a real
|
||||
// person nothing and rules out the one-word submissions that are
|
||||
// never anything but noise.
|
||||
'message' => ['required', 'string', 'min:20', 'max:5000'],
|
||||
|
||||
// Only the general form offers a choice; applying to a call is
|
||||
// always an offer of time.
|
||||
'kind' => [
|
||||
$this->isCallApplication() ? 'prohibited' : 'sometimes',
|
||||
Rule::enum(ApplicationKind::class),
|
||||
],
|
||||
|
||||
'organisation' => [
|
||||
Rule::requiredIf(fn () => $this->input('kind') === ApplicationKind::Request->value),
|
||||
'nullable', 'string', 'max:160',
|
||||
],
|
||||
|
||||
'hours_per_week' => ['sometimes', 'nullable', 'integer', 'min:1', 'max:40'],
|
||||
|
||||
'links' => ['sometimes', 'nullable', 'array', 'max:5'],
|
||||
'links.*' => ['nullable', 'url:http,https', 'max:255'],
|
||||
|
||||
// Personal data given by someone who is not a user of this system,
|
||||
// so the agreement to store it is part of the payload rather than
|
||||
// something the frontend is trusted to have asked for.
|
||||
'consent' => ['accepted'],
|
||||
|
||||
// The honeypot. Present in the rules so an unexpected type cannot
|
||||
// slip past the check that reads it later.
|
||||
$this->honeypotField() => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => __('rules.applications.attributes.name'),
|
||||
'email' => __('rules.applications.attributes.email'),
|
||||
'message' => __('rules.applications.attributes.message'),
|
||||
'organisation' => __('rules.applications.attributes.organisation'),
|
||||
'hours_per_week' => __('rules.applications.attributes.hours_per_week'),
|
||||
'consent' => __('rules.applications.attributes.consent'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this request is answering a specific call.
|
||||
*/
|
||||
public function isCallApplication(): bool
|
||||
{
|
||||
return $this->route('slug') !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the sender wants, with the call route pinned to "contribute".
|
||||
*/
|
||||
public function kind(): ApplicationKind
|
||||
{
|
||||
if ($this->isCallApplication()) {
|
||||
return ApplicationKind::Contribute;
|
||||
}
|
||||
|
||||
return ApplicationKind::tryFrom((string) $this->input('kind'))
|
||||
?? ApplicationKind::Contribute;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bot fills in every input it finds; a person never sees this one.
|
||||
*/
|
||||
public function looksAutomated(): bool
|
||||
{
|
||||
return filled($this->input($this->honeypotField()));
|
||||
}
|
||||
|
||||
public function honeypotField(): string
|
||||
{
|
||||
return (string) config('site.submissions.honeypot', 'website');
|
||||
}
|
||||
}
|
||||
43
app/Http/Resources/CategoryResource.php
Normal file
43
app/Http/Resources/CategoryResource.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Category
|
||||
*/
|
||||
class CategoryResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'locale' => $locale,
|
||||
|
||||
...TranslatablePayload::resolve($this->resource, $this->translatable, $locale),
|
||||
|
||||
'color' => $this->color,
|
||||
'position' => $this->position,
|
||||
'is_visible' => $this->is_visible,
|
||||
'pages_count' => $this->whenCounted('pages'),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
|
||||
'translations' => $this->when(
|
||||
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
61
app/Http/Resources/PageResource.php
Normal file
61
app/Http/Resources/PageResource.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Page;
|
||||
use App\Support\ImagePayload;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Page
|
||||
*/
|
||||
class PageResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$locale = $request->query(config('locales.query_parameter')) ?? app()->getLocale();
|
||||
$resolved = TranslatablePayload::resolve($this->resource, $this->translatable, $locale);
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'locale' => $locale,
|
||||
|
||||
...$resolved,
|
||||
|
||||
'status' => $this->status->value,
|
||||
'status_label' => $this->status->getLabel(),
|
||||
'is_featured' => $this->is_featured,
|
||||
// The stored path, because this is the editing API and it is
|
||||
// what a client PATCHes back. What was *derived* from it travels
|
||||
// alongside rather than replacing it.
|
||||
'featured_image' => $this->featured_image,
|
||||
'featured_image_media' => ImagePayload::make(
|
||||
$this->featured_image,
|
||||
$this->resource->relationLoaded('featuredMedia') ? $this->resource->getRelation('featuredMedia') : null,
|
||||
$resolved['featured_image_alt'] ?? null,
|
||||
),
|
||||
'published_at' => $this->published_at?->toIso8601String(),
|
||||
'unpublish_at' => $this->unpublish_at?->toIso8601String(),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
'deleted_at' => $this->deleted_at?->toIso8601String(),
|
||||
|
||||
'category' => CategoryResource::make($this->whenLoaded('category')),
|
||||
'author' => UserResource::make($this->whenLoaded('author')),
|
||||
|
||||
// Opt-in: `?with=translations` returns every language at once,
|
||||
// which is what a translation UI needs and a page renderer does not.
|
||||
'translations' => $this->when(
|
||||
in_array('translations', explode(',', (string) $request->query('with')), true),
|
||||
fn () => TranslatablePayload::all($this->resource, $this->translatable),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
53
app/Http/Resources/PublicApi/PublicCallResource.php
Normal file
53
app/Http/Resources/PublicApi/PublicCallResource.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\PublicApi;
|
||||
|
||||
use App\Models\Call;
|
||||
use App\Support\SiteUrls;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Call
|
||||
*/
|
||||
class PublicCallResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'locale' => $locale,
|
||||
|
||||
...TranslatablePayload::resolve(
|
||||
$this->resource,
|
||||
['title', 'slug', 'description', 'what_you_get'],
|
||||
$locale,
|
||||
),
|
||||
|
||||
'status' => $this->status->value,
|
||||
|
||||
// The numbers that let someone decide in ten seconds. Time is the
|
||||
// scarce resource here, not money.
|
||||
'hours_per_week' => $this->hours_per_week,
|
||||
'duration_weeks' => $this->duration_weeks,
|
||||
'total_hours' => $this->totalHours(),
|
||||
'seniority' => $this->seniority,
|
||||
|
||||
'closes_at' => $this->closes_at?->toDateString(),
|
||||
|
||||
'project' => PublicProjectResource::make($this->whenLoaded('project')),
|
||||
'technologies' => PublicTechnologyResource::collection($this->whenLoaded('technologies')),
|
||||
|
||||
'alternates' => SiteUrls::alternates($this->resource, 'call'),
|
||||
'path' => SiteUrls::alternates($this->resource, 'call')[$locale] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
30
app/Http/Resources/PublicApi/PublicCategoryResource.php
Normal file
30
app/Http/Resources/PublicApi/PublicCategoryResource.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\PublicApi;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Category
|
||||
*/
|
||||
class PublicCategoryResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
...TranslatablePayload::resolve($this->resource, $this->translatable, app()->getLocale()),
|
||||
'color' => $this->color,
|
||||
'position' => $this->position,
|
||||
'pages_count' => $this->whenCounted('pages'),
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Http/Resources/PublicApi/PublicClientResource.php
Normal file
43
app/Http/Resources/PublicApi/PublicClientResource.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\PublicApi;
|
||||
|
||||
use App\Models\Client;
|
||||
use App\Support\ImagePayload;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Client
|
||||
*/
|
||||
class PublicClientResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'sector' => $this->getTranslation('sector', $locale, false) ?: null,
|
||||
'url' => $this->url,
|
||||
|
||||
// The logo's alt text is the organisation's name, so there is
|
||||
// nothing to type and nothing to get wrong.
|
||||
'logo' => ImagePayload::make(
|
||||
$this->logo,
|
||||
$this->resource->relationLoaded('logoMedia') ? $this->resource->getRelation('logoMedia') : null,
|
||||
$this->name,
|
||||
),
|
||||
|
||||
'projects_count' => $this->whenCounted('projects'),
|
||||
'projects' => PublicProjectResource::collection($this->whenLoaded('projects')),
|
||||
];
|
||||
}
|
||||
}
|
||||
50
app/Http/Resources/PublicApi/PublicIssueResource.php
Normal file
50
app/Http/Resources/PublicApi/PublicIssueResource.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\PublicApi;
|
||||
|
||||
use App\Models\RepositoryIssue;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin RepositoryIssue
|
||||
*
|
||||
* An open door for someone who has never contributed before.
|
||||
*/
|
||||
class PublicIssueResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'number' => $this->number,
|
||||
'title' => $this->title,
|
||||
'url' => $this->html_url,
|
||||
'labels' => $this->labels ?? [],
|
||||
'is_good_first_issue' => $this->is_good_first_issue,
|
||||
'is_help_wanted' => $this->is_help_wanted,
|
||||
|
||||
// An issue someone has already claimed is not an invitation.
|
||||
'is_available' => $this->isUnclaimed(),
|
||||
|
||||
'comments' => $this->comments,
|
||||
'created_at' => $this->issue_created_at?->toIso8601String(),
|
||||
|
||||
'repository' => $this->whenLoaded('repository', fn () => [
|
||||
'full_name' => $this->repository->full_name,
|
||||
'url' => $this->repository->html_url,
|
||||
'primary_language' => $this->repository->primary_language,
|
||||
'project' => $this->repository->relationLoaded('project') && $this->repository->project
|
||||
? [
|
||||
'title' => $this->repository->project->getTranslation('title', app()->getLocale()),
|
||||
'slug' => $this->repository->project->getTranslation('slug', app()->getLocale()),
|
||||
]
|
||||
: null,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
78
app/Http/Resources/PublicApi/PublicMemberResource.php
Normal file
78
app/Http/Resources/PublicApi/PublicMemberResource.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\PublicApi;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Support\ImagePayload;
|
||||
use App\Support\SiteUrls;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Member
|
||||
*
|
||||
* A member's profile is the real currency a volunteer collective pays people
|
||||
* in, so it carries everything needed to render a proper personal page rather
|
||||
* than a bare avatar and name.
|
||||
*/
|
||||
class PublicMemberResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
|
||||
...TranslatablePayload::resolve($this->resource, ['headline', 'bio'], $locale),
|
||||
|
||||
// A portrait's description is the person's name, so there is no
|
||||
// field to fill in and no way to get it wrong.
|
||||
'avatar' => ImagePayload::make(
|
||||
$this->avatar,
|
||||
$this->resource->relationLoaded('avatarMedia') ? $this->resource->getRelation('avatarMedia') : null,
|
||||
$this->avatarAlt(),
|
||||
),
|
||||
'github_username' => $this->github_username,
|
||||
'links' => (object) ($this->links ?? []),
|
||||
'is_founder' => $this->is_founder,
|
||||
|
||||
// Former members stay on the site with their history intact; the
|
||||
// frontend decides how to present them.
|
||||
'is_current' => $this->left_at === null,
|
||||
'joined_at' => $this->joined_at?->toDateString(),
|
||||
'left_at' => $this->left_at?->toDateString(),
|
||||
|
||||
'role' => $this->whenPivotLoaded('member_project', fn () => $this->pivot->role),
|
||||
|
||||
'technologies' => PublicTechnologyResource::collection($this->whenLoaded('technologies')),
|
||||
'projects' => PublicProjectResource::collection($this->whenLoaded('projects')),
|
||||
|
||||
'alternates' => $this->memberAlternates(),
|
||||
'path' => SiteUrls::path('member', $locale, $this->slug),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* A member's slug is a single string rather than a translated map, so the
|
||||
* same slug is valid in every locale — only the path prefix changes.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function memberAlternates(): array
|
||||
{
|
||||
return collect(array_keys(config('locales.supported')))
|
||||
->mapWithKeys(fn (string $locale) => [
|
||||
$locale => SiteUrls::path('member', $locale, $this->slug),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
67
app/Http/Resources/PublicApi/PublicPageResource.php
Normal file
67
app/Http/Resources/PublicApi/PublicPageResource.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\PublicApi;
|
||||
|
||||
use App\Models\Page;
|
||||
use App\Support\ImagePayload;
|
||||
use App\Support\SiteUrls;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Page
|
||||
*/
|
||||
class PublicPageResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'locale' => $locale,
|
||||
|
||||
/*
|
||||
* An explicit list rather than the model's whole translatable
|
||||
* set. `featured_image_alt` is in that set, and resolving it here
|
||||
* put the alt text at the top level as well as inside
|
||||
* `featured_image`, where the specification says it lives and
|
||||
* where an <img> needs it. Two names for one string is how a
|
||||
* frontend ends up reading the one that later turns out to be the
|
||||
* accidental one.
|
||||
*/
|
||||
...TranslatablePayload::resolve(
|
||||
$this->resource,
|
||||
['title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description'],
|
||||
$locale,
|
||||
),
|
||||
|
||||
'status' => $this->status->value,
|
||||
'is_featured' => $this->is_featured,
|
||||
'featured_image' => ImagePayload::make(
|
||||
$this->featured_image,
|
||||
$this->resource->relationLoaded('featuredMedia') ? $this->resource->getRelation('featuredMedia') : null,
|
||||
$this->getTranslation('featured_image_alt', $locale, false) ?: null,
|
||||
),
|
||||
'published_at' => $this->published_at?->toIso8601String(),
|
||||
|
||||
// So a cache downstream can expire when the content does, rather
|
||||
// than holding a notice that is no longer true.
|
||||
'unpublish_at' => $this->unpublish_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
|
||||
'category' => PublicCategoryResource::make($this->whenLoaded('category')),
|
||||
|
||||
// Lets the frontend emit correct hreflang tags. Only locales that
|
||||
// actually have a translation appear.
|
||||
'alternates' => SiteUrls::alternates($this->resource, 'page'),
|
||||
'path' => SiteUrls::alternates($this->resource, 'page')[$locale] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
141
app/Http/Resources/PublicApi/PublicProjectResource.php
Normal file
141
app/Http/Resources/PublicApi/PublicProjectResource.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\PublicApi;
|
||||
|
||||
use App\Models\Media;
|
||||
use App\Models\Project;
|
||||
use App\Support\ImagePayload;
|
||||
use App\Support\SiteUrls;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Project
|
||||
*/
|
||||
class PublicProjectResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Whether to include the long-form fields. Listings stay light; the detail
|
||||
* endpoint opts in.
|
||||
*/
|
||||
protected bool $detailed = false;
|
||||
|
||||
public function detailed(bool $detailed = true): static
|
||||
{
|
||||
$this->detailed = $detailed;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
$summary = TranslatablePayload::resolve($this->resource, ['title', 'slug', 'tagline'], $locale);
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'locale' => $locale,
|
||||
|
||||
...$summary,
|
||||
|
||||
'status' => $this->status->value,
|
||||
'is_featured' => $this->is_featured,
|
||||
|
||||
// True when flagged directly, or when the project carries an open
|
||||
// call. Read from a count rather than the relation so a listing
|
||||
// does not trigger a query per project.
|
||||
'needs_help' => $this->needs_help || $this->openCallCount() > 0,
|
||||
// A full image object rather than a path: the frontend needs
|
||||
// width and height to reserve the box, a srcset so a phone does
|
||||
// not download the original, and the alt text to be accessible.
|
||||
'cover_image' => ImagePayload::make(
|
||||
$this->cover_image,
|
||||
$this->loadedMedia('coverMedia'),
|
||||
$this->getTranslation('cover_image_alt', $locale, false) ?: null,
|
||||
),
|
||||
'demo_url' => $this->demo_url,
|
||||
|
||||
// Who the work is for. Central to a non-profit collective's story
|
||||
// in a way it never is for a commercial portfolio — and now a
|
||||
// record of its own, so the same organisation is the same thing on
|
||||
// every project it appears in.
|
||||
'client' => PublicClientResource::make($this->whenLoaded('client')),
|
||||
|
||||
'started_at' => $this->started_at?->toDateString(),
|
||||
'ended_at' => $this->ended_at?->toDateString(),
|
||||
'published_at' => $this->published_at?->toIso8601String(),
|
||||
'unpublish_at' => $this->unpublish_at?->toIso8601String(),
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
|
||||
// The long form: the decisions and the postmortem are the content
|
||||
// other developers actually read, so they are never truncated.
|
||||
$this->mergeWhen($this->detailed, fn () => TranslatablePayload::resolve(
|
||||
$this->resource,
|
||||
['description', 'decisions', 'postmortem', 'meta_title', 'meta_description'],
|
||||
$locale,
|
||||
)),
|
||||
|
||||
// Only on the detail endpoint: a listing showing twenty projects
|
||||
// has no use for every screenshot of each one.
|
||||
'gallery' => $this->when(
|
||||
$this->detailed && $this->resource->relationLoaded('images'),
|
||||
fn () => $this->images->map(fn ($image) => [
|
||||
...ImagePayload::make(
|
||||
$image->path,
|
||||
$image->relationLoaded('media') ? $image->media : null,
|
||||
$image->getTranslation('alt', $locale, false) ?: null,
|
||||
),
|
||||
|
||||
// Shown to everyone, unlike the alt text.
|
||||
'caption' => $image->getTranslation('caption', $locale, false) ?: null,
|
||||
])->all(),
|
||||
),
|
||||
|
||||
'technologies' => PublicTechnologyResource::collection($this->whenLoaded('technologies')),
|
||||
'services' => PublicServiceResource::collection($this->whenLoaded('services')),
|
||||
'testimonials' => PublicTestimonialResource::collection($this->whenLoaded('testimonials')),
|
||||
'talks' => PublicTalkResource::collection($this->whenLoaded('talks')),
|
||||
'members' => PublicMemberResource::collection($this->whenLoaded('members')),
|
||||
'lead' => PublicMemberResource::make($this->whenLoaded('lead')),
|
||||
'calls' => PublicCallResource::collection($this->whenLoaded('calls')),
|
||||
|
||||
// Live repository activity — the part that keeps the site looking
|
||||
// alive without anyone maintaining it.
|
||||
'repositories' => PublicRepositoryResource::collection($this->whenLoaded('repositories')),
|
||||
|
||||
'alternates' => SiteUrls::alternates($this->resource, 'project'),
|
||||
'path' => SiteUrls::alternates($this->resource, 'project')[$locale] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The media row when the caller loaded it, and null otherwise — never a
|
||||
* lazy load, because this runs once per row in a listing.
|
||||
*/
|
||||
protected function loadedMedia(string $relation): ?Media
|
||||
{
|
||||
return $this->resource->relationLoaded($relation)
|
||||
? $this->resource->getRelation($relation)
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open calls attached to this project, from whichever source the caller
|
||||
* already loaded — never by lazy-loading the relation.
|
||||
*/
|
||||
protected function openCallCount(): int
|
||||
{
|
||||
if ($this->resource->relationLoaded('calls')) {
|
||||
return $this->calls->count();
|
||||
}
|
||||
|
||||
return (int) ($this->resource->open_calls_count ?? 0);
|
||||
}
|
||||
}
|
||||
49
app/Http/Resources/PublicApi/PublicRepositoryResource.php
Normal file
49
app/Http/Resources/PublicApi/PublicRepositoryResource.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\PublicApi;
|
||||
|
||||
use App\Models\Repository;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Repository
|
||||
*/
|
||||
class PublicRepositoryResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'provider' => $this->provider->value,
|
||||
'full_name' => $this->full_name,
|
||||
'url' => $this->html_url,
|
||||
'description' => $this->description,
|
||||
'primary_language' => $this->primary_language,
|
||||
'languages' => (object) ($this->languages ?? []),
|
||||
'topics' => $this->topics ?? [],
|
||||
'license' => $this->license,
|
||||
|
||||
'stars' => $this->stars,
|
||||
'forks' => $this->forks,
|
||||
'contributors_count' => $this->contributors_count,
|
||||
'open_issues_count' => $this->open_issues_count,
|
||||
|
||||
'is_archived' => $this->is_archived,
|
||||
|
||||
// Lets the frontend avoid presenting dead work as live.
|
||||
'is_dormant' => $this->isDormant(),
|
||||
|
||||
'pushed_at' => $this->pushed_at?->toIso8601String(),
|
||||
'synced_at' => $this->last_synced_at?->toIso8601String(),
|
||||
|
||||
'good_first_issues' => PublicIssueResource::collection($this->whenLoaded('goodFirstIssues')),
|
||||
'issues' => PublicIssueResource::collection($this->whenLoaded('issues')),
|
||||
];
|
||||
}
|
||||
}
|
||||
62
app/Http/Resources/PublicApi/PublicServiceResource.php
Normal file
62
app/Http/Resources/PublicApi/PublicServiceResource.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\PublicApi;
|
||||
|
||||
use App\Models\Service;
|
||||
use App\Support\SiteUrls;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Service
|
||||
*/
|
||||
class PublicServiceResource extends JsonResource
|
||||
{
|
||||
protected bool $detailed = false;
|
||||
|
||||
public function detailed(bool $detailed = true): static
|
||||
{
|
||||
$this->detailed = $detailed;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'locale' => $locale,
|
||||
|
||||
...TranslatablePayload::resolve($this->resource, ['title', 'slug', 'tagline'], $locale),
|
||||
|
||||
'icon' => $this->icon,
|
||||
'is_featured' => $this->is_featured,
|
||||
'position' => $this->position,
|
||||
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||
|
||||
$this->mergeWhen($this->detailed, fn () => TranslatablePayload::resolve(
|
||||
$this->resource,
|
||||
['description', 'meta_title', 'meta_description'],
|
||||
$locale,
|
||||
)),
|
||||
|
||||
// The work that backs the offer. Without it a service is a
|
||||
// promise, which is why it travels with it rather than being
|
||||
// something the frontend has to go and ask for.
|
||||
'projects' => PublicProjectResource::collection($this->whenLoaded('projects')),
|
||||
'technologies' => PublicTechnologyResource::collection($this->whenLoaded('technologies')),
|
||||
'testimonials' => PublicTestimonialResource::collection($this->whenLoaded('testimonials')),
|
||||
|
||||
'alternates' => SiteUrls::alternates($this->resource, 'service'),
|
||||
'path' => SiteUrls::alternates($this->resource, 'service')[$locale] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Http/Resources/PublicApi/PublicTalkResource.php
Normal file
43
app/Http/Resources/PublicApi/PublicTalkResource.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\PublicApi;
|
||||
|
||||
use App\Models\Talk;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Talk
|
||||
*/
|
||||
class PublicTalkResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
|
||||
// Not translated: it is the name of a talk that was given once, in
|
||||
// one language. `language` says which.
|
||||
'title' => $this->title,
|
||||
'language' => $this->language,
|
||||
|
||||
'summary' => $this->getTranslation('summary', $locale, false) ?: null,
|
||||
|
||||
'event' => $this->event,
|
||||
'city' => $this->city,
|
||||
'held_at' => $this->held_at?->toDateString(),
|
||||
|
||||
'video_url' => $this->video_url,
|
||||
'slides_url' => $this->slides_url,
|
||||
|
||||
'speaker' => PublicMemberResource::make($this->whenLoaded('member')),
|
||||
];
|
||||
}
|
||||
}
|
||||
50
app/Http/Resources/PublicApi/PublicTechnologyResource.php
Normal file
50
app/Http/Resources/PublicApi/PublicTechnologyResource.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\PublicApi;
|
||||
|
||||
use App\Models\Technology;
|
||||
use App\Support\ImagePayload;
|
||||
use App\Support\TranslatablePayload;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Technology
|
||||
*/
|
||||
class PublicTechnologyResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
// Not translated: technology names are proper nouns.
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'category' => $this->category?->value,
|
||||
'color' => $this->color,
|
||||
'icon' => $this->icon,
|
||||
|
||||
// The alt text of a logo is the name of the thing, so there is
|
||||
// nothing to type and nothing to get wrong.
|
||||
'logo' => ImagePayload::make(
|
||||
$this->logo,
|
||||
$this->resource->relationLoaded('logoMedia') ? $this->resource->getRelation('logoMedia') : null,
|
||||
$this->name,
|
||||
),
|
||||
'website' => $this->website,
|
||||
|
||||
// Tech radar. `ring` is null for technologies used but not placed.
|
||||
'ring' => $this->radar_ring?->value,
|
||||
|
||||
...TranslatablePayload::resolve($this->resource, ['description', 'radar_note'], app()->getLocale()),
|
||||
|
||||
'projects_count' => $this->whenCounted('projects'),
|
||||
'members_count' => $this->whenCounted('members'),
|
||||
];
|
||||
}
|
||||
}
|
||||
52
app/Http/Resources/PublicApi/PublicTestimonialResource.php
Normal file
52
app/Http/Resources/PublicApi/PublicTestimonialResource.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\PublicApi;
|
||||
|
||||
use App\Models\Testimonial;
|
||||
use App\Support\ImagePayload;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin Testimonial
|
||||
*/
|
||||
class PublicTestimonialResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$locale = app()->getLocale();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'quote' => $this->getTranslation('quote', $locale),
|
||||
|
||||
/*
|
||||
* Whether these are the words that were actually said. A quote
|
||||
* shown in a language it was not given in has been translated by
|
||||
* us, and the frontend can say so — "translated from Spanish" —
|
||||
* instead of pretending somebody phrased it that way.
|
||||
*/
|
||||
'original_locale' => $this->original_locale,
|
||||
'is_original' => $this->isOriginal($locale),
|
||||
|
||||
'author' => [
|
||||
'name' => $this->author_name,
|
||||
'role' => $this->getTranslation('author_role', $locale, false) ?: null,
|
||||
'avatar' => ImagePayload::make(
|
||||
$this->avatar,
|
||||
$this->resource->relationLoaded('avatarMedia') ? $this->resource->getRelation('avatarMedia') : null,
|
||||
$this->author_name,
|
||||
),
|
||||
],
|
||||
|
||||
'given_at' => $this->given_at?->toDateString(),
|
||||
|
||||
'client' => PublicClientResource::make($this->whenLoaded('client')),
|
||||
];
|
||||
}
|
||||
}
|
||||
33
app/Http/Resources/RoleResource.php
Normal file
33
app/Http/Resources/RoleResource.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
/**
|
||||
* @mixin Role
|
||||
*/
|
||||
class RoleResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'guard_name' => $this->guard_name,
|
||||
'users_count' => $this->whenCounted('users'),
|
||||
'permissions' => $this->whenLoaded(
|
||||
'permissions',
|
||||
fn () => $this->permissions->pluck('name')->values(),
|
||||
),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Http/Resources/UserResource.php
Normal file
46
app/Http/Resources/UserResource.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/**
|
||||
* @mixin User
|
||||
*/
|
||||
class UserResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
// The identity server's id for this person, for clients that also
|
||||
// talk to oauth.pireon.dev directly.
|
||||
'oauth_id' => $this->oauth_id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'avatar_url' => $this->avatar_url,
|
||||
'locale' => $this->locale,
|
||||
'is_active' => $this->is_active,
|
||||
'last_login_at' => $this->last_login_at?->toIso8601String(),
|
||||
'created_at' => $this->created_at?->toIso8601String(),
|
||||
|
||||
// Local authorisation — what this person may do in *this* app.
|
||||
'roles' => $this->whenLoaded('roles', fn () => $this->roles->pluck('name')->values()),
|
||||
'permissions' => $this->when(
|
||||
$request->routeIs('api.v1.me'),
|
||||
fn () => $this->getAllPermissions()->pluck('name')->values(),
|
||||
),
|
||||
|
||||
// Roles reported by the identity server. Informational only: they
|
||||
// grant nothing here.
|
||||
'remote_roles' => $this->remote_roles ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user