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:
17
README.md
17
README.md
@@ -1279,6 +1279,15 @@ GET /api/v1/applications el buzón; filtros: status (sin él, sin spam
|
|||||||
kind, call_id, search, trashed
|
kind, call_id, search, trashed
|
||||||
PATCH /api/v1/applications/{id} solo status y notes
|
PATCH /api/v1/applications/{id} solo status y notes
|
||||||
DELETE /api/v1/applications/{id} + POST …/restore
|
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)
|
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
|
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.
|
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
|
`GET /v1/me` devuelve el array `permissions`: úsalo para construir la UI del
|
||||||
cliente en vez de comprobar nombres de rol.
|
cliente en vez de comprobar nombres de rol.
|
||||||
|
|
||||||
|
|||||||
68
app/Http/Controllers/Api/V1/MaintenanceController.php
Normal file
68
app/Http/Controllers/Api/V1/MaintenanceController.php
Normal 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(),
|
||||||
|
]]);
|
||||||
|
}
|
||||||
|
}
|
||||||
95
app/Http/Controllers/Api/V1/MissingUrlController.php
Normal file
95
app/Http/Controllers/Api/V1/MissingUrlController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
app/Http/Controllers/Api/V1/RedirectController.php
Normal file
77
app/Http/Controllers/Api/V1/RedirectController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Http/Controllers/Api/V1/RepositoryController.php
Normal file
87
app/Http/Controllers/Api/V1/RepositoryController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Http/Controllers/Api/V1/SettingController.php
Normal file
56
app/Http/Controllers/Api/V1/SettingController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Http/Requests/Api/V1/MissingUrlRedirectRequest.php
Normal file
68
app/Http/Requests/Api/V1/MissingUrlRedirectRequest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
67
app/Http/Requests/Api/V1/RedirectRequest.php
Normal file
67
app/Http/Requests/Api/V1/RedirectRequest.php
Normal 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'])];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Http/Requests/Api/V1/RepositoryRequest.php
Normal file
50
app/Http/Requests/Api/V1/RepositoryRequest.php
Normal 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Http/Resources/MissingUrlResource.php
Normal file
35
app/Http/Resources/MissingUrlResource.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Http/Resources/RedirectResource.php
Normal file
35
app/Http/Resources/RedirectResource.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Http/Resources/RepositoryResource.php
Normal file
51
app/Http/Resources/RepositoryResource.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Http/Resources/SettingResource.php
Normal file
27
app/Http/Resources/SettingResource.php
Normal 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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@ return [
|
|||||||
'cannot_publish' => 'No tens permís per publicar pàgines.',
|
'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' => [
|
'publication' => [
|
||||||
'forbidden' => 'No tens permís per publicar ni despublicar això.',
|
'forbidden' => 'No tens permís per publicar ni despublicar això.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ return [
|
|||||||
'cannot_publish' => 'You do not have permission to publish pages.',
|
'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' => [
|
'publication' => [
|
||||||
'forbidden' => 'You do not have permission to publish or unpublish this.',
|
'forbidden' => 'You do not have permission to publish or unpublish this.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ return [
|
|||||||
'cannot_publish' => 'No tienes permiso para publicar páginas.',
|
'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' => [
|
'publication' => [
|
||||||
'forbidden' => 'No tienes permiso para publicar ni despublicar esto.',
|
'forbidden' => 'No tienes permiso para publicar ni despublicar esto.',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -124,6 +124,12 @@ tags:
|
|||||||
description: The inbox — what people sent from the public site
|
description: The inbox — what people sent from the public site
|
||||||
- name: Technologies
|
- name: Technologies
|
||||||
description: What the collective uses, and its place on the tech radar
|
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
|
- name: Users
|
||||||
description: Local user administration
|
description: Local user administration
|
||||||
- name: Media
|
- name: Media
|
||||||
@@ -2201,6 +2207,449 @@ paths:
|
|||||||
data: { $ref: "#/components/schemas/Application" }
|
data: { $ref: "#/components/schemas/Application" }
|
||||||
"403": { $ref: "#/components/responses/Forbidden" }
|
"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:
|
/v1/media:
|
||||||
post:
|
post:
|
||||||
tags: [Media]
|
tags: [Media]
|
||||||
@@ -3659,6 +4108,130 @@ components:
|
|||||||
links: { $ref: "#/components/schemas/PaginationLinks" }
|
links: { $ref: "#/components/schemas/PaginationLinks" }
|
||||||
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
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:
|
MediaFor:
|
||||||
type: string
|
type: string
|
||||||
enum: [pages, technologies, projects, members, clients, testimonials]
|
enum: [pages, technologies, projects, members, clients, testimonials]
|
||||||
|
|||||||
@@ -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\CallController;
|
||||||
use App\Http\Controllers\Api\V1\CategoryController;
|
use App\Http\Controllers\Api\V1\CategoryController;
|
||||||
use App\Http\Controllers\Api\V1\ClientController;
|
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\MeController;
|
||||||
use App\Http\Controllers\Api\V1\MediaController;
|
use App\Http\Controllers\Api\V1\MediaController;
|
||||||
use App\Http\Controllers\Api\V1\MemberController;
|
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\PageController;
|
||||||
use App\Http\Controllers\Api\V1\ProjectController;
|
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\RoleController;
|
||||||
use App\Http\Controllers\Api\V1\ServiceController;
|
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\TalkController;
|
||||||
use App\Http\Controllers\Api\V1\TechnologyController;
|
use App\Http\Controllers\Api\V1\TechnologyController;
|
||||||
use App\Http\Controllers\Api\V1\TestimonialController;
|
use App\Http\Controllers\Api\V1\TestimonialController;
|
||||||
@@ -205,6 +210,22 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
|||||||
->withTrashed()
|
->withTrashed()
|
||||||
->name('applications.restore');
|
->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.
|
// Upload only: the file is then referenced from a record by its path.
|
||||||
Route::post('media', [MediaController::class, 'store'])->name('media.store');
|
Route::post('media', [MediaController::class, 'store'])->name('media.store');
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ use App\Models\Call;
|
|||||||
use App\Models\Category;
|
use App\Models\Category;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
|
use App\Models\MissingUrl;
|
||||||
use App\Models\Page;
|
use App\Models\Page;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Redirect;
|
||||||
|
use App\Models\Repository;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
|
use App\Models\Setting;
|
||||||
use App\Models\Talk;
|
use App\Models\Talk;
|
||||||
use App\Models\Technology;
|
use App\Models\Technology;
|
||||||
use App\Models\Testimonial;
|
use App\Models\Testimonial;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Support\Maintenance;
|
||||||
use Database\Seeders\RolePermissionSeeder;
|
use Database\Seeders\RolePermissionSeeder;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
@@ -82,6 +87,9 @@ class AdminApiContractTest extends TestCase
|
|||||||
'talks' => ['path' => '/v1/talks'],
|
'talks' => ['path' => '/v1/talks'],
|
||||||
'calls' => ['path' => '/v1/calls'],
|
'calls' => ['path' => '/v1/calls'],
|
||||||
'applications' => ['path' => '/v1/applications'],
|
'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();
|
$talk = Talk::factory()->create();
|
||||||
$call = Call::factory()->create();
|
$call = Call::factory()->create();
|
||||||
$application = Application::factory()->for($call)->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
|
// One request, one documented path — the URL a real caller hits and
|
||||||
// the path key that describes it, side by side rather than derived
|
// 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}'],
|
'call' => ["/v1/calls/{$call->id}", '/v1/calls/{call}'],
|
||||||
'applications' => ['/v1/applications', '/v1/applications'],
|
'applications' => ['/v1/applications', '/v1/applications'],
|
||||||
'application' => ["/v1/applications/{$application->id}", '/v1/applications/{application}'],
|
'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'],
|
'users' => ['/v1/users', '/v1/users'],
|
||||||
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
'user' => ["/v1/users/{$this->caller->id}", '/v1/users/{user}'],
|
||||||
'me' => ['/v1/me', '/v1/me'],
|
'me' => ['/v1/me', '/v1/me'],
|
||||||
|
|||||||
221
tests/Feature/Api/SiteAdministrationApiTest.php
Normal file
221
tests/Feature/Api/SiteAdministrationApiTest.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user