Manage redirects, missing URLs, repositories and settings through the API

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 19:51:00 +02:00
parent 4c65604f04
commit 8d0f2e3639
20 changed files with 1581 additions and 0 deletions

View File

@@ -1279,6 +1279,15 @@ GET /api/v1/applications el buzón; filtros: status (sin él, sin spam
kind, call_id, search, trashed
PATCH /api/v1/applications/{id} solo status y notes
DELETE /api/v1/applications/{id} + POST …/restore
/api/v1/redirects CRUD; from/to normalizados, únicos por idioma
GET /api/v1/missing-urls las 404 que avisó el frontend (pending por defecto)
PATCH /api/v1/missing-urls/{id} pending ⇄ ignored
POST /api/v1/missing-urls/{id}/redirect crear la redirección que la resuelve
/api/v1/repositories CRUD (solo lo que no viene del proveedor)
POST /api/v1/repositories/{id}/sync encola una sincronización (202)
GET /api/v1/settings ajustes; PATCH /settings/{clave} sustituye el valor
GET /api/v1/maintenance modo mantenimiento de la web pública
PUT /api/v1/maintenance {enabled, message, until}
POST /api/v1/media subir una imagen (fichero o URL)
```
@@ -1288,6 +1297,14 @@ salen desactivados): sin `projects.publish` o `services.publish`, la API rechaza
algo no puede sacarlo a la web solo. Las relaciones enviadas sustituyen a las
que había, y las no enviadas no se tocan; todo se guarda en una transacción.
Los ajustes siguen los permisos del panel: `settings.view` para leer,
`settings.update` para cambiar y `settings.maintenance` para `site.maintenance`
y `/v1/maintenance`. `content.version` lo escribe la aplicación y la API lo
rechaza: puesto a mano, el frontend serviría páginas viejas o las pediría de
nuevo para nada. Sincronizar un repositorio pide `repositories.sync`, porque
gasta un límite del proveedor que comparte toda la aplicación, y va a la cola
en vez de hacer esperar a quien llama.
`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del
cliente en vez de comprobar nombres de rol.

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Setting;
use App\Support\Maintenance;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* The public site's maintenance mode — the panel's maintenance screen, as two
* calls. Only the public site goes down; the panel and this API keep working.
*/
class MaintenanceController extends Controller
{
public function show(): JsonResponse
{
$this->authorize('settings.view');
return $this->state();
}
/**
* `enabled` switches it; `message` (language → text) and `until` are kept
* either way, so the wording can be prepared before the site goes down.
*/
public function update(Request $request): JsonResponse
{
$this->authorize('settings.maintenance');
$data = $request->validate([
'enabled' => ['required', 'boolean'],
'message' => ['sometimes', 'nullable', 'array'],
'message.*' => ['nullable', 'string', 'max:500'],
'until' => ['sometimes', 'nullable', 'date', 'after:now'],
]);
$message = array_key_exists('message', $data)
? array_filter((array) $data['message'], fn ($text) => filled($text))
: Maintenance::messages();
if ($data['enabled']) {
Maintenance::enable(
message: $message,
until: filled($data['until'] ?? null) ? CarbonImmutable::parse($data['until']) : null,
);
} else {
// Switching it off must not take the prepared wording with it.
Setting::set(Maintenance::KEY, [...Maintenance::state(), 'enabled' => false, 'until' => null, 'message' => $message]);
}
return $this->state();
}
protected function state(): JsonResponse
{
return response()->json(['data' => [
'enabled' => Maintenance::isEnabled(),
'message' => (object) Maintenance::messages(),
'until' => Maintenance::until()?->toIso8601String(),
'started_at' => Maintenance::startedAt()?->toIso8601String(),
]]);
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\MissingUrlStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\MissingUrlRedirectRequest;
use App\Http\Resources\MissingUrlResource;
use App\Http\Resources\RedirectResource;
use App\Models\MissingUrl;
use App\Models\Redirect;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Validation\Rule;
/**
* Addresses the public site was asked for and could not find, reported by the
* frontend. What can be done with one is what the panel does: ignore it, or
* give it a destination.
*/
class MissingUrlController extends Controller
{
/**
* Filters: status (pending by default), locale, search (path).
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', MissingUrl::class);
$missing = MissingUrl::query()
->where('status', $request->string('status', MissingUrlStatus::Pending->value))
->when($request->filled('locale'), fn ($q) => $q->where('locale', $request->string('locale')))
->when($request->filled('search'), fn ($q) => $q->where('path', 'like', '%'.$request->string('search').'%'))
->orderByDesc('hits')
->orderByDesc('last_seen_at')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return MissingUrlResource::collection($missing);
}
public function show(MissingUrl $missingUrl): MissingUrlResource
{
$this->authorize('view', $missingUrl);
return MissingUrlResource::make($missingUrl);
}
/**
* Only between pending and ignored: `resolved` is what writing a redirect
* makes it, and claiming it without one would be a lie the next sighting
* undoes anyway.
*/
public function update(Request $request, MissingUrl $missingUrl): MissingUrlResource
{
$this->authorize('update', $missingUrl);
$data = $request->validate([
'status' => ['required', Rule::in([MissingUrlStatus::Pending->value, MissingUrlStatus::Ignored->value])],
]);
$missingUrl->update($data);
return MissingUrlResource::make($missingUrl);
}
public function destroy(MissingUrl $missingUrl): JsonResponse
{
$this->authorize('delete', $missingUrl);
$missingUrl->delete();
return response()->json(status: 204);
}
/**
* Give the address a destination. The report is marked resolved by the
* redirect being created, as from anywhere else.
*/
public function redirect(MissingUrlRedirectRequest $request, MissingUrl $missingUrl): JsonResponse
{
$this->authorize('redirect', $missingUrl);
$redirect = Redirect::create([
'from' => $missingUrl->path,
'is_enabled' => true,
...$request->validated(),
])->refresh();
return RedirectResource::make($redirect)->response()->setStatusCode(201);
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\RedirectRequest;
use App\Http\Resources\RedirectResource;
use App\Models\Redirect;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* Old addresses and where they go now.
*/
class RedirectController extends Controller
{
/**
* Filters: is_enabled, locale, automatic (left behind by a slug rename),
* search (either end).
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Redirect::class);
$redirects = Redirect::query()
->when($request->has('is_enabled'), fn ($q) => $q->where('is_enabled', $request->boolean('is_enabled')))
->when($request->filled('locale'), fn ($q) => $q->where('locale', $request->string('locale')))
->when($request->has('automatic'), fn ($q) => $request->boolean('automatic')
? $q->whereNotNull('source_type')
: $q->whereNull('source_type'))
->when($request->filled('search'), function ($q) use ($request): void {
$term = '%'.$request->string('search').'%';
$q->where(fn ($q) => $q->where('from', 'like', $term)->orWhere('to', 'like', $term));
})
->latest()
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return RedirectResource::collection($redirects);
}
public function store(RedirectRequest $request): JsonResponse
{
$this->authorize('create', Redirect::class);
return RedirectResource::make(Redirect::create($request->validated())->refresh())->response()->setStatusCode(201);
}
public function show(Redirect $redirect): RedirectResource
{
$this->authorize('view', $redirect);
return RedirectResource::make($redirect);
}
public function update(RedirectRequest $request, Redirect $redirect): RedirectResource
{
$this->authorize('update', $redirect);
$redirect->update($request->validated());
return RedirectResource::make($redirect);
}
public function destroy(Redirect $redirect): JsonResponse
{
$this->authorize('delete', $redirect);
$redirect->delete();
return response()->json(status: 204);
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\RepositoryRequest;
use App\Http\Resources\RepositoryResource;
use App\Jobs\SyncRepository;
use App\Models\Repository;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* The collective's git repositories. Only which repository, whose project and
* whether to show it are written here; everything else is what the provider
* last reported.
*/
class RepositoryController extends Controller
{
/**
* Filters: provider, project_id, show_publicly, failing, search (full name).
*/
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('viewAny', Repository::class);
$repositories = Repository::query()
->when($request->filled('provider'), fn ($q) => $q->where('provider', $request->string('provider')))
->when($request->filled('project_id'), fn ($q) => $q->where('project_id', $request->integer('project_id')))
->when($request->has('show_publicly'), fn ($q) => $q->where('show_publicly', $request->boolean('show_publicly')))
->when($request->boolean('failing'), fn ($q) => $q->failing())
->when($request->filled('search'), fn ($q) => $q->where('full_name', 'like', '%'.$request->string('search').'%'))
->orderBy('full_name')
->paginate(perPage: min($request->integer('per_page', 25), 100))
->withQueryString();
return RepositoryResource::collection($repositories);
}
public function store(RepositoryRequest $request): JsonResponse
{
$this->authorize('create', Repository::class);
return RepositoryResource::make(Repository::create($request->validated())->refresh())->response()->setStatusCode(201);
}
public function show(Repository $repository): RepositoryResource
{
$this->authorize('view', $repository);
return RepositoryResource::make($repository);
}
public function update(RepositoryRequest $request, Repository $repository): RepositoryResource
{
$this->authorize('update', $repository);
$repository->update($request->validated());
return RepositoryResource::make($repository);
}
public function destroy(Repository $repository): JsonResponse
{
$this->authorize('delete', $repository);
$repository->delete();
return response()->json(status: 204);
}
/**
* Ask the provider again, on the queue: a sync spends a rate limit the
* whole application shares, and a caller should not wait on it.
*/
public function sync(Repository $repository): JsonResponse
{
$this->authorize('sync', $repository);
SyncRepository::dispatch($repository);
return RepositoryResource::make($repository)->response()->setStatusCode(202);
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Resources\SettingResource;
use App\Models\Setting;
use App\Support\ContentVersion;
use App\Support\Maintenance;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* Runtime settings, as the panel's settings screen shows them: the ones that
* exist, each value as the JSON it is. None is created or deleted from here.
*/
class SettingController extends Controller
{
/**
* Written by the application, never by hand: a hand-set content version
* would leave the frontend's cache serving stale pages or refetching them
* for nothing.
*/
protected const READ_ONLY = [ContentVersion::KEY];
public function index(Request $request): AnonymousResourceCollection
{
$this->authorize('settings.view');
return SettingResource::collection(Setting::query()->orderBy('key')->get());
}
public function show(Setting $setting): SettingResource
{
$this->authorize('settings.view');
return SettingResource::make($setting);
}
public function update(Request $request, Setting $setting): SettingResource
{
// Taking the site down is its own permission, in the panel as here.
$this->authorize($setting->key === Maintenance::KEY ? 'settings.maintenance' : 'settings.update');
abort_if(in_array($setting->key, self::READ_ONLY, true), 403, __('rules.settings.read_only'));
// `present` rather than `required`: null is a value a setting may hold.
$request->validate(['value' => ['present']]);
$setting->update(['value' => $request->input('value')]);
return SettingResource::make($setting);
}
}

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Models\MissingUrl;
use App\Models\Redirect;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Where a missing address should go. The origin is the address itself.
*/
class MissingUrlRedirectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
if (is_string($this->input('to')) && filled($this->input('to'))) {
$this->merge(['to' => Redirect::normalise($this->input('to'))]);
}
// The language it was seen in, unless told otherwise; null for all.
if (! $this->exists('locale')) {
$this->merge(['locale' => $this->missingUrl()->locale]);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$path = $this->missingUrl()->path;
$locale = $this->input('locale');
return [
'to' => ['required', 'string', 'max:500', Rule::notIn([$path])],
'status' => ['sometimes', 'integer', Rule::in([301, 302])],
'locale' => [
'nullable',
'string',
Rule::in(array_keys((array) config('locales.supported'))),
// Already answered by a rule: edit that one instead.
function (string $attribute, mixed $value, \Closure $fail) use ($path, $locale): void {
$exists = Redirect::query()
->where('from', $path)
->when($locale === null, fn ($q) => $q->whereNull('locale'), fn ($q) => $q->where('locale', $locale))
->exists();
if ($exists) {
$fail(__('validation.unique', ['attribute' => 'from']));
}
},
],
];
}
protected function missingUrl(): MissingUrl
{
return $this->route('missing_url');
}
}

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Models\Redirect;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* The panel's redirect form, as rules. Both ends are normalised before they
* are checked, so `/a/` and `/a` are the same rule however it was typed.
*/
class RedirectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
foreach (['from', 'to'] as $end) {
if (is_string($this->input($end)) && filled($this->input($end))) {
$this->merge([$end => Redirect::normalise($this->input($end))]);
}
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$redirect = $this->route('redirect');
$creating = ! $redirect instanceof Redirect;
$sometimes = $creating ? 'required' : 'sometimes';
$from = $this->input('from', $redirect?->from);
$locale = $this->exists('locale') ? $this->input('locale') : $redirect?->locale;
return [
'from' => [
$sometimes,
'string',
'max:500',
// One rule per address and language, as the table's index says.
Rule::unique('redirects', 'from')
->ignore($redirect?->id)
->where(fn ($q) => $locale === null ? $q->whereNull('locale') : $q->where('locale', $locale)),
],
'to' => [$sometimes, 'string', 'max:500', Rule::notIn([$from])],
'status' => ['sometimes', 'integer', Rule::in([301, 302])],
'locale' => ['sometimes', 'nullable', 'string', Rule::in(array_keys((array) config('locales.supported')))],
'is_enabled' => ['sometimes', 'boolean'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return ['to.not_in' => __('validation.different', ['attribute' => 'to', 'other' => 'from'])];
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Enums\GitProvider;
use App\Models\Repository;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* The panel's repository form, as rules. The synced columns are not here on
* purpose: nothing a caller sends may fake a repository's numbers.
*/
class RepositoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$repository = $this->route('repository');
$creating = ! $repository instanceof Repository;
$sometimes = $creating ? 'required' : 'sometimes';
$provider = $this->input('provider', $repository?->provider?->value);
return [
'provider' => [$sometimes, Rule::enum(GitProvider::class)],
'full_name' => [
$sometimes,
'string',
'max:255',
'regex:/^[\w.-]+\/[\w.-]+$|^[\w.-]+$/',
Rule::unique('repositories', 'full_name')
->ignore($repository?->id)
->where('provider', $provider),
],
'project_id' => ['sometimes', 'nullable', 'integer', Rule::exists('projects', 'id')->whereNull('deleted_at')],
'is_primary' => ['sometimes', 'boolean'],
'show_publicly' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\MissingUrl;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin MissingUrl
*/
class MissingUrlResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'path' => $this->path,
'locale' => $this->locale,
'referrer' => $this->referrer,
// Our own page links here: more urgent than someone else's stale link.
'internal_referrer' => $this->resource->isInternalReferrer(),
'status' => $this->status->value,
'hits' => (int) $this->hits,
'first_seen_at' => $this->first_seen_at?->toIso8601String(),
'last_seen_at' => $this->last_seen_at?->toIso8601String(),
'resolved_at' => $this->resolved_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Redirect;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Redirect
*/
class RedirectResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'from' => $this->from,
'to' => $this->to,
'status' => $this->status,
'locale' => $this->locale,
'is_enabled' => (bool) $this->is_enabled,
'automatic' => $this->resource->wasAutomatic(),
'hits' => (int) $this->hits,
'last_used_at' => $this->last_used_at?->toIso8601String(),
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Repository;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Repository
*/
class RepositoryResource 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,
'project_id' => $this->project_id,
'is_primary' => (bool) $this->is_primary,
'show_publicly' => (bool) $this->show_publicly,
// What the provider last reported.
'html_url' => $this->html_url,
'description' => $this->description,
'homepage' => $this->homepage,
'default_branch' => $this->default_branch,
'primary_language' => $this->primary_language,
'license' => $this->license,
'topics' => $this->topics ?? [],
'stars' => (int) $this->stars,
'forks' => (int) $this->forks,
'open_issues_count' => (int) $this->open_issues_count,
'is_archived' => (bool) $this->is_archived,
'pushed_at' => $this->pushed_at?->toIso8601String(),
'last_synced_at' => $this->last_synced_at?->toIso8601String(),
'sync_error' => $this->sync_error,
'sync_failures' => (int) $this->sync_failures,
'created_at' => $this->created_at?->toIso8601String(),
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Setting;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Setting
*/
class SettingResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'key' => $this->key,
'value' => $this->value,
'updated_at' => $this->updated_at?->toIso8601String(),
];
}
}

View File

@@ -29,6 +29,10 @@ return [
'cannot_publish' => 'No tens permís per publicar pàgines.',
],
'settings' => [
'read_only' => 'Aquest ajust l\'escriu l\'aplicació i no es pot editar a mà.',
],
'publication' => [
'forbidden' => 'No tens permís per publicar ni despublicar això.',
],

View File

@@ -29,6 +29,10 @@ return [
'cannot_publish' => 'You do not have permission to publish pages.',
],
'settings' => [
'read_only' => 'This setting is written by the application and cannot be edited by hand.',
],
'publication' => [
'forbidden' => 'You do not have permission to publish or unpublish this.',
],

View File

@@ -29,6 +29,10 @@ return [
'cannot_publish' => 'No tienes permiso para publicar páginas.',
],
'settings' => [
'read_only' => 'Este ajuste lo escribe la aplicación y no se puede editar a mano.',
],
'publication' => [
'forbidden' => 'No tienes permiso para publicar ni despublicar esto.',
],

View File

@@ -124,6 +124,12 @@ tags:
description: The inbox — what people sent from the public site
- name: Technologies
description: What the collective uses, and its place on the tech radar
- name: Redirects
description: Old addresses, and the missing ones the frontend reported
- name: Repositories
description: The collective's git repositories and their synced numbers
- name: Settings
description: Runtime settings and the public site's maintenance mode
- name: Users
description: Local user administration
- name: Media
@@ -2201,6 +2207,449 @@ paths:
data: { $ref: "#/components/schemas/Application" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/redirects:
get:
tags: [Redirects]
summary: List redirects
parameters:
- $ref: "#/components/parameters/PerPage"
- name: is_enabled
in: query
schema: { type: boolean }
- name: locale
in: query
schema: { type: string }
- name: automatic
in: query
description: "True for the ones a slug rename left behind, false for those typed by hand."
schema: { type: boolean }
- name: search
in: query
description: "Matches either end."
schema: { type: string }
responses:
"200":
description: Paginated redirects
content:
application/json:
schema: { $ref: "#/components/schemas/RedirectCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Redirects]
summary: Create a redirect
description: |
Both ends are normalised — leading slash, no trailing one, no query
string. A `locale` of null applies in every language. Creating one marks
the matching missing URLs as resolved.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/RedirectWrite" }
responses:
"201":
description: Redirect created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Redirect" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/redirects/{redirect}:
parameters:
- name: redirect
in: path
required: true
schema: { type: integer }
get:
tags: [Redirects]
summary: Show a redirect
responses:
"200":
description: The redirect
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Redirect" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Redirects]
summary: Update a redirect
description: Partial update.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/RedirectWrite" }
responses:
"200":
description: Redirect updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Redirect" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Redirects]
summary: Delete a redirect
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/missing-urls:
get:
tags: [Redirects]
summary: List missing urls
parameters:
- $ref: "#/components/parameters/PerPage"
- name: status
in: query
description: "`pending` (the default), `resolved` or `ignored`."
schema: { type: string }
- name: locale
in: query
schema: { type: string }
- name: search
in: query
description: "Matches the path."
schema: { type: string }
responses:
"200":
description: Paginated missing URLs
content:
application/json:
schema: { $ref: "#/components/schemas/MissingUrlCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/missing-urls/{missing_url}:
parameters:
- name: missing_url
in: path
required: true
schema: { type: integer }
get:
tags: [Redirects]
summary: Show a missing URL
responses:
"200":
description: The missing URL
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/MissingUrl" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Redirects]
summary: Ignore a missing URL, or stop ignoring it
description: "`status` moves between `pending` and `ignored` only: `resolved` is what giving it a redirect does."
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/MissingUrlWrite" }
responses:
"200":
description: MissingUrl updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/MissingUrl" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Redirects]
summary: Delete a missing URL
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/missing-urls/{missing_url}/redirect:
post:
tags: [Redirects]
summary: Give a missing URL a destination
description: |
Creates the redirect from its path, which marks it resolved. `locale`
defaults to the language it was seen in; send null for every language.
parameters:
- name: missing_url
in: path
required: true
schema: { type: integer }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [to]
properties:
to: { type: string, maxLength: 500 }
status: { type: integer, enum: [301, 302], default: 301 }
locale: { type: string, nullable: true }
responses:
"201":
description: Redirect created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Redirect" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/repositories:
get:
tags: [Repositories]
summary: List repositories
parameters:
- $ref: "#/components/parameters/PerPage"
- name: provider
in: query
description: "`gitea`, `github` or `gitlab`."
schema: { type: string }
- name: project_id
in: query
schema: { type: integer }
- name: show_publicly
in: query
schema: { type: boolean }
- name: failing
in: query
description: "Only those whose sync has failed three times or more."
schema: { type: boolean }
- name: search
in: query
description: "Matches the full name."
schema: { type: string }
responses:
"200":
description: Paginated repositories
content:
application/json:
schema: { $ref: "#/components/schemas/RepositoryCollection" }
"403": { $ref: "#/components/responses/Forbidden" }
post:
tags: [Repositories]
summary: Create a repository
description: |
Only which repository, whose project and whether to show it. Everything
else is what the provider reports on the next sync.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/RepositoryWrite" }
responses:
"201":
description: Repository created
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Repository" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/repositories/{repository}:
parameters:
- name: repository
in: path
required: true
schema: { type: integer }
get:
tags: [Repositories]
summary: Show a repository
responses:
"200":
description: The repository
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Repository" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Repositories]
summary: Update a repository
description: Partial update.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/RepositoryWrite" }
responses:
"200":
description: Repository updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Repository" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
delete:
tags: [Repositories]
summary: Delete a repository
responses:
"204": { description: Deleted }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/repositories/{repository}/sync:
post:
tags: [Repositories]
summary: Sync a repository
description: |
Queues a sync with the provider and answers straight away. Requires
`repositories.sync`: a sync spends a rate limit the whole application
shares.
parameters:
- name: repository
in: path
required: true
schema: { type: integer }
responses:
"202":
description: Sync queued
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Repository" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/settings:
get:
tags: [Settings]
summary: List settings
description: Every stored setting, unpaginated. Requires `settings.view`.
responses:
"200":
description: The settings
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Setting" }
"403": { $ref: "#/components/responses/Forbidden" }
/v1/settings/{setting}:
parameters:
- name: setting
in: path
required: true
description: "The setting's key, e.g. `site.maintenance`."
schema: { type: string }
get:
tags: [Settings]
summary: Show a setting
responses:
"200":
description: The setting
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Setting" }
"403": { $ref: "#/components/responses/Forbidden" }
"404": { description: Not found }
patch:
tags: [Settings]
summary: Update a setting
description: |
Replaces the value whole. Requires `settings.update`, or
`settings.maintenance` for `site.maintenance`. `content.version` is
written by the application and refused here.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [value]
properties:
value: { description: "Any JSON value, null included." }
responses:
"200":
description: Setting updated
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Setting" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/maintenance:
get:
tags: [Settings]
summary: Show maintenance mode
description: The public site's maintenance mode. Requires `settings.view`.
responses:
"200":
description: Maintenance state
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Maintenance" }
"403": { $ref: "#/components/responses/Forbidden" }
put:
tags: [Settings]
summary: Switch maintenance mode
description: |
Only the public site goes down; the panel and this API keep working.
`message` (language → text) and `until` are kept either way, so the
wording can be written before switching it on. A passed `until` lifts
it by itself. Requires `settings.maintenance`.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [enabled]
properties:
enabled: { type: boolean }
message: { type: object, nullable: true, additionalProperties: { type: string, maxLength: 500 } }
until: { type: string, format: date-time, nullable: true }
responses:
"200":
description: Maintenance state
content:
application/json:
schema:
type: object
properties:
data: { $ref: "#/components/schemas/Maintenance" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/media:
post:
tags: [Media]
@@ -3659,6 +4108,130 @@ components:
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
Redirect:
type: object
properties:
id: { type: integer }
from: { type: string }
to: { type: string }
status: { type: integer, enum: [301, 302] }
locale: { type: string, nullable: true, description: Null applies in every language. }
is_enabled: { type: boolean }
automatic: { type: boolean, description: Left behind by a slug rename. }
hits: { type: integer }
last_used_at: { type: string, format: date-time, nullable: true }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
RedirectWrite:
type: object
required: [from, to]
properties:
from: { type: string, maxLength: 500 }
to: { type: string, maxLength: 500, description: Different from `from`. }
status: { type: integer, enum: [301, 302], default: 301 }
locale: { type: string, nullable: true }
is_enabled: { type: boolean, default: true }
RedirectCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Redirect" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
MissingUrl:
type: object
properties:
id: { type: integer }
path: { type: string }
locale: { type: string }
referrer: { type: string, nullable: true }
internal_referrer: { type: boolean, description: "A page of our own links here — more urgent than someone else's stale link." }
status: { type: string, enum: [pending, resolved, ignored] }
hits: { type: integer }
first_seen_at: { type: string, format: date-time, nullable: true }
last_seen_at: { type: string, format: date-time, nullable: true }
resolved_at: { type: string, format: date-time, nullable: true }
MissingUrlWrite:
type: object
required: [status]
properties:
status: { type: string, enum: [pending, ignored] }
MissingUrlCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/MissingUrl" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
Repository:
type: object
properties:
id: { type: integer }
provider: { type: string, enum: [gitea, github, gitlab] }
full_name: { type: string, description: "`owner/name`." }
project_id: { type: integer, nullable: true }
is_primary: { type: boolean }
show_publicly: { type: boolean }
html_url: { type: string, nullable: true }
description: { type: string, nullable: true }
homepage: { type: string, nullable: true }
default_branch: { type: string, nullable: true }
primary_language: { type: string, nullable: true }
license: { type: string, nullable: true }
topics: { type: array, items: { type: string } }
stars: { type: integer }
forks: { type: integer }
open_issues_count: { type: integer }
is_archived: { type: boolean }
pushed_at: { type: string, format: date-time, nullable: true }
last_synced_at: { type: string, format: date-time, nullable: true }
sync_error: { type: string, nullable: true }
sync_failures: { type: integer }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
RepositoryWrite:
type: object
required: [provider, full_name]
properties:
provider: { type: string, enum: [gitea, github, gitlab] }
full_name: { type: string, maxLength: 255, description: "`owner/name`, unique per provider." }
project_id: { type: integer, nullable: true }
is_primary: { type: boolean }
show_publicly: { type: boolean }
RepositoryCollection:
type: object
properties:
data:
type: array
items: { $ref: "#/components/schemas/Repository" }
links: { $ref: "#/components/schemas/PaginationLinks" }
meta: { $ref: "#/components/schemas/PaginationMeta" }
Setting:
type: object
properties:
key: { type: string }
value: { nullable: true, description: Any JSON value. }
updated_at: { type: string, format: date-time, nullable: true }
Maintenance:
type: object
properties:
enabled: { type: boolean, description: False once `until` has passed. }
message: { type: object, additionalProperties: { type: string }, description: Language → text shown to visitors. }
until: { type: string, format: date-time, nullable: true }
started_at: { type: string, format: date-time, nullable: true }
MediaFor:
type: string
enum: [pages, technologies, projects, members, clients, testimonials]

View File

@@ -14,13 +14,18 @@ use App\Http\Controllers\Api\V1\ApplicationController as AdminApplicationControl
use App\Http\Controllers\Api\V1\CallController;
use App\Http\Controllers\Api\V1\CategoryController;
use App\Http\Controllers\Api\V1\ClientController;
use App\Http\Controllers\Api\V1\MaintenanceController;
use App\Http\Controllers\Api\V1\MeController;
use App\Http\Controllers\Api\V1\MediaController;
use App\Http\Controllers\Api\V1\MemberController;
use App\Http\Controllers\Api\V1\MissingUrlController;
use App\Http\Controllers\Api\V1\PageController;
use App\Http\Controllers\Api\V1\ProjectController;
use App\Http\Controllers\Api\V1\RedirectController;
use App\Http\Controllers\Api\V1\RepositoryController;
use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\ServiceController;
use App\Http\Controllers\Api\V1\SettingController;
use App\Http\Controllers\Api\V1\TalkController;
use App\Http\Controllers\Api\V1\TechnologyController;
use App\Http\Controllers\Api\V1\TestimonialController;
@@ -205,6 +210,22 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->withTrashed()
->name('applications.restore');
// Old addresses, and the missing ones the frontend reported.
Route::apiResource('redirects', RedirectController::class);
Route::apiResource('missing-urls', MissingUrlController::class)->only(['index', 'show', 'update', 'destroy']);
Route::post('missing-urls/{missing_url}/redirect', [MissingUrlController::class, 'redirect'])
->name('missing-urls.redirect');
Route::apiResource('repositories', RepositoryController::class);
Route::post('repositories/{repository}/sync', [RepositoryController::class, 'sync'])
->name('repositories.sync');
// Declared before the settings routes so "maintenance" is never read
// as a setting's key.
Route::get('maintenance', [MaintenanceController::class, 'show'])->name('maintenance.show');
Route::put('maintenance', [MaintenanceController::class, 'update'])->name('maintenance.update');
Route::apiResource('settings', SettingController::class)->only(['index', 'show', 'update']);
// Upload only: the file is then referenced from a record by its path.
Route::post('media', [MediaController::class, 'store'])->name('media.store');

View File

@@ -9,13 +9,18 @@ use App\Models\Call;
use App\Models\Category;
use App\Models\Client;
use App\Models\Member;
use App\Models\MissingUrl;
use App\Models\Page;
use App\Models\Project;
use App\Models\Redirect;
use App\Models\Repository;
use App\Models\Service;
use App\Models\Setting;
use App\Models\Talk;
use App\Models\Technology;
use App\Models\Testimonial;
use App\Models\User;
use App\Support\Maintenance;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
@@ -82,6 +87,9 @@ class AdminApiContractTest extends TestCase
'talks' => ['path' => '/v1/talks'],
'calls' => ['path' => '/v1/calls'],
'applications' => ['path' => '/v1/applications'],
'redirects' => ['path' => '/v1/redirects'],
'missing-urls' => ['path' => '/v1/missing-urls'],
'repositories' => ['path' => '/v1/repositories'],
];
}
@@ -99,6 +107,10 @@ class AdminApiContractTest extends TestCase
$talk = Talk::factory()->create();
$call = Call::factory()->create();
$application = Application::factory()->for($call)->create();
$redirect = Redirect::factory()->create();
$missingUrl = MissingUrl::factory()->create();
$repository = Repository::factory()->create();
Setting::set(Maintenance::KEY, ['enabled' => false]);
// One request, one documented path — the URL a real caller hits and
// the path key that describes it, side by side rather than derived
@@ -127,6 +139,15 @@ class AdminApiContractTest extends TestCase
'call' => ["/v1/calls/{$call->id}", '/v1/calls/{call}'],
'applications' => ['/v1/applications', '/v1/applications'],
'application' => ["/v1/applications/{$application->id}", '/v1/applications/{application}'],
'redirects' => ['/v1/redirects', '/v1/redirects'],
'redirect' => ["/v1/redirects/{$redirect->id}", '/v1/redirects/{redirect}'],
'missing-urls' => ['/v1/missing-urls', '/v1/missing-urls'],
'missing-url' => ["/v1/missing-urls/{$missingUrl->id}", '/v1/missing-urls/{missing_url}'],
'repositories' => ['/v1/repositories', '/v1/repositories'],
'repository' => ["/v1/repositories/{$repository->id}", '/v1/repositories/{repository}'],
'settings' => ['/v1/settings', '/v1/settings'],
'setting' => ['/v1/settings/'.Maintenance::KEY, '/v1/settings/{setting}'],
'maintenance' => ['/v1/maintenance', '/v1/maintenance'],
'users' => ['/v1/users', '/v1/users'],
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
'me' => ['/v1/me', '/v1/me'],

View File

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\MissingUrlStatus;
use App\Jobs\SyncRepository;
use App\Models\MissingUrl;
use App\Models\Repository;
use App\Models\Setting;
use App\Models\User;
use App\Support\ContentVersion;
use App\Support\Maintenance;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Redirects, missing URLs, repositories, settings and maintenance through the
* admin API — the panel's administration screens.
*/
class SiteAdministrationApiTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
protected function actingWithRole(string $role): User
{
$user = User::factory()->create();
$user->syncRoles([$role]);
$this->withHeaders($this->apiHeadersFor($user));
return $user;
}
#[Test]
public function a_redirect_is_normalised_and_closes_the_report_it_answers(): void
{
$this->actingWithRole('editor');
MissingUrl::report('/proyectos/viejo', 'es');
$this->postJson('/api/v1/redirects', [
'from' => 'proyectos/viejo/',
'to' => '/proyectos/nuevo?x=1',
])
->assertCreated()
->assertJsonPath('data.from', '/proyectos/viejo')
->assertJsonPath('data.to', '/proyectos/nuevo')
->assertJsonPath('data.status', 301)
->assertJsonPath('data.automatic', false);
$this->assertSame(MissingUrlStatus::Resolved, MissingUrl::query()->sole()->status);
// The same address in the same language is one rule, not two.
$this->postJson('/api/v1/redirects', ['from' => '/proyectos/viejo', 'to' => '/otra'])
->assertUnprocessable()
->assertJsonValidationErrors('from');
$this->postJson('/api/v1/redirects', ['from' => '/a', 'to' => '/a/'])
->assertUnprocessable()
->assertJsonValidationErrors('to');
}
#[Test]
public function a_missing_url_is_given_a_destination(): void
{
$this->actingWithRole('editor');
MissingUrl::report('/en/old', 'en');
$missing = MissingUrl::query()->sole();
$this->getJson('/api/v1/missing-urls')->assertJsonCount(1, 'data');
$this->postJson("/api/v1/missing-urls/{$missing->id}/redirect", ['to' => '/en/new'])
->assertCreated()
->assertJsonPath('data.from', '/en/old')
->assertJsonPath('data.locale', 'en');
$this->assertSame(MissingUrlStatus::Resolved, $missing->fresh()->status);
$this->getJson('/api/v1/missing-urls')->assertJsonCount(0, 'data');
$this->postJson("/api/v1/missing-urls/{$missing->id}/redirect", ['to' => '/en/other'])
->assertUnprocessable()
->assertJsonValidationErrors('locale');
}
#[Test]
public function a_missing_url_can_be_ignored_but_not_declared_resolved(): void
{
$this->actingWithRole('editor');
$missing = MissingUrl::factory()->create();
$this->patchJson("/api/v1/missing-urls/{$missing->id}", ['status' => 'ignored'])
->assertOk()
->assertJsonPath('data.status', 'ignored');
$this->patchJson("/api/v1/missing-urls/{$missing->id}", ['status' => 'resolved'])
->assertUnprocessable();
}
#[Test]
public function a_repository_is_added_and_synced_on_the_queue(): void
{
Queue::fake();
$this->actingWithRole('admin');
$id = $this->postJson('/api/v1/repositories', [
'provider' => 'github',
'full_name' => 'pireon/landing',
'show_publicly' => true,
// Synced columns are not the caller's to set.
'stars' => 9999,
])
->assertCreated()
->assertJsonPath('data.full_name', 'pireon/landing')
->assertJsonPath('data.stars', 0)
->json('data.id');
$this->postJson('/api/v1/repositories', ['provider' => 'github', 'full_name' => 'pireon/landing'])
->assertUnprocessable()
->assertJsonValidationErrors('full_name');
$this->postJson("/api/v1/repositories/{$id}/sync")->assertAccepted();
Queue::assertPushed(SyncRepository::class);
}
#[Test]
public function an_editor_reads_repositories_but_cannot_sync_them(): void
{
Queue::fake();
$this->actingWithRole('editor');
$repository = Repository::factory()->create();
$this->getJson('/api/v1/repositories')->assertOk();
$this->postJson("/api/v1/repositories/{$repository->id}/sync")->assertForbidden();
Queue::assertNothingPushed();
}
#[Test]
public function a_setting_is_replaced_whole(): void
{
$this->actingWithRole('admin');
Setting::set('site.contact', ['email' => 'hola@example.org']);
$this->getJson('/api/v1/settings')->assertOk()->assertJsonPath('data.0.key', 'site.contact');
$this->patchJson('/api/v1/settings/site.contact', ['value' => ['email' => 'nuevo@example.org']])
->assertOk()
->assertJsonPath('data.value.email', 'nuevo@example.org');
$this->assertSame(['email' => 'nuevo@example.org'], Setting::get('site.contact'));
}
#[Test]
public function the_content_version_is_not_edited_by_hand(): void
{
$this->actingWithRole('admin');
ContentVersion::bump();
$this->patchJson('/api/v1/settings/'.ContentVersion::KEY, ['value' => 1])->assertForbidden();
}
#[Test]
public function an_editor_sees_settings_but_changes_none(): void
{
$this->actingWithRole('editor');
Setting::set(Maintenance::KEY, ['enabled' => false]);
$this->getJson('/api/v1/settings')->assertOk();
$this->getJson('/api/v1/maintenance')->assertOk()->assertJsonPath('data.enabled', false);
$this->patchJson('/api/v1/settings/'.Maintenance::KEY, ['value' => ['enabled' => true]])->assertForbidden();
$this->putJson('/api/v1/maintenance', ['enabled' => true])->assertForbidden();
$this->assertFalse(Maintenance::isEnabled());
}
#[Test]
public function maintenance_is_switched_on_and_off_keeping_its_wording(): void
{
$this->actingWithRole('admin');
$this->putJson('/api/v1/maintenance', [
'enabled' => true,
'message' => ['es' => 'Volvemos enseguida.', 'en' => ''],
'until' => now()->addHour()->toIso8601String(),
])
->assertOk()
->assertJsonPath('data.enabled', true)
->assertJsonPath('data.message', ['es' => 'Volvemos enseguida.']);
$this->assertTrue(Maintenance::isEnabled());
$this->putJson('/api/v1/maintenance', ['enabled' => false])
->assertOk()
->assertJsonPath('data.enabled', false)
->assertJsonPath('data.until', null)
->assertJsonPath('data.message.es', 'Volvemos enseguida.');
}
}