diff --git a/README.md b/README.md index f54f9a3..a1ba2b1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/Http/Controllers/Api/V1/MaintenanceController.php b/app/Http/Controllers/Api/V1/MaintenanceController.php new file mode 100644 index 0000000..f716646 --- /dev/null +++ b/app/Http/Controllers/Api/V1/MaintenanceController.php @@ -0,0 +1,68 @@ +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(), + ]]); + } +} diff --git a/app/Http/Controllers/Api/V1/MissingUrlController.php b/app/Http/Controllers/Api/V1/MissingUrlController.php new file mode 100644 index 0000000..08aae57 --- /dev/null +++ b/app/Http/Controllers/Api/V1/MissingUrlController.php @@ -0,0 +1,95 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/RedirectController.php b/app/Http/Controllers/Api/V1/RedirectController.php new file mode 100644 index 0000000..570665c --- /dev/null +++ b/app/Http/Controllers/Api/V1/RedirectController.php @@ -0,0 +1,77 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/RepositoryController.php b/app/Http/Controllers/Api/V1/RepositoryController.php new file mode 100644 index 0000000..1c9abba --- /dev/null +++ b/app/Http/Controllers/Api/V1/RepositoryController.php @@ -0,0 +1,87 @@ +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); + } +} diff --git a/app/Http/Controllers/Api/V1/SettingController.php b/app/Http/Controllers/Api/V1/SettingController.php new file mode 100644 index 0000000..cb010ae --- /dev/null +++ b/app/Http/Controllers/Api/V1/SettingController.php @@ -0,0 +1,56 @@ +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); + } +} diff --git a/app/Http/Requests/Api/V1/MissingUrlRedirectRequest.php b/app/Http/Requests/Api/V1/MissingUrlRedirectRequest.php new file mode 100644 index 0000000..189dbc8 --- /dev/null +++ b/app/Http/Requests/Api/V1/MissingUrlRedirectRequest.php @@ -0,0 +1,68 @@ +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 + */ + 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'); + } +} diff --git a/app/Http/Requests/Api/V1/RedirectRequest.php b/app/Http/Requests/Api/V1/RedirectRequest.php new file mode 100644 index 0000000..9679def --- /dev/null +++ b/app/Http/Requests/Api/V1/RedirectRequest.php @@ -0,0 +1,67 @@ +input($end)) && filled($this->input($end))) { + $this->merge([$end => Redirect::normalise($this->input($end))]); + } + } + } + + /** + * @return array + */ + 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 + */ + public function messages(): array + { + return ['to.not_in' => __('validation.different', ['attribute' => 'to', 'other' => 'from'])]; + } +} diff --git a/app/Http/Requests/Api/V1/RepositoryRequest.php b/app/Http/Requests/Api/V1/RepositoryRequest.php new file mode 100644 index 0000000..5832e0d --- /dev/null +++ b/app/Http/Requests/Api/V1/RepositoryRequest.php @@ -0,0 +1,50 @@ + + */ + 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'], + ]; + } +} diff --git a/app/Http/Resources/MissingUrlResource.php b/app/Http/Resources/MissingUrlResource.php new file mode 100644 index 0000000..b542023 --- /dev/null +++ b/app/Http/Resources/MissingUrlResource.php @@ -0,0 +1,35 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Http/Resources/RedirectResource.php b/app/Http/Resources/RedirectResource.php new file mode 100644 index 0000000..20282b4 --- /dev/null +++ b/app/Http/Resources/RedirectResource.php @@ -0,0 +1,35 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Http/Resources/RepositoryResource.php b/app/Http/Resources/RepositoryResource.php new file mode 100644 index 0000000..fba7f08 --- /dev/null +++ b/app/Http/Resources/RepositoryResource.php @@ -0,0 +1,51 @@ + + */ + 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(), + ]; + } +} diff --git a/app/Http/Resources/SettingResource.php b/app/Http/Resources/SettingResource.php new file mode 100644 index 0000000..d64c124 --- /dev/null +++ b/app/Http/Resources/SettingResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'key' => $this->key, + 'value' => $this->value, + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/lang/ca/rules.php b/lang/ca/rules.php index bb43feb..0dc1f69 100644 --- a/lang/ca/rules.php +++ b/lang/ca/rules.php @@ -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ò.', ], diff --git a/lang/en/rules.php b/lang/en/rules.php index 210421c..d173a54 100644 --- a/lang/en/rules.php +++ b/lang/en/rules.php @@ -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.', ], diff --git a/lang/es/rules.php b/lang/es/rules.php index c81edeb..6f83980 100644 --- a/lang/es/rules.php +++ b/lang/es/rules.php @@ -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.', ], diff --git a/public/openapi.yaml b/public/openapi.yaml index 6cc81d6..cf669b3 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -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] diff --git a/routes/api.php b/routes/api.php index d454e25..ca8bfd0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/tests/Feature/Api/AdminApiContractTest.php b/tests/Feature/Api/AdminApiContractTest.php index c952e68..105d0ac 100644 --- a/tests/Feature/Api/AdminApiContractTest.php +++ b/tests/Feature/Api/AdminApiContractTest.php @@ -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'], diff --git a/tests/Feature/Api/SiteAdministrationApiTest.php b/tests/Feature/Api/SiteAdministrationApiTest.php new file mode 100644 index 0000000..fd77cd8 --- /dev/null +++ b/tests/Feature/Api/SiteAdministrationApiTest.php @@ -0,0 +1,221 @@ +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.'); + } +}