Compare commits

...

11 Commits

Author SHA1 Message Date
c0825d94d4 Pass the new Pireon variables through Compose
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
The production stack hands the containers only the variables it lists,
and PIREON_REDIRECT_CLIENT_ID, its secret, PIREON_REDIRECT_LOGIN and the
two webhook secrets were not among them: set in Dokploy, they never
reached the application, so «Entrar con Pireon» never showed up.

Compose also passes an unset variable as an empty one, so an empty
PIREON_REDIRECT_URI now falls back to APP_URL/auth/callback (the redirect
flow had none otherwise), and an empty PIREON_REDIRECT_LOGIN no longer
hides the button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:27:18 +02:00
d10ce1ca57 Take Pireon's webhooks: a deactivation or a revocation counts at once
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Until now, an account switched off or a token revoked at the identity
server kept working here: bearer tokens until the introspection cache
ran out, panel sessions until they ended, and machine tokens for good,
as they only look at the local account.

POST /api/v1/webhooks/pireon takes user.deactivated (the account goes
inactive, which stops its machine tokens and the panel; its sessions
end and its cached tokens are forgotten) and token.revoked (sessions
and cached tokens go). Pireon signs each delivery with the secret of
the client that registered the webhook, so either PIREON_WEBHOOK_SECRET
(password client) or PIREON_REDIRECT_WEBHOOK_SECRET («Entrar con
Pireon») verifies it; with neither, the endpoint refuses everything.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 17:01:57 +02:00
ec4f687ca8 Offer «Entrar con Pireon» on the panel's login page
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Under the e-mail and password form, a button takes the redirect flow
(Authorization Code + PKCE), so the password is only ever typed at the
identity server. The routes for it were already there; the page had no
way to reach them.

A Pireon client allows one grant type and the panel's is a password
client, so the redirect flow can use its own: PIREON_REDIRECT_CLIENT_ID
and PIREON_REDIRECT_CLIENT_SECRET (the main client when they are empty).
The button shows up once that client is set, or with
PIREON_REDIRECT_LOGIN=true. Each session renews its token with the
client that issued it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 12:46:07 +02:00
3c119d425e Open a TaskStream board for a project that has none yet
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
The project form gets «Crear en TaskStream» while the project has no
board, and `taskstream: true` on a PATCH does the same through the API.
A refusal leaves the edit unsaved; a project with a board keeps it, and
`taskstream_key` without the flag is still ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:21:38 +02:00
7e7d2847fe Point the public site to each project's TaskStream board
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
A project with a board now carries its key, its address and the address
of the board's public showcase in the public API, so the website can show
its roadmap, releases and tickets for newcomers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:13:56 +02:00
b32d6bf001 Open a TaskStream board from the panel's project form
Some checks failed
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Tests / lint (push) Has been cancelled
A "TaskStream" section on the create form (toggle and optional key,
suggested from the slug) opens the board before saving, as the person
signed in, with the Pireon token of their session. A refusal shows on the
key field and a TaskStream outage as a notification; either way nothing is
saved. The created notification carries the link, and the edit page has
an "Open in TaskStream" button.

Documented in the user manual (and its PDF) and the README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:01:55 +02:00
cdfc8763a5 Document TaskStream boards in the README and .env.example
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:20:23 +02:00
d30dd971d7 Open a TaskStream board when a project is created through the API
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
`taskstream: true` on POST /v1/projects also creates the project in
TaskStream, public, as the caller (their Pireon token is passed on), and
the answer carries `taskstream_key` and `taskstream_url`. The key is
`taskstream_key` or one made from the default-language slug.

The board is opened before the project is saved: if TaskStream refuses
it, nothing is created here and the reason comes back as a validation
error (502 when TaskStream does not answer). Machine tokens need the
server's own TASKSTREAM_TOKEN. Updates ignore the flag, and duplicates do
not inherit the link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:12:36 +02:00
9390db18c2 Send a webhook for every change a person makes to a record
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Events are {resource}.created, .updated, .deleted and .restored for content,
the inbox, redirects, repositories and media, taken from the audit log so the
two agree on what happened. Subscriptions take wildcards. Users, tokens,
webhooks and settings are never sent, nor what the application does by
itself; the inbox's own words only with personal data enabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 14:32:54 +02:00
d4cf4a66b7 Manage webhooks from the panel
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / test-postgres (push) Has been cancelled
Tests / mcp (push) Has been cancelled
Create one and see its secret once, send a test, change the secret, and read
each delivery with what the other end answered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:44:11 +02:00
d80ed1a3e8 Name dynamic translation keys the way the catalogue test reads them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:31:47 +02:00
64 changed files with 3053 additions and 73 deletions

View File

@@ -76,6 +76,21 @@ PIREON_CLIENT_SECRET=
PIREON_REDIRECT_URI="${APP_URL}/auth/callback"
PIREON_SCOPES=account:read
# «Entrar con Pireon» (Authorization Code + PKCE) under the login form. A Pireon
# client allows one grant type: the one above is the password client, so
# register another for authorization_code with the redirect URI above and put
# it here; the button shows up once it is set. PIREON_REDIRECT_LOGIN=true shows
# it with the client above instead (when that one allows authorization_code).
PIREON_REDIRECT_CLIENT_ID=
PIREON_REDIRECT_CLIENT_SECRET=
# PIREON_REDIRECT_LOGIN=
# Webhooks at ${APP_URL}/api/v1/webhooks/pireon (user.deactivated, token.revoked):
# register it on each client and paste the secret each one returns. Empty: the
# endpoint refuses everything and a revocation waits for the cache below.
PIREON_WEBHOOK_SECRET=
PIREON_REDIRECT_WEBHOOK_SECRET=
# Grants. The panel's sign-in form uses the password grant, so turning this off
# locks everyone out of the panel as well as out of POST /api/v1/auth/token.
PIREON_PASSWORD_GRANT=true
@@ -186,3 +201,18 @@ GIT_SYNC_CRON=hourly
GIT_SYNC_TTL=55
GIT_MAX_ISSUES=25
GIT_HTTP_TIMEOUT=15
###############################################################################
# TaskStream — the issue tracker
###############################################################################
# `taskstream: true` on POST /v1/projects opens a public board there too.
# Both default to production.
TASKSTREAM_API_URL=https://api.taskstream.pireon.dev
TASKSTREAM_WEB_URL=https://taskstream.pireon.dev
# A Pireon personal access token (oauth.pireon.dev/tokens), only used when the
# caller signs in with a machine token (pmt_…), which TaskStream cannot read.
# People with a Pireon token have theirs passed on instead. Empty: machine
# tokens cannot ask for a board.
TASKSTREAM_TOKEN=
TASKSTREAM_HTTP_TIMEOUT=10

112
README.md
View File

@@ -70,9 +70,20 @@ al remoto por si alguien comitea con `--no-verify`.
En `https://oauth.pireon.dev` crea un cliente OAuth con:
- **Grant types**: `password` — es el que usa el formulario de acceso del panel
Para ofrecer también **«Entrar con Pireon»** (redirección con Authorization
Code + PKCE, donde la contraseña solo se escribe en el servidor de identidad),
crea un segundo cliente, porque cada cliente admite un único grant:
- **Grant types**: `authorization_code`
- **Redirect URI**: `https://tu-dominio/auth/callback` (en local,
`http://localhost:8000/auth/callback`), solo si quieres conservar el flujo de
redirección, que sigue disponible aunque el panel ya no lo use
`http://localhost:8000/auth/callback`)
y pon sus credenciales en `PIREON_REDIRECT_CLIENT_ID` y
`PIREON_REDIRECT_CLIENT_SECRET`. El botón aparece bajo el formulario en cuanto
están; `PIREON_REDIRECT_LOGIN` lo fuerza (`true`, si el cliente principal
admite los dos grants) o lo quita (`false`). Cada sesión renueva su token con el
cliente que lo emitió.
Copia las credenciales al `.env`:
@@ -187,6 +198,27 @@ curl -X POST http://localhost:8000/api/v1/auth/token \
Es el mismo grant que usa el formulario del panel, así que
`PIREON_PASSWORD_GRANT=false` desactiva los dos a la vez.
### Webhooks de Pireon
Para que desactivar una cuenta o revocar un token en Pireon surta efecto al
instante, sin esperar al TTL de la caché:
1. Registra `https://tu-dominio/api/v1/webhooks/pireon` con los eventos
`user.deactivated` y `token.revoked` en el cliente de contraseña
(`POST /api/clients/{client}/webhooks` del servidor de identidad) y pon el
secret que devuelve en `PIREON_WEBHOOK_SECRET`.
2. Si usas el cliente de «Entrar con Pireon», registra el mismo webhook en él y
pon su secret en `PIREON_REDIRECT_WEBHOOK_SECRET`. Pireon firma cada aviso
con el secret del cliente que lo registró, y una revocación solo la avisa el
cliente que emitió el token.
`user.deactivated` desactiva la cuenta aquí (lo que corta también sus tokens de
máquina y el panel), cierra sus sesiones del panel (con `SESSION_DRIVER=database`)
y olvida sus tokens cacheados; vuelve a estar activa al entrar de nuevo cuando
Pireon la reactive. `token.revoked` cierra sus sesiones y olvida sus tokens
cacheados. La firma (`X-Webhook-Signature`, HMAC-SHA256 del cuerpo) vale con
cualquiera de los dos secrets; sin ninguno, el endpoint lo rechaza todo.
## Permisos
El catálogo vive en un único sitio: [`app/Support/Permissions.php`](app/Support/Permissions.php).
@@ -525,6 +557,58 @@ fuera. Añadir GitLab es escribir una clase que implemente
[`RepositoryProvider`](app/Services/Git/Contracts/RepositoryProvider.php) y
registrarla en `config/git.php`, sin tocar nada más.
## Tableros en TaskStream
Al crear un proyecto, la casilla «Crear también en TaskStream» del panel o
`taskstream: true` en la API le abren también un tablero en
[TaskStream](https://taskstream.pireon.dev), el gestor de tareas del colectivo.
El panel avisa con el enlace y, al editar, ofrece «Abrir en TaskStream»; la API
lo devuelve en la respuesta:
```json
POST /api/v1/projects
{ "title": { "es": "Catálogo abierto" }, "slug": { "es": "catalogo" }, "taskstream": true }
→ 201 { "data": { …, "taskstream_key": "CATALOGO", "taskstream_url": "https://taskstream.pireon.dev/p/CATALOGO" } }
```
- **Público** siempre: es el tablero de un proyecto que se enseña.
- **Clave**: la de `taskstream_key` (de 2 a 10 letras o cifras, empezando por
letra) o, si no se indica, una sacada del slug en el idioma por defecto: de
una palabra, sus diez primeros caracteres (`catalogo` → `CATALOGO`); de
varias, sus iniciales (`mi-web-nueva` → `MWN`).
- **Primero TaskStream**: si lo rechaza (una clave ya usada, por ejemplo), aquí
no se crea nada y el motivo vuelve como error de validación (422); si no
responde, 502. Así no quedan proyectos a medias ni webhooks de algo que no
existe.
- **A nombre de quien lo pide**: TaskStream acepta los mismos tokens de Pireon
que esta API, y se le pasa el de quien llama (en el panel, el de su sesión). Un token de máquina (`pmt_…`)
allí no significa nada, así que esas peticiones usan `TASKSTREAM_TOKEN`; sin
él, no pueden pedir tablero.
- **Después**: un proyecto que se creó sin tablero puede tenerlo luego, con
«Crear en TaskStream» en su ficha o `taskstream: true` en un `PATCH` (si
TaskStream lo rechaza, la edición tampoco se guarda). Uno que ya tiene tablero
lo conserva, y `taskstream_key` sin la casilla se ignora, así que un cliente
puede devolver tal cual lo que leyó. Un proyecto duplicado no hereda el
tablero del original.
```dotenv
TASKSTREAM_API_URL=https://api.taskstream.pireon.dev # por defecto
TASKSTREAM_WEB_URL=https://taskstream.pireon.dev # de aquí sale el enlace
TASKSTREAM_TOKEN= # token personal de Pireon, solo para tokens de máquina
TASKSTREAM_HTTP_TIMEOUT=10
```
El cliente está en [`TaskStreamClient`](app/Services/TaskStream/TaskStreamClient.php)
y la lógica (clave, token, errores) en
[`OpenInTaskStream`](app/Actions/Projects/OpenInTaskStream.php).
En la API pública, un proyecto con tablero trae `taskstream` (`key`, `url`,
`web_url` y `showcase_url`). `showcase_url` es público en TaskStream y devuelve
su hoja de ruta, sus versiones, el sprint en curso y los tickets etiquetados
`good-first-issue`, para que la web los enseñe en la página del proyecto.
## API pública (para el frontend)
Sin autenticación y de solo lectura. Todo lo que devuelve ya está publicado en
@@ -1456,6 +1540,14 @@ concedía el permiso sin preguntar al token.
### Webhooks salientes
En el panel, **Administración → Webhooks** (solo con `webhooks.*`): alta con la
URL y los eventos marcados, y al guardar sale **el secreto una única vez**, en
un aviso que no se cierra solo. En la ficha, «Enviar prueba» manda un `ping`,
«Cambiar secreto» genera otro (el anterior deja de valer en el acto) y abajo
están **los envíos**: estado, respuesta, intentos, error, el contenido enviado y
«Reenviar». El menú se marca en rojo si alguno se apagó por fallos. Lo mismo
por API:
```
/api/v1/webhooks CRUD (permiso webhooks.*, solo admin)
GET /api/v1/webhooks/{id}/deliveries qué se envió y cómo fue
@@ -1464,7 +1556,21 @@ POST /api/v1/webhook-deliveries/{id}/redeliver
```
Avisan a otro sistema de `application.received`, `content.published`,
`content.unpublished`, `review.requested` y `review.returned` (o `*`). Salen de
`content.unpublished`, `review.requested` y `review.returned`, y además de
**cualquier cambio que haga una persona en una ficha**: `{tipo}.created`,
`.updated`, `.deleted` y `.restored` para páginas, categorías, etiquetas,
proyectos, miembros, servicios, clientes, testimonios, charlas, tecnologías,
convocatorias, redirecciones, repositorios, medios y el buzón. Se puede
suscribir a uno, a `*`, a todo un tipo (`projects.*`) o a una acción en
cualquiera (`*.deleted`).
Esos eventos salen del [registro de auditoría](#registro-de-auditoría), así
que dicen lo mismo que él —qué ficha, quién, por dónde (panel, API, token) y,
al editar, qué campos cambiaron con su valor anterior y el nuevo— y dejan fuera
lo mismo: lo que hace la aplicación sola y los secretos. Del buzón solo se
cuenta cómo se está atendiendo, salvo con `include_personal_data`. Usuarios,
tokens, webhooks y ajustes no se envían nunca: son la seguridad de la
aplicación, no su contenido. Salen de
los propios registros —el historial ya distingue publicar de editar, venga del
panel, de la API o de un lote— y no de los controladores, así que no hay forma
de publicar algo sin que se avise.

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace App\Actions\Auth;
use App\Auth\PireonTokenGuard;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* What a Pireon webhook changes here.
*
* - `user.deactivated`: the account goes inactive (which also stops its
* machine tokens and the panel), its panel sessions end and its cached
* bearer tokens are forgotten. Signing in again once Pireon reactivates it
* turns it back on, as every sign-in copies the upstream state.
* - `token.revoked`: only token hashes are cached, so every cached token and
* panel session of that person goes; they sign in again.
*
* Anything else, or a person who never signed in here, is ignored. Repeating
* a delivery (one arrives per registered client) changes nothing.
*/
class HandlePireonWebhook
{
public const EVENTS = ['user.deactivated', 'token.revoked'];
/**
* @param array<string, mixed> $data
*/
public function handle(string $event, array $data): void
{
if (! in_array($event, self::EVENTS, true)) {
Log::debug('Unhandled Pireon webhook', ['event' => $event]);
return;
}
$oauthId = $data['user_id'] ?? $data['userId'] ?? ($data['user']['id'] ?? null);
if ($oauthId === null || $oauthId === '') {
Log::warning('Pireon webhook without a user id, ignored', ['event' => $event]);
return;
}
PireonTokenGuard::forgetUser((string) $oauthId);
$user = User::query()->where('oauth_id', $oauthId)->first();
if ($user === null) {
return;
}
if ($event === 'user.deactivated' && $user->is_active) {
$user->forceFill(['is_active' => false])->save();
}
$this->endSessions($user);
}
/**
* Sessions kept in the database can be ended one person at a time. With
* any other driver they cannot, but an inactive account is still turned
* away on its next request (canAccessPanel).
*/
protected function endSessions(User $user): void
{
if (config('session.driver') !== 'database') {
return;
}
DB::connection(config('session.connection'))
->table((string) config('session.table', 'sessions'))
->where('user_id', $user->getKey())
->delete();
}
}

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Actions\Projects;
use App\Models\Project;
use App\Models\User;
use App\Services\TaskStream\TaskStreamClient;
use App\Services\TaskStream\TaskStreamException;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Opens a board in TaskStream for a project, new or one without a board
* yet, and points the project at it.
*
* It runs before the project is saved: if TaskStream says no, nothing is
* saved here either, and the caller gets the reason to fix and retry.
* The board is public, like the project it tracks is meant to be.
*/
class OpenInTaskStream
{
/** TaskStream's rule for a project key. */
public const KEY_PATTERN = '/^[A-Z][A-Z0-9]{1,9}$/';
public function __construct(protected TaskStreamClient $client) {}
/**
* @param string|null $bearer The token the caller presented here.
* @param string|null $key The key asked for; derived from the slug when null.
*
* @throws ValidationException when the caller can fix the request
*/
public function handle(Project $project, User $user, ?string $bearer, ?string $key = null): void
{
$locale = (string) config('locales.default');
$key = $key !== null ? Str::upper($key) : $this->keyFrom((string) $project->getTranslation('slug', $locale, false));
if ($key === null) {
throw ValidationException::withMessages(['taskstream_key' => __('rules.taskstream.key_needed')]);
}
try {
$created = $this->client->createProject(
token: $this->token($user, $bearer),
key: $key,
name: Str::limit((string) $project->getTranslation('title', $locale, false), 80, '…'),
description: $project->getTranslation('tagline', $locale, false) ?: null,
public: true,
);
} catch (TaskStreamException $e) {
throw $this->explain($e);
}
$project->forceFill([
'taskstream_project_id' => $created['id'],
'taskstream_key' => $created['key'],
]);
}
/**
* A Pireon token is passed on, so the board belongs to whoever asked. A
* machine token is this application's alone and means nothing there.
*/
protected function token(User $user, ?string $bearer): string
{
$token = $user->currentAccessToken() === null ? $bearer : $this->client->serviceToken();
if (blank($token)) {
throw ValidationException::withMessages(['taskstream' => __('rules.taskstream.machine')]);
}
return $token;
}
/**
* A key in TaskStream's style: one word gives its first ten characters
* ("catalogo" → CATALOGO), several give their initials ("mi-web-nueva"
* → MWN). Null when nothing usable is left.
*/
public function keyFrom(string $slug): ?string
{
$words = array_values(array_filter(preg_split('/[^A-Z0-9]+/', Str::upper(Str::ascii($slug))) ?: []));
$key = count($words) === 1
? $words[0]
: implode('', array_map(fn (string $word) => $word[0], $words));
// It must start with a letter.
$key = substr((string) preg_replace('/^[0-9]+/', '', $key), 0, 10);
return preg_match(self::KEY_PATTERN, $key) === 1 ? $key : null;
}
protected function explain(TaskStreamException $e): ValidationException|HttpException
{
if ($e->isRefusal()) {
return ValidationException::withMessages([
'taskstream_key' => __('rules.taskstream.refused', ['reason' => $e->getMessage()]),
]);
}
if ($e->isUnauthorised()) {
return ValidationException::withMessages(['taskstream' => __('rules.taskstream.unauthorised')]);
}
return new HttpException(502, __('rules.taskstream.unavailable'), $e);
}
}

View File

@@ -104,9 +104,33 @@ class PireonTokenGuard
Cache::put($key, $identity->toArray(), $ttl);
// Which cached tokens are whose, so a webhook can drop a person's.
$index = self::indexKey($identity->id);
Cache::put($index, array_values(array_unique([...(array) Cache::get($index, []), $key])), $ttl);
return $identity;
}
/**
* Forget every token of that person checked lately: the next request with
* any of them asks the identity server again.
*/
public static function forgetUser(int|string $oauthId): void
{
$index = self::indexKey($oauthId);
foreach ((array) Cache::get($index, []) as $key) {
Cache::forget((string) $key);
}
Cache::forget($index);
}
protected static function indexKey(int|string $oauthId): string
{
return 'pireon:introspect:user:'.$oauthId;
}
/**
* Mirror the identity locally so policies, ownership checks and foreign
* keys have a real row to work with. Local roles are left untouched.

View File

@@ -10,6 +10,7 @@ use App\Services\Pireon\PireonClient;
use App\Services\Pireon\PireonException;
use App\Services\Pireon\TokenStore;
use DanHarrin\LivewireRateLimiting\Exceptions\TooManyRequestsException;
use Filament\Actions\Action;
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Filament\Auth\Pages\Login;
use Filament\Facades\Filament;
@@ -27,6 +28,9 @@ use SensitiveParameter;
* usable password column — but the person signing in never sees that. They
* type their credentials here and land in the panel.
*
* «Entrar con Pireon», under the form, takes the redirect flow instead: the
* password is then typed at the identity server only.
*
* The cost of that convenience is stated plainly because it is real: this
* application now handles the password rather than never seeing it. It is sent
* straight to the identity server over the password grant and is never stored,
@@ -53,6 +57,25 @@ class PireonLogin extends Login
return __('pireon.sign_in_intro');
}
/**
* Under «Entrar», «Entrar con Pireon»: the redirect flow, for whoever would
* rather not type their password here (or signs in some other way there).
*/
protected function getFormActions(): array
{
$actions = parent::getFormActions();
if (config('pireon.redirect_login') && PireonClient::forRedirect()->isConfigured()) {
$actions[] = Action::make('pireon')
->label(__('pireon.continue_with_pireon'))
->url(route('auth.redirect'))
->color('gray')
->outlined();
}
return $actions;
}
/**
* Filament's own flow checks the credentials against the local user table.
* There are no local passwords here, so the whole of it is replaced: the
@@ -110,7 +133,7 @@ class PireonLogin extends Login
Auth::login($user, (bool) ($data['remember'] ?? false));
app(TokenStore::class)->put($token);
app(TokenStore::class)->put($token, viaRedirect: false);
if ($user->locale) {
session()->put(config('locales.session_key'), $user->locale);

View File

@@ -88,7 +88,7 @@ class ApplicationInfolist
->state(fn (ApplicationReply $record) => __('panel.replies.by', [
'name' => $record->user_name ?? '—',
'when' => $record->created_at?->isoFormat('LLL'),
]).' · '.__('panel.replies.channels.'.$record->channel))
]).' · '.__("panel.replies.channels.{$record->channel}"))
->color('gray'),
TextEntry::make('body')

View File

@@ -4,10 +4,79 @@ declare(strict_types=1);
namespace App\Filament\Resources\Projects\Pages;
use App\Actions\Projects\OpenInTaskStream;
use App\Filament\Resources\Projects\ProjectResource;
use App\Models\Project;
use App\Services\Pireon\TokenStore;
use App\Services\TaskStream\TaskStreamClient;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\CreateRecord;
use Filament\Support\Exceptions\Halt;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
class CreateProject extends CreateRecord
{
protected static string $resource = ProjectResource::class;
/**
* With the TaskStream toggle on, the board is opened before the project
* is saved, as the person signed in (their Pireon token from the session).
*/
protected function handleRecordCreation(array $data): Model
{
$taskstream = (bool) Arr::pull($data, 'taskstream', false);
$key = Arr::pull($data, 'taskstream_key');
$project = new Project($data);
if ($taskstream) {
$this->openInTaskStream($project, filled($key) ? $key : null);
}
$project->save();
return $project;
}
protected function openInTaskStream(Project $project, ?string $key): void
{
try {
app(OpenInTaskStream::class)->handle($project, Auth::user(), app(TokenStore::class)->accessToken(), $key);
} catch (ValidationException $e) {
// The action names the API's fields; here they live under `data`.
throw ValidationException::withMessages(
collect($e->errors())->mapWithKeys(fn (array $messages, string $field) => ["data.{$field}" => $messages])->all(),
);
} catch (HttpException $e) {
Notification::make()->danger()->title($e->getMessage())->send();
throw (new Halt)->rollBackDatabaseTransaction();
}
}
protected function getCreatedNotification(): ?Notification
{
$notification = parent::getCreatedNotification();
$key = $this->getRecord()->taskstream_key;
if ($notification === null || $key === null) {
return $notification;
}
$url = app(TaskStreamClient::class)->projectUrl($key);
return $notification
->body(__('panel.projects.taskstream.created', ['url' => $url]))
->actions([
Action::make('taskstream')
->label(__('panel.projects.taskstream.open'))
->url($url, shouldOpenInNewTab: true),
])
->persistent();
}
}

View File

@@ -4,13 +4,22 @@ declare(strict_types=1);
namespace App\Filament\Resources\Projects\Pages;
use App\Actions\Projects\OpenInTaskStream;
use App\Filament\Concerns\WarnsOfConcurrentEditing;
use App\Filament\Resources\Projects\ProjectResource;
use App\Filament\Support\DuplicateAction;
use App\Filament\Support\PreviewAction;
use App\Filament\Support\TranslateAction;
use App\Services\Pireon\TokenStore;
use App\Services\TaskStream\TaskStreamClient;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
class EditProject extends EditRecord
{
@@ -23,9 +32,79 @@ class EditProject extends EditRecord
return [
// Seeing it on the real site without leaving the form.
PreviewAction::for('project'),
Action::make('taskstream')
->label(__('panel.projects.taskstream.open'))
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(fn () => app(TaskStreamClient::class)->projectUrl((string) $this->record->taskstream_key))
->openUrlInNewTab()
->visible(fn () => filled($this->record->taskstream_key)),
$this->createInTaskStream(),
DuplicateAction::make(),
TranslateAction::make(),
DeleteAction::make(),
];
}
/**
* For a project made without a board: opens one now, as the person
* signed in, and saves only the link (whatever is being edited in the
* form stays as it is, unsaved).
*/
protected function createInTaskStream(): Action
{
return Action::make('createInTaskStream')
->label(__('panel.projects.taskstream.create'))
->icon('heroicon-o-plus')
->color('gray')
->visible(fn () => blank($this->record->taskstream_key))
->authorize('update')
->modalHeading(__('panel.projects.taskstream.create_heading'))
->modalDescription(__('panel.projects.taskstream.section_hint'))
->modalSubmitActionLabel(__('panel.projects.taskstream.create'))
->schema([
TextInput::make('key')
->label(__('panel.projects.taskstream.key'))
->helperText(__('panel.projects.taskstream.key_hint'))
->placeholder(fn () => app(OpenInTaskStream::class)
->keyFrom((string) $this->record->getTranslation('slug', (string) config('locales.default'), false)))
->maxLength(10)
->regex('/^[A-Za-z][A-Za-z0-9]{1,9}$/'),
])
->action(function (array $data, Action $action): void {
$project = $this->record;
try {
app(OpenInTaskStream::class)->handle(
$project,
Auth::user(),
app(TokenStore::class)->accessToken(),
filled($data['key'] ?? null) ? $data['key'] : null,
);
} catch (ValidationException|HttpException $e) {
// The modal stays open, to change the key and try again.
Notification::make()
->danger()
->title($e instanceof ValidationException ? collect($e->errors())->flatten()->first() : $e->getMessage())
->send();
$action->halt();
}
$project->save();
$url = app(TaskStreamClient::class)->projectUrl((string) $project->taskstream_key);
Notification::make()
->success()
->title(__('panel.projects.taskstream.created', ['url' => $url]))
->actions([
Action::make('taskstream')
->label(__('panel.projects.taskstream.open'))
->url($url, shouldOpenInNewTab: true),
])
->persistent()
->send();
});
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Filament\Resources\Projects\Schemas;
use App\Actions\Projects\OpenInTaskStream;
use App\Enums\ProjectStatus;
use App\Filament\Resources\Projects\ProjectResource;
use App\Filament\Support\TranslatableTabs;
@@ -221,6 +222,27 @@ class ProjectForm
->label(__('panel.projects.fields.needs_help'))
->helperText(__('panel.projects.fields.needs_help_hint')),
]),
// Only when creating: CreateProject opens the board before
// saving, so a refusal there leaves nothing half made here.
Section::make(__('panel.projects.taskstream.section'))
->description(__('panel.projects.taskstream.section_hint'))
->visibleOn('create')
->columns(2)
->schema([
Toggle::make('taskstream')
->label(__('panel.projects.taskstream.toggle'))
->live(),
TextInput::make('taskstream_key')
->label(__('panel.projects.taskstream.key'))
->helperText(__('panel.projects.taskstream.key_hint'))
->placeholder(fn (callable $get) => app(OpenInTaskStream::class)
->keyFrom((string) $get('slug.'.config('locales.default'))))
->maxLength(10)
->regex('/^[A-Za-z][A-Za-z0-9]{1,9}$/')
->visible(fn (callable $get) => (bool) $get('taskstream')),
]),
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Webhooks\Pages;
use App\Filament\Resources\Webhooks\WebhookResource;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
/**
* The secret is made here and shown once, on the page that opens next.
*/
class CreateWebhook extends CreateRecord
{
protected static string $resource = WebhookResource::class;
protected string $secret = '';
protected function mutateFormDataBeforeCreate(array $data): array
{
$this->secret = Str::random(40);
return [...$data, 'secret' => $this->secret];
}
protected function afterCreate(): void
{
$this->record->forceFill(['created_by' => Auth::id()])->save();
WebhookResource::showSecret($this->secret);
}
protected function getCreatedNotification(): null
{
return null;
}
protected function getRedirectUrl(): string
{
return WebhookResource::getUrl('edit', ['record' => $this->record]);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Webhooks\Pages;
use App\Filament\Resources\Webhooks\WebhookResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditWebhook extends EditRecord
{
protected static string $resource = WebhookResource::class;
protected function getHeaderActions(): array
{
return [
WebhookResource::pingAction(),
WebhookResource::rotateAction(),
DeleteAction::make(),
];
}
/**
* Switching it back on starts its failure count again, as through the API.
*/
protected function mutateFormDataBeforeSave(array $data): array
{
if (($data['is_active'] ?? false) && ! $this->record->is_active) {
$this->record->consecutive_failures = 0;
}
return $data;
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Webhooks\Pages;
use App\Filament\Resources\Webhooks\WebhookResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListWebhooks extends ListRecords
{
protected static string $resource = WebhookResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Webhooks\RelationManagers;
use App\Models\WebhookDelivery;
use App\Support\Webhooks\Webhooks;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\HtmlString;
/**
* What was sent, and what the other end answered.
*/
class DeliveriesRelationManager extends RelationManager
{
protected static string $relationship = 'deliveries';
public static function getTitle(Model $ownerRecord, string $pageClass): string
{
return __('panel.webhooks.deliveries.title');
}
public function isReadOnly(): bool
{
return true;
}
public function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->poll('10s')
->columns([
TextColumn::make('event')
->label(__('panel.webhooks.deliveries.event'))
->badge()
->color('gray'),
TextColumn::make('status')
->label(__('panel.webhooks.deliveries.status'))
->badge()
->formatStateUsing(fn (string $state) => __("panel.webhooks.deliveries.statuses.{$state}"))
->color(fn (string $state) => match ($state) {
WebhookDelivery::SUCCEEDED => 'success',
WebhookDelivery::FAILED => 'danger',
default => 'warning',
}),
TextColumn::make('response_status')
->label(__('panel.webhooks.deliveries.response'))
->placeholder('—'),
TextColumn::make('attempts')
->label(__('panel.webhooks.deliveries.attempts')),
TextColumn::make('error')
->label(__('panel.webhooks.deliveries.error'))
->limit(60)
->tooltip(fn (WebhookDelivery $record) => $record->error)
->placeholder('—')
->color('danger'),
TextColumn::make('created_at')
->label(__('panel.webhooks.deliveries.created_at'))
->since()
->dateTimeTooltip(),
])
->filters([
SelectFilter::make('status')
->label(__('panel.webhooks.deliveries.status'))
->options(collect([WebhookDelivery::PENDING, WebhookDelivery::SUCCEEDED, WebhookDelivery::FAILED])
->mapWithKeys(fn (string $s) => [$s => __("panel.webhooks.deliveries.statuses.{$s}")])
->all()),
])
->recordActions([
Action::make('payload')
->label(__('panel.webhooks.deliveries.payload'))
->icon('heroicon-o-code-bracket')
->color('gray')
->modalHeading(fn (WebhookDelivery $record) => $record->event.' · '.$record->uuid)
->modalSubmitAction(false)
->modalContent(fn (WebhookDelivery $record) => new HtmlString(
'<pre style="white-space: pre-wrap; overflow-wrap: anywhere; font-size: .8125rem;">'
.e((string) json_encode($record->payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE))
.'</pre>'
)),
Action::make('redeliver')
->label(__('panel.webhooks.deliveries.redeliver'))
->icon('heroicon-o-arrow-path')
->requiresConfirmation()
->authorize(fn () => $this->getOwnerRecord() && auth()->user()?->can('update', $this->getOwnerRecord()))
->action(function (WebhookDelivery $record): void {
Webhooks::redeliver($record);
Notification::make()->title(__('panel.webhooks.deliveries.redelivered'))->success()->send();
}),
]);
}
}

View File

@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\Webhooks;
use App\Filament\Resources\Webhooks\Pages\CreateWebhook;
use App\Filament\Resources\Webhooks\Pages\EditWebhook;
use App\Filament\Resources\Webhooks\Pages\ListWebhooks;
use App\Filament\Resources\Webhooks\RelationManagers\DeliveriesRelationManager;
use App\Models\Webhook;
use App\Rules\PublicHttpsUrl;
use App\Support\Webhooks\Webhooks;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use UnitEnum;
/**
* Outgoing webhooks: where to tell when something happens here, and how each
* delivery went.
*/
class WebhookResource extends Resource
{
protected static ?string $model = Webhook::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBolt;
protected static ?int $navigationSort = 85;
protected static ?string $recordTitleAttribute = 'name';
public static function getModelLabel(): string
{
return __('panel.webhooks.label');
}
public static function getPluralModelLabel(): string
{
return __('panel.webhooks.plural');
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.administration');
}
/**
* Red while one is switched off by failures: something is not hearing
* what it signed up for.
*/
public static function getNavigationBadge(): ?string
{
$off = static::getModel()::query()
->where('is_active', false)
->where('consecutive_failures', '>=', Webhook::MAX_FAILURES)
->count();
return $off > 0 ? (string) $off : null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'danger';
}
public static function form(Schema $schema): Schema
{
return $schema->components([
Section::make()
->columns(2)
->schema([
TextInput::make('name')
->label(__('panel.webhooks.fields.name'))
->required()
->maxLength(120),
TextInput::make('url')
->label(__('panel.webhooks.fields.url'))
->helperText(__('panel.webhooks.fields.url_hint'))
->required()
->url()
->maxLength(2048)
->rule(new PublicHttpsUrl),
Select::make('events')
->label(__('panel.webhooks.fields.events'))
->helperText(__('panel.webhooks.events_hint'))
->multiple()
->searchable()
->options(fn () => self::eventOptions())
->required()
->columnSpanFull(),
Toggle::make('include_personal_data')
->label(__('panel.webhooks.fields.include_personal_data'))
->helperText(__('panel.webhooks.fields.include_personal_data_hint')),
Toggle::make('is_active')
->label(__('panel.webhooks.fields.is_active'))
->default(true),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label(__('panel.webhooks.fields.name'))
->weight('bold')
->description(fn (Webhook $record) => $record->url)
->searchable(),
TextColumn::make('events')
->label(__('panel.webhooks.fields.events'))
->badge()
->color('gray'),
IconColumn::make('is_active')
->label(__('panel.webhooks.fields.is_active'))
->boolean(),
TextColumn::make('consecutive_failures')
->label(__('panel.webhooks.fields.failures'))
->color(fn (int $state) => $state > 0 ? 'danger' : 'gray'),
TextColumn::make('last_delivered_at')
->label(__('panel.webhooks.fields.last_delivered_at'))
->since()
->dateTimeTooltip()
->placeholder('—'),
])
->recordActions([
self::pingAction(),
EditAction::make(),
DeleteAction::make(),
]);
}
public static function eventLabel(string $event): string
{
$key = str_replace('.', '_', $event);
return __("panel.webhooks.events.{$key}");
}
/**
* Grouped for the select: everything, the events with a meaning of their
* own, any record by action, then each kind of record.
*
* @return array<string, array<string, string>>
*/
public static function eventOptions(): array
{
$groups = [
__('panel.webhooks.groups.everything') => [
'*' => __('panel.webhooks.events.*'),
...collect(Webhook::ACTIONS)->mapWithKeys(fn (string $action) => [
"*.{$action}" => __('panel.webhooks.any_record', ['action' => self::actionLabel($action)]),
])->all(),
],
__('panel.webhooks.groups.notices') => collect(Webhook::EVENTS)
->mapWithKeys(fn (string $event) => [$event => self::eventLabel($event)])
->all(),
];
foreach (Webhook::RESOURCES as $resource) {
$groups[__("panel.webhooks.resources.{$resource}")] = [
"{$resource}.*" => __('panel.webhooks.all_of', ['resource' => __("panel.webhooks.resources.{$resource}")]),
...collect(Webhook::ACTIONS)->mapWithKeys(fn (string $action) => [
"{$resource}.{$action}" => __("panel.webhooks.resources.{$resource}").' · '.self::actionLabel($action),
])->all(),
];
}
return $groups;
}
protected static function actionLabel(string $action): string
{
return __("panel.webhooks.actions_on.{$action}");
}
public static function pingAction(): Action
{
return Action::make('ping')
->label(__('panel.webhooks.actions.ping'))
->icon('heroicon-o-paper-airplane')
->color('gray')
->authorize('update')
->action(function (Webhook $record): void {
Webhooks::ping($record);
Notification::make()
->title(__('panel.webhooks.actions.ping_sent'))
->body(__('panel.webhooks.actions.ping_hint'))
->success()
->send();
});
}
/**
* A new secret, shown once. The old one stops being valid at once, so the
* receiving end has to be updated straight after.
*/
public static function rotateAction(): Action
{
return Action::make('rotate_secret')
->label(__('panel.webhooks.actions.rotate'))
->icon('heroicon-o-key')
->color('warning')
->requiresConfirmation()
->modalDescription(__('panel.webhooks.actions.rotate_hint'))
->authorize('update')
->action(function (Webhook $record): void {
$secret = Str::random(40);
$record->forceFill(['secret' => $secret])->save();
self::showSecret($secret);
});
}
public static function showSecret(string $secret): void
{
Notification::make()
->title(__('panel.webhooks.secret.title'))
->body(__('panel.webhooks.secret.body', ['secret' => $secret]))
->warning()
->persistent()
->send();
}
public static function getRelations(): array
{
return [
DeliveriesRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListWebhooks::route('/'),
'create' => CreateWebhook::route('/create'),
'edit' => EditWebhook::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Actions\Auth\HandlePireonWebhook;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* Receives `user.deactivated` and `token.revoked` from the Pireon identity
* server, called by it and not by apps.
*
* `X-Webhook-Signature` is the HMAC-SHA256 (hex) of the raw body with the
* secret of the Pireon client that registered the webhook. This application
* has two clients (password and redirect), so a delivery is good if it
* verifies with either secret. With none configured the endpoint is off: an
* open one would let anyone switch accounts off.
*/
class PireonWebhookController extends Controller
{
public function __invoke(Request $request, HandlePireonWebhook $handle): Response|JsonResponse
{
$secrets = (array) config('pireon.webhook_secrets', []);
if ($secrets === []) {
return $this->refuse('Pireon webhooks are disabled (PIREON_WEBHOOK_SECRET is not set).');
}
$signature = preg_replace('/^sha256=/', '', (string) $request->header('X-Webhook-Signature'));
$body = $request->getContent();
$valid = $signature !== '' && collect($secrets)->contains(
fn (string $secret): bool => hash_equals(hash_hmac('sha256', $body, $secret), $signature),
);
if (! $valid) {
return $this->refuse('Missing or invalid signature.');
}
$event = (string) ($request->input('event') ?? $request->input('type') ?? '');
if ($event === '') {
return response()->json(['message' => 'Missing event name.'], 400);
}
$data = (array) ($request->input('data') ?? $request->input('payload') ?? $request->all());
$handle->handle($event, $data);
return response()->noContent();
}
protected function refuse(string $message): JsonResponse
{
return response()->json(['message' => $message], 401);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Actions\Projects\OpenInTaskStream;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\ProjectRequest;
use App\Http\Resources\ProjectResource;
@@ -45,11 +46,21 @@ class ProjectController extends Controller
return ProjectResource::collection($projects);
}
public function store(ProjectRequest $request): JsonResponse
/**
* With `taskstream: true`, the project's board is opened in TaskStream
* first; if that fails, nothing is created here either.
*/
public function store(ProjectRequest $request, OpenInTaskStream $openInTaskStream): JsonResponse
{
$this->authorize('create', Project::class);
$project = $this->save(new Project, $request);
$project = $this->fill(new Project, $request);
if ($request->boolean('taskstream')) {
$openInTaskStream->handle($project, $request->user(), $request->bearerToken(), $request->validated('taskstream_key'));
}
$project = $this->persist($project, $request);
return ProjectResource::make($project)->response()->setStatusCode(201);
}
@@ -61,11 +72,22 @@ class ProjectController extends Controller
return ProjectResource::make($project->load(self::WITH));
}
public function update(ProjectRequest $request, Project $project): ProjectResource
/**
* `taskstream: true` opens a board for a project that has none yet, as on
* creation (and nothing is saved if that fails); one that has a board
* keeps it.
*/
public function update(ProjectRequest $request, Project $project, OpenInTaskStream $openInTaskStream): ProjectResource
{
$this->authorize('update', $project);
return ProjectResource::make($this->save($project, $request));
$project = $this->fill($project, $request);
if ($request->boolean('taskstream') && $project->taskstream_project_id === null) {
$openInTaskStream->handle($project, $request->user(), $request->bearerToken(), $request->validated('taskstream_key'));
}
return ProjectResource::make($this->persist($project, $request));
}
public function destroy(Project $project): JsonResponse
@@ -86,20 +108,24 @@ class ProjectController extends Controller
return ProjectResource::make($project->load(self::WITH));
}
protected function fill(Project $project, ProjectRequest $request): Project
{
return $project->fill(TranslatablePayload::merge(
$project,
collect($request->validated())->except(['technology_ids', 'service_ids', 'members', 'taskstream', 'taskstream_key'])->all(),
$project->translatable,
));
}
/**
* The record and its relations in one transaction: a project that saved
* but lost its team halfway would be worse than one that did not save.
*/
protected function save(Project $project, ProjectRequest $request): Project
protected function persist(Project $project, ProjectRequest $request): Project
{
$data = $request->validated();
DB::transaction(function () use ($project, $data): void {
$project->fill(TranslatablePayload::merge(
$project,
collect($data)->except(['technology_ids', 'service_ids', 'members'])->all(),
$project->translatable,
));
$project->save();
if (array_key_exists('technology_ids', $data)) {

View File

@@ -8,9 +8,9 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Api\V1\WebhookRequest;
use App\Http\Resources\WebhookDeliveryResource;
use App\Http\Resources\WebhookResource;
use App\Jobs\DeliverWebhook;
use App\Models\Webhook;
use App\Models\WebhookDelivery;
use App\Support\Webhooks\Webhooks;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -115,15 +115,9 @@ class WebhookController extends Controller
{
$this->authorize('update', $webhook);
$delivery = $webhook->deliveries()->create([
'uuid' => (string) Str::uuid(),
'event' => 'ping',
'payload' => ['webhook' => $webhook->id, 'events' => $webhook->events],
]);
$delivery = Webhooks::ping($webhook);
DeliverWebhook::dispatch($delivery);
return WebhookDeliveryResource::make($delivery->refresh())->response()->setStatusCode(202);
return WebhookDeliveryResource::make($delivery)->response()->setStatusCode(202);
}
/**
@@ -133,14 +127,8 @@ class WebhookController extends Controller
{
$this->authorize('update', $delivery->webhook);
$again = $delivery->webhook->deliveries()->create([
'uuid' => (string) Str::uuid(),
'event' => $delivery->event,
'payload' => $delivery->payload,
]);
$again = Webhooks::redeliver($delivery);
DeliverWebhook::dispatch($again);
return WebhookDeliveryResource::make($again->refresh())->response()->setStatusCode(202);
return WebhookDeliveryResource::make($again)->response()->setStatusCode(202);
}
}

View File

@@ -19,6 +19,8 @@ use Illuminate\Support\Facades\Log;
*
* The browser never sees a password belonging to this application, and the
* authorization code alone is useless without the verifier we keep server-side.
* It is «Entrar con Pireon» on the login page; it uses the redirect client
* (`PireonClient::forRedirect()`), its own one when configured.
*/
class PireonAuthController extends Controller
{
@@ -38,7 +40,7 @@ class PireonAuthController extends Controller
}
try {
['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = $this->client->authorizationUrl();
['url' => $url, 'state' => $state, 'code_verifier' => $verifier] = PireonClient::forRedirect()->authorizationUrl();
} catch (PireonException $e) {
return $this->fail($e, route('login'));
}
@@ -81,7 +83,7 @@ class PireonAuthController extends Controller
}
try {
$token = $this->client->exchangeCode((string) $request->query('code'), (string) $verifier);
$token = PireonClient::forRedirect()->exchangeCode((string) $request->query('code'), (string) $verifier);
$identity = $this->client->identity($token->accessToken);
} catch (PireonException $e) {
return $this->fail($e, route('login'));
@@ -100,7 +102,7 @@ class PireonAuthController extends Controller
$request->session()->regenerate();
Auth::login($user, remember: true);
$this->tokens->put($token);
$this->tokens->put($token, viaRedirect: true);
if ($user->locale) {
$request->session()->put(config('locales.session_key'), $user->locale);

View File

@@ -19,6 +19,10 @@ use Illuminate\Validation\Validator;
*
* Relations travel as ids and are replaced whole when sent: `technology_ids`,
* `service_ids`, and `members` with each one's role on the project.
*
* `taskstream` opens a public board in TaskStream for the project, keyed
* `taskstream_key` or one derived from the slug; on an update, only when the
* project has none yet.
*/
class ProjectRequest extends FormRequest
{
@@ -74,6 +78,11 @@ class ProjectRequest extends FormRequest
'members' => ['sometimes', 'array'],
'members.*.id' => ['required', 'integer', 'distinct', Rule::exists('members', 'id')->whereNull('deleted_at')],
'members.*.role' => ['nullable', 'string', 'max:120'],
// The key is only read along with `taskstream`: a client PATCHing
// back what it read sends the key it got, which changes nothing.
'taskstream' => ['sometimes', 'boolean'],
'taskstream_key' => ['sometimes', 'nullable', 'string', 'regex:/^[A-Za-z][A-Za-z0-9]{1,9}$/'],
];
}

View File

@@ -5,10 +5,9 @@ declare(strict_types=1);
namespace App\Http\Requests\Api\V1;
use App\Models\Webhook;
use App\Support\Webhooks\PublicEndpoint;
use App\Rules\PublicHttpsUrl;
use Closure;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class WebhookRequest extends FormRequest
{
@@ -34,16 +33,18 @@ class WebhookRequest extends FormRequest
'max:2048',
// Refused now rather than at the first delivery; checked
// again at every delivery all the same.
new PublicHttpsUrl,
],
'events' => [$sometimes, 'array', 'min:1'],
'events.*' => [
'string',
'distinct',
function (string $attribute, mixed $value, Closure $fail): void {
[, $reason] = app(PublicEndpoint::class)->check((string) $value);
if ($reason !== null) {
$fail(__("rules.media.remote.{$reason}"));
if (! is_string($value) || ! Webhook::isSubscribable($value)) {
$fail(__('rules.webhooks.event', ['event' => (string) (is_scalar($value) ? $value : '')]));
}
},
],
'events' => [$sometimes, 'array', 'min:1'],
'events.*' => ['string', 'distinct', Rule::in(['*', ...Webhook::EVENTS])],
'secret' => [$creating ? 'sometimes' : 'prohibited', 'nullable', 'string', 'min:16', 'max:255'],
'rotate_secret' => [$creating ? 'prohibited' : 'sometimes', 'boolean'],
'include_personal_data' => ['sometimes', 'boolean'],

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Resources;
use App\Models\Project;
use App\Services\TaskStream\TaskStreamClient;
use App\Support\ImagePayload;
use App\Support\TranslatablePayload;
use Illuminate\Http\Request;
@@ -33,6 +34,10 @@ class ProjectResource extends JsonResource
'is_featured' => (bool) $this->is_featured,
'needs_help' => (bool) $this->needs_help,
'demo_url' => $this->demo_url,
'taskstream_key' => $this->taskstream_key,
'taskstream_url' => $this->taskstream_key !== null
? app(TaskStreamClient::class)->projectUrl($this->taskstream_key)
: null,
// The stored path, which is what a client PATCHes back; what was
// derived from it travels alongside, as on a page.
'cover_image' => $this->cover_image,

View File

@@ -6,6 +6,7 @@ namespace App\Http\Resources\PublicApi;
use App\Models\Media;
use App\Models\Project;
use App\Services\TaskStream\TaskStreamClient;
use App\Support\ImagePayload;
use App\Support\SiteUrls;
use App\Support\TranslatablePayload;
@@ -110,11 +111,30 @@ class PublicProjectResource extends JsonResource
// alive without anyone maintaining it.
'repositories' => PublicRepositoryResource::collection($this->whenLoaded('repositories')),
// Its board in TaskStream, when it has one: the public site reads
// the roadmap, releases and tickets for newcomers from there.
'taskstream' => $this->taskstream_key === null ? null : $this->taskstream(),
'alternates' => SiteUrls::alternates($this->resource, 'project'),
'path' => SiteUrls::alternates($this->resource, 'project')[$locale] ?? null,
];
}
/**
* @return array{key: string, url: string, web_url: string, showcase_url: string}
*/
protected function taskstream(): array
{
$client = app(TaskStreamClient::class);
return [
'key' => $this->taskstream_key,
'url' => $client->projectUrl($this->taskstream_key),
'web_url' => $client->webUrl(),
'showcase_url' => $client->showcaseUrl($this->taskstream_key),
];
}
/**
* The media row when the caller loaded it, and null otherwise — never a
* lazy load, because this runs once per row in a listing.

View File

@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Str;
/**
* Somewhere to tell when something happens here.
@@ -21,7 +22,7 @@ class Webhook extends Model
use HasFactory;
/**
* What can be subscribed to. `*` is all of them.
* Events with a meaning of their own, whatever record they are about.
*/
public const EVENTS = [
'application.received',
@@ -31,6 +32,53 @@ class Webhook extends Model
'review.returned',
];
/**
* Kinds of record whose every change can be subscribed to, as
* `{resource}.{action}`. Not users, tokens, webhooks or settings: those
* are the application's security, not its content, and do not leave it.
*/
public const RESOURCES = [
'pages', 'categories', 'tags', 'projects', 'members', 'services',
'clients', 'testimonials', 'talks', 'technologies', 'calls',
'redirects', 'repositories', 'media', 'applications',
];
public const ACTIONS = ['created', 'updated', 'deleted', 'restored'];
/**
* Every event that is ever sent.
*
* @return array<int, string>
*/
public static function events(): array
{
$changes = [];
foreach (self::RESOURCES as $resource) {
foreach (self::ACTIONS as $action) {
$changes[] = "{$resource}.{$action}";
}
}
return [...self::EVENTS, ...$changes];
}
/**
* What a webhook may subscribe to: an event, `*`, everything about one
* kind of record (`projects.*`) or one action on any (`*.deleted`).
*/
public static function isSubscribable(string $pattern): bool
{
if ($pattern === '*' || in_array($pattern, self::events(), true)) {
return true;
}
[$resource, $action] = array_pad(explode('.', $pattern, 2), 2, null);
return ($action === '*' && in_array($resource, self::RESOURCES, true))
|| ($resource === '*' && in_array($action, self::ACTIONS, true));
}
/**
* Switched off after this many failed deliveries in a row, so a dead
* endpoint does not keep a queue busy forever. Switching it back on is a
@@ -64,9 +112,13 @@ class Webhook extends Model
public function listensTo(string $event): bool
{
$events = $this->events ?? [];
foreach ($this->events ?? [] as $pattern) {
if ($pattern === $event || Str::is($pattern, $event)) {
return true;
}
}
return in_array('*', $events, true) || in_array($event, $events, true);
return false;
}
/**

View File

@@ -32,7 +32,7 @@ class ExpiringSoon extends Notification
return FilamentNotification::make()
->title(trans_choice('panel.expiring.notification.title', $this->items->count(), ['count' => $this->items->count()]))
->body($this->items
->map(fn (array $item) => $item['title'].' — '.__('panel.expiring.events.'.$item['event'], [
->map(fn (array $item) => $item['title'].' — '.__("panel.expiring.events.{$item['event']}", [
'when' => $item['at']->diffForHumans(),
'date' => $item['at']->isoFormat('LL'),
]))

View File

@@ -6,12 +6,14 @@ namespace App\Providers;
use App\Auth\PireonTokenGuard;
use App\Models\Application;
use App\Models\AuditLog;
use App\Models\ReviewComment;
use App\Models\Revision;
use App\Models\User;
use App\Services\Git\GitManager;
use App\Services\Pireon\PireonClient;
use App\Services\Pireon\TokenStore;
use App\Services\TaskStream\TaskStreamClient;
use App\Support\Audit\Auditor;
use App\Support\Webhooks\Webhooks;
use Illuminate\Auth\Events\Login;
@@ -42,6 +44,8 @@ class AppServiceProvider extends ServiceProvider
// Resolves git hosting drivers; see config/git.php.
$this->app->singleton(GitManager::class);
$this->app->singleton(TaskStreamClient::class, fn () => TaskStreamClient::fromConfig(config('services.taskstream')));
}
/**
@@ -64,6 +68,7 @@ class AppServiceProvider extends ServiceProvider
Application::created(fn (Application $application) => Webhooks::applicationReceived($application));
Revision::created(fn (Revision $revision) => Webhooks::revisionRecorded($revision));
ReviewComment::created(fn (ReviewComment $comment) => Webhooks::reviewRecorded($comment));
AuditLog::created(fn (AuditLog $entry) => Webhooks::auditRecorded($entry));
// The audit log: every change a person makes to an audited record,
// role changes, and signing in to the panel.

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Rules;
use App\Support\Webhooks\PublicEndpoint;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* An HTTPS address whose host resolves only to public addresses — somewhere
* this server may send a request to without reaching into its own network.
*/
class PublicHttpsUrl implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
[, $reason] = app(PublicEndpoint::class)->check((string) $value);
if ($reason !== null) {
$fail(__("rules.media.remote.{$reason}"));
}
}
}

View File

@@ -37,6 +37,22 @@ class PireonClient
);
}
/**
* The client for the redirect flow: its own when PIREON_REDIRECT_CLIENT_ID
* is set (a Pireon client allows one grant type), the main one otherwise.
*/
public static function forRedirect(): self
{
$own = filled(config('pireon.redirect_client_id'));
return new self(
server: (string) config('pireon.server'),
clientId: $own ? config('pireon.redirect_client_id') : config('pireon.client_id'),
clientSecret: $own ? config('pireon.redirect_client_secret') : config('pireon.client_secret'),
redirectUri: (string) config('pireon.redirect_uri'),
);
}
public function isConfigured(): bool
{
return filled($this->clientId);

View File

@@ -18,14 +18,25 @@ class TokenStore
{
protected const KEY = 'pireon.token';
/** Which client issued it: a refresh token only works with that one. */
protected const ISSUER = 'pireon.token_issuer';
public function __construct(
protected Session $session,
protected PireonClient $client,
) {}
public function put(AccessToken $token): void
/**
* @param bool|null $viaRedirect Whether the redirect flow's client
* issued it; null keeps what was said.
*/
public function put(AccessToken $token, ?bool $viaRedirect = null): void
{
$this->session->put(self::KEY, $token->toArray());
if ($viaRedirect !== null) {
$this->session->put(self::ISSUER, $viaRedirect ? 'redirect' : 'password');
}
}
public function get(): ?AccessToken
@@ -37,7 +48,7 @@ class TokenStore
public function forget(): void
{
$this->session->forget(self::KEY);
$this->session->forget([self::KEY, self::ISSUER]);
}
/**
@@ -66,7 +77,10 @@ class TokenStore
}
try {
$refreshed = $this->client->refresh($token->refreshToken);
$issuer = $this->session->get(self::ISSUER) === 'redirect'
? PireonClient::forRedirect()
: $this->client;
$refreshed = $issuer->refresh($token->refreshToken);
} catch (PireonException) {
// The refresh token was revoked or expired upstream.
$this->forget();

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Services\TaskStream;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
/**
* The few calls this application makes to TaskStream's REST API.
*
* Every call is made as somebody: TaskStream accepts the same Pireon tokens
* this application does, and whoever's token it is becomes the owner of
* what is created.
*/
class TaskStreamClient
{
public function __construct(
protected string $apiUrl,
protected string $webUrl,
protected ?string $serviceToken = null,
protected int $timeout = 10,
) {}
/**
* @param array<string, mixed> $config
*/
public static function fromConfig(array $config): self
{
return new self(
apiUrl: rtrim((string) ($config['api_url'] ?? ''), '/'),
webUrl: rtrim((string) ($config['web_url'] ?? ''), '/'),
serviceToken: filled($config['token'] ?? null) ? $config['token'] : null,
timeout: (int) ($config['timeout'] ?? 10),
);
}
/**
* The token for requests that do not carry a Pireon token of their own.
*/
public function serviceToken(): ?string
{
return $this->serviceToken;
}
/**
* @return array{id: string, key: string}
*
* @throws TaskStreamException
*/
public function createProject(string $token, string $key, string $name, ?string $description, bool $public): array
{
try {
$response = Http::withToken($token)
->acceptJson()
->timeout($this->timeout)
->post("{$this->apiUrl}/api/projects", array_filter([
'key' => $key,
'name' => $name,
'description' => $description,
'isPublic' => $public,
], fn ($value) => $value !== null));
} catch (ConnectionException $e) {
throw TaskStreamException::unreachable($this->apiUrl, $e);
}
if ($response->failed()) {
// Nest answers `message` as a string, or as a list from validation.
$message = $response->json('message');
throw new TaskStreamException(
is_array($message) ? implode(' ', $message) : (string) ($message ?: $response->reason()),
status: $response->status(),
);
}
return ['id' => (string) $response->json('id'), 'key' => (string) $response->json('key')];
}
/**
* Where people open a project's board.
*/
public function projectUrl(string $key): string
{
return "{$this->webUrl}/p/{$key}";
}
/**
* The public summary of a board (roadmap, releases, sprint, tickets for
* newcomers). Anyone may read it, so the public site can ask directly.
*/
public function showcaseUrl(string $key): string
{
return "{$this->apiUrl}/api/explore/projects/{$key}/showcase";
}
/**
* Where the paths of a showcase (`/p/KEY/issues/KEY-3`) are opened.
*/
public function webUrl(): string
{
return $this->webUrl;
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Services\TaskStream;
use RuntimeException;
use Throwable;
class TaskStreamException extends RuntimeException
{
public function __construct(
string $message,
public readonly ?int $status = null,
?Throwable $previous = null,
) {
parent::__construct($message, $status ?? 0, $previous);
}
/**
* TaskStream refused what it was asked for (a key in use, a bad name):
* the caller can fix it and try again.
*/
public function isRefusal(): bool
{
return in_array($this->status, [400, 409, 422], true);
}
/**
* The token was not good enough there: expired, or without access.
*/
public function isUnauthorised(): bool
{
return in_array($this->status, [401, 403], true);
}
public static function unreachable(string $url, ?Throwable $previous = null): self
{
return new self("TaskStream could not be reached at [{$url}].", previous: $previous);
}
}

View File

@@ -76,19 +76,19 @@ final class Auditor
/**
* @param array<string, mixed>|null $changes
*/
public static function record(string $action, ?Model $subject = null, ?array $changes = null, ?User $actor = null): void
public static function record(string $action, ?Model $subject = null, ?array $changes = null, ?User $actor = null): ?AuditLog
{
$actor ??= Auth::user();
if (! $actor instanceof User) {
return;
return null;
}
$request = app()->bound('request') ? request() : null;
$route = $request?->route();
$token = $actor->currentAccessToken();
AuditLog::query()->create([
return AuditLog::query()->create([
'user_id' => $actor->id,
'user_name' => $actor->name,
'via' => self::via($route?->getName(), $token !== null),

View File

@@ -32,10 +32,12 @@ final class Duplicator
];
/**
* Never copied: where it stands rather than what it says.
* Never copied: where it stands rather than what it says — and the
* TaskStream board, which belongs to the original alone.
*/
protected const RESET = [
'published_at', 'unpublish_at', 'review_requested_at', 'review_requested_by', 'reviewer_id',
'taskstream_project_id', 'taskstream_key',
];
public static function supports(Model $record): bool

View File

@@ -6,10 +6,14 @@ namespace App\Support\Webhooks;
use App\Jobs\DeliverWebhook;
use App\Models\Application;
use App\Models\AuditLog;
use App\Models\Member;
use App\Models\ReviewComment;
use App\Models\Revision;
use App\Models\Webhook;
use App\Models\WebhookDelivery;
use App\Support\AdminContent;
use App\Support\Audit\Auditor;
use App\Support\ContentTypes;
use App\Support\SiteUrls;
use Illuminate\Database\Eloquent\Model;
@@ -43,6 +47,38 @@ final class Webhooks
}
}
/**
* A `ping`, to check the receiving end before anything real happens.
*/
public static function ping(Webhook $webhook): WebhookDelivery
{
$delivery = $webhook->deliveries()->create([
'uuid' => (string) Str::uuid(),
'event' => 'ping',
'payload' => ['webhook' => $webhook->id, 'events' => $webhook->events],
]);
DeliverWebhook::dispatch($delivery);
return $delivery->refresh();
}
/**
* One sent again, as a new delivery of the same event and payload.
*/
public static function redeliver(WebhookDelivery $delivery): WebhookDelivery
{
$again = $delivery->webhook->deliveries()->create([
'uuid' => (string) Str::uuid(),
'event' => $delivery->event,
'payload' => $delivery->payload,
]);
DeliverWebhook::dispatch($again);
return $again->refresh();
}
public static function applicationReceived(Application $application): void
{
self::dispatch('application.received', [
@@ -60,6 +96,90 @@ final class Webhooks
]);
}
/**
* `projects.created`, `pages.updated`, `media.deleted`…: whatever a person
* did to a record, from the audit log — which already leaves out what the
* application does by itself, and already masks secrets, so the two
* cannot disagree about what happened.
*/
public static function auditRecorded(AuditLog $entry): void
{
[$resource, $action] = array_pad(explode('.', $entry->action, 2), 2, null);
if (! in_array($resource, Webhook::RESOURCES, true) || ! in_array($action, Webhook::ACTIONS, true)) {
return;
}
$record = self::find($resource, $entry->subject_id);
$changes = $entry->changes;
$data = [
'type' => $resource,
'id' => is_numeric($entry->subject_id) ? (int) $entry->subject_id : $entry->subject_id,
'title' => $entry->subject_label,
'action' => $action,
'by' => ['id' => $entry->user_id, 'name' => $entry->user_name],
'via' => $entry->via,
'changes' => $changes,
'url' => $action === 'deleted' || $record === null ? null : self::publicUrl($record),
];
$personal = [];
// A submission is somebody else's words: what is sent about it by
// default is how it is being handled, nothing they wrote.
if ($resource === 'applications') {
$data['title'] = $record instanceof Application ? $record->subject() : null;
$data['changes'] = $changes === null ? null : array_intersect_key($changes, array_flip(['status', 'handled_by', 'handled_at']));
$personal = [
'name' => $record?->name,
'email' => $record?->email,
'changes' => $changes,
];
}
self::dispatch($entry->action, $data, $personal);
}
protected static function find(string $resource, ?string $id): ?Model
{
$model = array_search($resource, Auditor::AUDITED, true);
if ($model === false || $id === null) {
return null;
}
$query = $model::query();
if (method_exists($model, 'bootSoftDeletes')) {
$query->withTrashed();
}
return $query->find($id);
}
/**
* Where it can be seen on the site, when it can be.
*/
protected static function publicUrl(Model $record): ?string
{
if (method_exists($record, 'isPublishedNow') && ! $record->isPublishedNow()) {
return null;
}
if ($record instanceof Member) {
return $record->is_public
? rtrim((string) config('site.url'), '/').SiteUrls::path('member', (string) config('locales.default'), $record->slug)
: null;
}
$siteType = AdminContent::PREVIEWABLE[AdminContent::nameOf($record) ?? ''] ?? null;
return $siteType
? (SiteUrls::absoluteAlternates($record, $siteType)[(string) config('locales.default')] ?? null)
: null;
}
/**
* Publishing and unpublishing are already told apart by the history, for
* every kind of record and from wherever it happened; this listens there.

View File

@@ -31,7 +31,28 @@ return [
| Where the identity server sends the user back after they authorise us.
| Must match one of the redirect URIs registered for the client.
*/
'redirect_uri' => env('PIREON_REDIRECT_URI', env('APP_URL', 'http://localhost').'/auth/callback'),
// Compose passes an unset variable as an empty one: empty means default.
'redirect_uri' => env('PIREON_REDIRECT_URI') ?: rtrim((string) env('APP_URL', 'http://localhost'), '/').'/auth/callback',
/*
| The redirect flow («Entrar con Pireon», Authorization Code + PKCE) can
| use a client of its own. A Pireon client allows one grant type, so when
| the one above is a password client, register another for
| authorization_code (redirect URI as above) and put it here. Left empty,
| the client above is used for both.
*/
'redirect_client_id' => env('PIREON_REDIRECT_CLIENT_ID'),
'redirect_client_secret' => env('PIREON_REDIRECT_CLIENT_SECRET'),
/*
| Offer «Entrar con Pireon» under the e-mail and password form. By default
| only once a redirect client is set: the main one is usually a password
| client, and the button would fail with it. Set it to true when the main
| client allows authorization_code too.
*/
'redirect_login' => filled(env('PIREON_REDIRECT_LOGIN'))
? (bool) env('PIREON_REDIRECT_LOGIN')
: filled(env('PIREON_REDIRECT_CLIENT_ID')),
/*
| Scopes requested for panel sessions. We only ever need to read the
@@ -77,6 +98,25 @@ return [
'introspection_ttl' => (int) env('PIREON_INTROSPECTION_TTL', 60),
/*
|--------------------------------------------------------------------------
| Webhooks (POST /api/v1/webhooks/pireon)
|--------------------------------------------------------------------------
|
| `user.deactivated` and `token.revoked` take effect at once instead of
| when the cache above runs out. Pireon signs each delivery with the secret
| of the client that registered the webhook, and only the client that
| issued a token tells when it is revoked: register the webhook on both
| clients (password and redirect) and put each secret here. With none, the
| endpoint refuses everything.
|
*/
'webhook_secrets' => array_values(array_filter([
env('PIREON_WEBHOOK_SECRET'),
env('PIREON_REDIRECT_WEBHOOK_SECRET'),
])),
'http_timeout' => (int) env('PIREON_HTTP_TIMEOUT', 10),
/*

View File

@@ -35,4 +35,23 @@ return [
],
],
/*
| TaskStream, the collective's issue tracker. Creating a project here can
| open its board there too (`taskstream: true`); it is made public.
*/
'taskstream' => [
'api_url' => env('TASKSTREAM_API_URL', 'https://api.taskstream.pireon.dev'),
'web_url' => env('TASKSTREAM_WEB_URL', 'https://taskstream.pireon.dev'),
/*
| TaskStream only understands Pireon tokens. A person signed in with
| one has it forwarded, so the board is theirs; a machine token means
| nothing there, so its requests use this one instead — a Pireon
| personal access token. Without it, they cannot ask for a board.
*/
'token' => env('TASKSTREAM_TOKEN'),
'timeout' => (int) env('TASKSTREAM_HTTP_TIMEOUT', 10),
],
];

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* The board a project has in TaskStream, when it was opened from here: its
* id there, and the key its address is made from.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->string('taskstream_project_id', 64)->nullable()->after('demo_url');
$table->string('taskstream_key', 10)->nullable()->after('taskstream_project_id');
});
}
public function down(): void
{
Schema::table('projects', function (Blueprint $table) {
$table->dropColumn(['taskstream_project_id', 'taskstream_key']);
});
}
};

View File

@@ -91,6 +91,13 @@ x-app-env: &app-env
PIREON_CLIENT_ID: ${PIREON_CLIENT_ID}
PIREON_CLIENT_SECRET: ${PIREON_CLIENT_SECRET}
PIREON_REDIRECT_URI: ${PIREON_REDIRECT_URI}
# «Entrar con Pireon»: its own authorization_code client (the button shows
# up once it is set), and the webhook secret of each client.
PIREON_REDIRECT_CLIENT_ID: ${PIREON_REDIRECT_CLIENT_ID:-}
PIREON_REDIRECT_CLIENT_SECRET: ${PIREON_REDIRECT_CLIENT_SECRET:-}
PIREON_REDIRECT_LOGIN: ${PIREON_REDIRECT_LOGIN:-}
PIREON_WEBHOOK_SECRET: ${PIREON_WEBHOOK_SECRET:-}
PIREON_REDIRECT_WEBHOOK_SECRET: ${PIREON_REDIRECT_WEBHOOK_SECRET:-}
PIREON_SCOPES: ${PIREON_SCOPES:-account:read}
PIREON_PASSWORD_GRANT: ${PIREON_PASSWORD_GRANT:-true}
PIREON_INTROSPECTION_TTL: ${PIREON_INTROSPECTION_TTL:-60}

View File

@@ -372,6 +372,22 @@ catalán /ca/projectes/gestor-de-socis</code></pre>
<tr><td>Busca gente</td><td>Lo saca en la sección de colaboración de la web.</td></tr>
</table>
<h3>Su tablero en TaskStream</h3>
<p>
Al crearlo, la casilla <strong>Crear también en TaskStream</strong> le abre un tablero
público en <a href="https://taskstream.pireon.dev">TaskStream</a>, el gestor de tareas
del colectivo, a tu nombre. La clave (la de los tickets: <code>WEB-1</code>,
<code>WEB-2</code>…) se saca del slug, o puedes escribirla tú: de 2 a 10 letras o
cifras, empezando por letra. Al guardar, el aviso trae el enlace, y desde entonces
el botón <strong>Abrir en TaskStream</strong> de la ficha lleva al tablero.
</p>
<p>
Si TaskStream no lo acepta (porque esa clave ya existe, por ejemplo), el proyecto
tampoco se guarda aquí: cambia la clave y vuelve a guardar. Si lo creaste sin tablero,
el botón <strong>Crear en TaskStream</strong> de su ficha se lo abre después, con la
misma clave a elegir.
</p>
<h3>Decisiones y postmortem</h3>
<p>
Es la sección plegada del formulario, y es la que hace que un proyecto merezca

Binary file not shown.

View File

@@ -146,6 +146,88 @@ return [
'select_all' => 'Selecciona-ho tot',
],
'webhooks' => [
'label' => 'Webhook',
'events_hint' => 'Pots triar-ne diversos. «Tot de…» inclou qualsevol canvi en aquest tipus de fitxa.',
'any_record' => 'Qualsevol fitxa · :action',
'all_of' => 'Tot de :resource',
'groups' => [
'everything' => 'General',
'notices' => 'Avisos',
],
'actions_on' => [
'created' => 'Creada',
'updated' => 'Editada',
'deleted' => 'Esborrada',
'restored' => 'Recuperada',
],
'resources' => [
'pages' => 'Pàgines',
'categories' => 'Categories',
'tags' => 'Etiquetes',
'projects' => 'Projectes',
'members' => 'Membres',
'services' => 'Serveis',
'clients' => 'Clients',
'testimonials' => 'Testimonis',
'talks' => 'Xerrades',
'technologies' => 'Tecnologies',
'calls' => 'Convocatòries',
'redirects' => 'Redireccions',
'repositories' => 'Repositoris',
'media' => 'Mitjans',
'applications' => 'Bústia',
],
'plural' => 'Webhooks',
'fields' => [
'name' => 'Nom',
'url' => 'URL',
'url_hint' => 'HTTPS i una adreça pública: no s\'accepten localhost, IPs privades ni noms interns.',
'events' => 'Esdeveniments',
'include_personal_data' => 'Incloure dades personals',
'include_personal_data_hint' => 'Només afecta «Sol·licitud rebuda»: afegeix el nom, el correu i el missatge de qui va escriure.',
'is_active' => 'Actiu',
'failures' => 'Errors seguits',
'last_delivered_at' => 'Últim enviament correcte',
],
'events' => [
'*' => 'Tots',
'application_received' => 'Sol·licitud rebuda a la bústia',
'content_published' => 'Contingut publicat',
'content_unpublished' => 'Contingut retirat del web',
'review_requested' => 'Revisió demanada',
'review_returned' => 'Revisió retornada amb canvis',
],
'actions' => [
'ping' => 'Enviar prova',
'ping_sent' => 'Prova en camí',
'ping_hint' => 'Mira el resultat a «Enviaments», més avall a la fitxa.',
'rotate' => 'Canviar secret',
'rotate_hint' => 'El secret actual deixa de valer a l\'acte: actualitza el servei que rep els avisos just després.',
],
'secret' => [
'title' => 'Desa aquest secret',
'body' => 'Signa cada enviament i no es tornarà a mostrar: :secret',
],
'deliveries' => [
'title' => 'Enviaments',
'event' => 'Esdeveniment',
'status' => 'Estat',
'response' => 'Resposta',
'attempts' => 'Intents',
'error' => 'Error',
'created_at' => 'Quan',
'payload' => 'Veure contingut',
'redeliver' => 'Reenviar',
'redelivered' => 'Reenviament en camí',
'statuses' => [
'pending' => 'Pendent',
'succeeded' => 'Lliurat',
'failed' => 'Fallit',
],
],
],
'tags' => [
'label' => 'Etiqueta',
'plural' => 'Etiquetes',
@@ -439,6 +521,17 @@ return [
'needs_help' => 'Busca gent',
'needs_help_hint' => 'El mostra a la secció de col·laboració del web.',
],
'taskstream' => [
'section' => 'TaskStream',
'section_hint' => 'Un tauler públic a TaskStream, el gestor de tasques del col·lectiu, al teu nom.',
'toggle' => 'Crear-lo també a TaskStream',
'key' => 'Clau a TaskStream',
'key_hint' => 'De 2 a 10 lletres o xifres, començant per lletra. Si la deixes buida, es treu de l\'slug.',
'created' => 'Tauler creat a TaskStream: :url',
'open' => 'Obrir a TaskStream',
'create' => 'Crear a TaskStream',
'create_heading' => 'Obrir un tauler a TaskStream',
],
'filters' => ['unpublished' => 'Sense publicar'],
'actions' => ['publish' => 'Publica'],
],

View File

@@ -4,7 +4,8 @@ declare(strict_types=1);
return [
'sign_in' => 'Entra',
'sign_in_intro' => 'Accedeix amb el teu correu i la teva contrasenya.',
'sign_in_intro' => 'Accedeix amb el teu correu i la teva contrasenya, o amb el teu compte de Pireon.',
'continue_with_pireon' => 'Entra amb Pireon',
'signing_in' => 'Comprovant les teves credencials…',
'password_grant_disabled' => 'L\'accés amb contrasenya està desactivat en aquesta instal·lació. Avisa un administrador.',
'server_unavailable' => 'El servidor d\'identitat no respon. No és cosa de les teves credencials; avisa un administrador.',

View File

@@ -44,6 +44,18 @@ return [
'too_many' => 'Com a molt :max files per importació.',
],
'webhooks' => [
'event' => '«:event» no és un esdeveniment al qual subscriure\'s.',
],
'taskstream' => [
'machine' => 'TaskStream només entén tokens de Pireon: entra amb un, o configura TASKSTREAM_TOKEN per als tokens de màquina.',
'key_needed' => 'No es pot treure una clau de l\'slug: indica\'n una a «taskstream_key» (de 2 a 10 lletres o xifres, començant per lletra).',
'refused' => 'TaskStream no ha creat el projecte: :reason',
'unauthorised' => 'TaskStream ha rebutjat el token: potser ha caducat o no hi té accés.',
'unavailable' => 'TaskStream no respon ara mateix; no s\'ha creat res, torna-ho a provar d\'aquí a una estona.',
],
'tokens' => [
'ability' => '«:ability» no és un permís que tinguis, així que un token teu no el pot tenir.',
'machine' => 'Els tokens els gestiona una persona, no un altre token.',

View File

@@ -146,6 +146,88 @@ return [
'select_all' => 'Select all',
],
'webhooks' => [
'label' => 'Webhook',
'events_hint' => 'Pick as many as you like. "All of…" covers any change to that kind of record.',
'any_record' => 'Any record · :action',
'all_of' => 'All of :resource',
'groups' => [
'everything' => 'General',
'notices' => 'Notices',
],
'actions_on' => [
'created' => 'Created',
'updated' => 'Edited',
'deleted' => 'Deleted',
'restored' => 'Restored',
],
'resources' => [
'pages' => 'Pages',
'categories' => 'Categories',
'tags' => 'Tags',
'projects' => 'Projects',
'members' => 'Members',
'services' => 'Services',
'clients' => 'Clients',
'testimonials' => 'Testimonials',
'talks' => 'Talks',
'technologies' => 'Technologies',
'calls' => 'Calls',
'redirects' => 'Redirects',
'repositories' => 'Repositories',
'media' => 'Media',
'applications' => 'Inbox',
],
'plural' => 'Webhooks',
'fields' => [
'name' => 'Name',
'url' => 'URL',
'url_hint' => 'HTTPS and a public address: localhost, private IPs and internal names are refused.',
'events' => 'Events',
'include_personal_data' => 'Include personal data',
'include_personal_data_hint' => 'Only for "Submission received": adds the name, e-mail and message of whoever wrote.',
'is_active' => 'Active',
'failures' => 'Failures in a row',
'last_delivered_at' => 'Last successful delivery',
],
'events' => [
'*' => 'All',
'application_received' => 'Submission received in the inbox',
'content_published' => 'Content published',
'content_unpublished' => 'Content taken off the site',
'review_requested' => 'Review requested',
'review_returned' => 'Review returned for changes',
],
'actions' => [
'ping' => 'Send a test',
'ping_sent' => 'Test on its way',
'ping_hint' => 'See how it went under "Deliveries", further down.',
'rotate' => 'Change secret',
'rotate_hint' => 'The current secret stops working at once: update the receiving service straight after.',
],
'secret' => [
'title' => 'Keep this secret',
'body' => 'It signs every delivery and will not be shown again: :secret',
],
'deliveries' => [
'title' => 'Deliveries',
'event' => 'Event',
'status' => 'Status',
'response' => 'Response',
'attempts' => 'Attempts',
'error' => 'Error',
'created_at' => 'When',
'payload' => 'View content',
'redeliver' => 'Send again',
'redelivered' => 'Sending again',
'statuses' => [
'pending' => 'Pending',
'succeeded' => 'Delivered',
'failed' => 'Failed',
],
],
],
'tags' => [
'label' => 'Tag',
'plural' => 'Tags',
@@ -437,6 +519,17 @@ return [
'needs_help' => 'Looking for help',
'needs_help_hint' => 'Surfaces it in the contribute section of the site.',
],
'taskstream' => [
'section' => 'TaskStream',
'section_hint' => 'A public board in TaskStream, the collective\'s issue tracker, in your name.',
'toggle' => 'Also create it in TaskStream',
'key' => 'Key in TaskStream',
'key_hint' => '2 to 10 letters or digits, starting with a letter. Left empty, it is made from the slug.',
'created' => 'Board created in TaskStream: :url',
'open' => 'Open in TaskStream',
'create' => 'Create in TaskStream',
'create_heading' => 'Open a board in TaskStream',
],
'filters' => ['unpublished' => 'Unpublished'],
'actions' => ['publish' => 'Publish'],
],

View File

@@ -4,7 +4,8 @@ declare(strict_types=1);
return [
'sign_in' => 'Sign in',
'sign_in_intro' => 'Sign in with your e-mail and password.',
'sign_in_intro' => 'Sign in with your e-mail and password, or with your Pireon account.',
'continue_with_pireon' => 'Sign in with Pireon',
'signing_in' => 'Checking your credentials…',
'password_grant_disabled' => 'Password sign-in is switched off on this installation. Please tell an administrator.',
'server_unavailable' => 'The identity server is not responding. This is not about your credentials; please tell an administrator.',

View File

@@ -44,6 +44,18 @@ return [
'too_many' => 'At most :max rows per import.',
],
'webhooks' => [
'event' => '":event" is not an event that can be subscribed to.',
],
'taskstream' => [
'machine' => 'TaskStream only understands Pireon tokens: sign in with one, or set TASKSTREAM_TOKEN for machine tokens.',
'key_needed' => 'No key can be made from the slug: give one in "taskstream_key" (2 to 10 letters or digits, starting with a letter).',
'refused' => 'TaskStream did not create the project: :reason',
'unauthorised' => 'TaskStream rejected the token: it may have expired or lack access.',
'unavailable' => 'TaskStream is not answering right now; nothing was created, try again in a while.',
],
'tokens' => [
'ability' => '":ability" is not a permission you hold, so a token of yours cannot hold it.',
'machine' => 'Tokens are managed by a person, not by another token.',

View File

@@ -146,6 +146,88 @@ return [
'select_all' => 'Seleccionar todos',
],
'webhooks' => [
'label' => 'Webhook',
'events_hint' => 'Puedes elegir varios. «Todo de…» incluye cualquier cambio en ese tipo de ficha.',
'any_record' => 'Cualquier ficha · :action',
'all_of' => 'Todo de :resource',
'groups' => [
'everything' => 'General',
'notices' => 'Avisos',
],
'actions_on' => [
'created' => 'Creada',
'updated' => 'Editada',
'deleted' => 'Borrada',
'restored' => 'Recuperada',
],
'resources' => [
'pages' => 'Páginas',
'categories' => 'Categorías',
'tags' => 'Etiquetas',
'projects' => 'Proyectos',
'members' => 'Miembros',
'services' => 'Servicios',
'clients' => 'Clientes',
'testimonials' => 'Testimonios',
'talks' => 'Charlas',
'technologies' => 'Tecnologías',
'calls' => 'Convocatorias',
'redirects' => 'Redirecciones',
'repositories' => 'Repositorios',
'media' => 'Medios',
'applications' => 'Buzón',
],
'plural' => 'Webhooks',
'fields' => [
'name' => 'Nombre',
'url' => 'URL',
'url_hint' => 'HTTPS y una dirección pública: no se aceptan localhost, IPs privadas ni nombres internos.',
'events' => 'Eventos',
'include_personal_data' => 'Incluir datos personales',
'include_personal_data_hint' => 'Solo afecta a «Solicitud recibida»: añade el nombre, el correo y el mensaje de quien escribió.',
'is_active' => 'Activo',
'failures' => 'Fallos seguidos',
'last_delivered_at' => 'Último envío correcto',
],
'events' => [
'*' => 'Todos',
'application_received' => 'Solicitud recibida en el buzón',
'content_published' => 'Contenido publicado',
'content_unpublished' => 'Contenido retirado de la web',
'review_requested' => 'Revisión pedida',
'review_returned' => 'Revisión devuelta con cambios',
],
'actions' => [
'ping' => 'Enviar prueba',
'ping_sent' => 'Prueba en camino',
'ping_hint' => 'Mira el resultado en «Envíos», más abajo en la ficha.',
'rotate' => 'Cambiar secreto',
'rotate_hint' => 'El secreto actual deja de valer en el acto: actualiza el servicio que recibe los avisos justo después.',
],
'secret' => [
'title' => 'Guarda este secreto',
'body' => 'Firma cada envío y no se volverá a mostrar: :secret',
],
'deliveries' => [
'title' => 'Envíos',
'event' => 'Evento',
'status' => 'Estado',
'response' => 'Respuesta',
'attempts' => 'Intentos',
'error' => 'Error',
'created_at' => 'Cuándo',
'payload' => 'Ver contenido',
'redeliver' => 'Reenviar',
'redelivered' => 'Reenvío en camino',
'statuses' => [
'pending' => 'Pendiente',
'succeeded' => 'Entregado',
'failed' => 'Fallido',
],
],
],
'tags' => [
'label' => 'Etiqueta',
'plural' => 'Etiquetas',
@@ -439,6 +521,17 @@ return [
'needs_help' => 'Busca gente',
'needs_help_hint' => 'Lo muestra en la sección de colaboración de la web.',
],
'taskstream' => [
'section' => 'TaskStream',
'section_hint' => 'Un tablero público en TaskStream, el gestor de tareas del colectivo, a tu nombre.',
'toggle' => 'Crear también en TaskStream',
'key' => 'Clave en TaskStream',
'key_hint' => 'De 2 a 10 letras o cifras, empezando por letra. Si la dejas vacía, se saca del slug.',
'created' => 'Tablero creado en TaskStream: :url',
'open' => 'Abrir en TaskStream',
'create' => 'Crear en TaskStream',
'create_heading' => 'Abrir un tablero en TaskStream',
],
'filters' => ['unpublished' => 'Sin publicar'],
'actions' => ['publish' => 'Publicar'],
],

View File

@@ -4,7 +4,8 @@ declare(strict_types=1);
return [
'sign_in' => 'Entrar',
'sign_in_intro' => 'Accede con tu email y tu contraseña.',
'sign_in_intro' => 'Accede con tu email y tu contraseña, o con tu cuenta de Pireon.',
'continue_with_pireon' => 'Entrar con Pireon',
'signing_in' => 'Comprobando tus credenciales…',
'password_grant_disabled' => 'El acceso con contraseña está desactivado en esta instalación. Avisa a un administrador.',
'server_unavailable' => 'El servidor de identidad no está respondiendo. No es cosa de tus credenciales; avisa a un administrador.',

View File

@@ -44,6 +44,18 @@ return [
'too_many' => 'Como mucho :max filas por importación.',
],
'webhooks' => [
'event' => '«:event» no es un evento al que suscribirse.',
],
'taskstream' => [
'machine' => 'TaskStream solo entiende tokens de Pireon: entra con uno, o configura TASKSTREAM_TOKEN para los tokens de máquina.',
'key_needed' => 'No se puede sacar una clave del slug: indica una en «taskstream_key» (de 2 a 10 letras o cifras, empezando por letra).',
'refused' => 'TaskStream no ha creado el proyecto: :reason',
'unauthorised' => 'TaskStream ha rechazado el token: puede que haya caducado o que no tenga acceso.',
'unavailable' => 'TaskStream no responde ahora mismo; no se ha creado nada, prueba otra vez en un rato.',
],
'tokens' => [
'ability' => '«:ability» no es un permiso que tengas, así que un token tuyo no puede tenerlo.',
'machine' => 'Los tokens los gestiona una persona, no otro token.',

View File

@@ -241,6 +241,10 @@ export interface PublicProject {
lead: PublicMember;
calls: PublicCall[];
repositories: PublicRepository[];
/**
* Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue.
*/
taskstream?: { key: string; url: string; web_url: string; showcase_url: string };
alternates: Alternates;
path?: string;
}

View File

@@ -1054,6 +1054,41 @@ paths:
"403": { description: Account inactive or awaiting approval }
"422": { $ref: "#/components/responses/ValidationError" }
/v1/webhooks/pireon:
post:
tags: [Auth]
summary: Pireon events (called by the identity server, not by apps)
description: |
`user.deactivated` turns the account off here, ends its panel sessions
and forgets its cached bearer tokens; `token.revoked` ends its sessions
and forgets its cached tokens. Signed in `X-Webhook-Signature` with the
HMAC-SHA256 (hex) of the raw body and the webhook secret of the Pireon
client that registered it (`PIREON_WEBHOOK_SECRET` or
`PIREON_REDIRECT_WEBHOOK_SECRET`). Disabled while neither is set.
security: []
parameters:
- name: X-Webhook-Signature
in: header
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [event]
properties:
event: { type: string, example: user.deactivated }
data:
type: object
properties:
user_id: { type: integer, example: 4242 }
responses:
"204": { description: "Processed (or nothing to do)" }
"400": { description: No event name }
"401": { description: "Missing or invalid signature, or webhooks disabled" }
/v1/me:
get:
tags: [Auth]
@@ -1566,6 +1601,16 @@ paths:
`technology_ids`, `service_ids`, and `members` with each one's role.
`published_at` and `unpublish_at` require `projects.publish`. For a
cover, upload it with `POST /v1/media` and `for: projects`.
With `taskstream: true` the project also gets a **public** board in
TaskStream, the collective's issue tracker, and the answer carries its
link in `taskstream_url`. Its key is `taskstream_key`, or one made from
the slug in the default language (one word: its first ten characters;
several: their initials). The board is opened first: if TaskStream
refuses it (a key in use, say), nothing is created here either and the
reason comes back as a validation error. It is opened as the caller,
whose Pireon token is passed on; a machine token cannot ask for one
unless the server has a `TASKSTREAM_TOKEN` of its own.
requestBody:
required: true
content:
@@ -1582,6 +1627,8 @@ paths:
data: { $ref: "#/components/schemas/Project" }
"403": { $ref: "#/components/responses/Forbidden" }
"422": { $ref: "#/components/responses/ValidationError" }
"502":
description: TaskStream did not answer; nothing was created.
/v1/projects/{project}:
parameters:
@@ -1609,7 +1656,14 @@ paths:
patch:
tags: [Projects]
summary: Update a project
description: Partial update. Translatable fields merge into the existing map.
description: |
Partial update. Translatable fields merge into the existing map.
`taskstream: true` opens a TaskStream board for a project that has none
yet, as on creation (`taskstream_key`, or one made from the slug); if
TaskStream refuses, the update is not saved either. A project that
already has a board keeps it, and `taskstream_key` without the flag is
ignored, so a client can PATCH back what it read.
requestBody:
required: true
content:
@@ -3945,9 +3999,26 @@ paths:
times over about two and a half hours; twenty failures in a row switch the
webhook off.
Events: `application.received` (its sender's details only with
`include_personal_data`), `content.published`, `content.unpublished`,
`review.requested`, `review.returned` — or `*`.
Events with a meaning of their own: `application.received` (its
sender's details only with `include_personal_data`),
`content.published`, `content.unpublished`, `review.requested`,
`review.returned`.
And every change a person makes to a record, as `{resource}.{action}`
— `projects.created`, `pages.updated`, `media.deleted`,
`calls.restored` — for pages, categories, tags, projects, members,
services, clients, testimonials, talks, technologies, calls,
redirects, repositories, media and applications. Its `data` says
what (`type`, `id`, `title`, `url` when it is on the site), who
(`by`), how they came in (`via`: panel, api, token) and, on an
update, `changes` as field → `{old, new}`. What the application does
by itself — a sync, a scheduled publication — is not sent. Changes to
an application carry only how it is handled unless
`include_personal_data`. Users, tokens, webhooks and settings are
never sent.
Subscribe to an event, to `*`, to all of one kind of record
(`projects.*`) or to one action on any (`*.deleted`).
requestBody:
required: true
content:
@@ -5012,6 +5083,15 @@ components:
repositories:
type: array
items: { $ref: "#/components/schemas/PublicRepository" }
taskstream:
type: object
nullable: true
description: "Its board in TaskStream, if it has one. `showcase_url` answers without a token: roadmap, releases, the sprint under way and tickets labelled good-first-issue."
properties:
key: { type: string, example: WEB }
url: { type: string, format: uri }
web_url: { type: string, format: uri }
showcase_url: { type: string, format: uri }
alternates: { $ref: "#/components/schemas/Alternates" }
path: { type: string, nullable: true, example: /proyectos/mi-web }
@@ -5486,6 +5566,8 @@ components:
is_featured: { type: boolean }
needs_help: { type: boolean }
demo_url: { type: string, nullable: true, format: uri }
taskstream_key: { type: string, nullable: true, description: "The project's key in TaskStream, when it has a board there." }
taskstream_url: { type: string, nullable: true, format: uri, readOnly: true, description: Where its TaskStream board opens. }
cover_image: { type: string, nullable: true, description: The stored path — what a client PATCHes back. }
cover_image_media:
allOf: [{ $ref: "#/components/schemas/Image" }]
@@ -5532,6 +5614,14 @@ components:
is_featured: { type: boolean }
needs_help: { type: boolean }
demo_url: { type: string, format: uri, nullable: true }
taskstream:
type: boolean
description: Also open a public board for it in TaskStream, on creation or when it has none yet; see `POST /v1/projects`.
taskstream_key:
type: string
nullable: true
pattern: "^[A-Za-z][A-Za-z0-9]{1,9}$"
description: Only read with `taskstream`. The key in TaskStream; made from the slug when left out.
cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. }
started_at: { type: string, format: date, nullable: true }
ended_at: { type: string, format: date, nullable: true }
@@ -6200,7 +6290,10 @@ components:
url: { type: string, format: uri, description: "HTTPS, to a public address." }
events:
type: array
items: { type: string, enum: ["*", application.received, content.published, content.unpublished, review.requested, review.returned] }
items:
type: string
description: "An event, `*`, `{resource}.*` or `*.{action}`."
example: projects.*
secret: { type: string, minLength: 16, description: On creation only; generated when left out. }
rotate_secret: { type: boolean, description: On update only. }
include_personal_data: { type: boolean, default: false }

View File

@@ -35,7 +35,7 @@
<div class="ec-legend" aria-label="{{ __('panel.calendar.legend') }}">
@foreach (['published', 'scheduled', 'unpublishes', 'opens', 'closes'] as $event)
<span class="ec-event ec-{{ $event }}">{{ __('panel.calendar.events.'.$event) }}</span>
<span class="ec-event ec-{{ $event }}">{{ __("panel.calendar.events.{$event}") }}</span>
@endforeach
</div>
@@ -73,8 +73,8 @@
@foreach ($day['events'] as $event)
<a href="{{ $event['url'] }}" class="ec-event ec-{{ $event['event'] }}">
<span class="sr-only">{{ __('panel.calendar.events.'.$event['event']) }}:</span>
{{ __('panel.calendar.types.'.$event['type']) }} · {{ $event['title'] }}
<span class="sr-only">{{ __("panel.calendar.events.{$event['event']}") }}:</span>
{{ __("panel.calendar.types.{$event['type']}") }} · {{ $event['title'] }}
</a>
@endforeach
</div>

View File

@@ -70,7 +70,7 @@
{{ $row['title'] }}
@endif
</td>
<td>{{ __('panel.statistics.types.'.$row['type']) }}</td>
<td>{{ __("panel.statistics.types.{$row['type']}") }}</td>
<td class="num">{{ number_format($row['views'], 0, ',', '.') }}</td>
<td @class(['num', 'st-up' => str_starts_with($delta, '+'), 'st-down' => str_starts_with($delta, '−')])>{{ $delta }}</td>
</tr>
@@ -88,7 +88,7 @@
<ul class="st-unread">
@foreach ($unread as $row)
<li>
<x-filament::badge color="gray" size="sm" style="display: inline-flex">{{ __('panel.statistics.types.'.$row['type']) }}</x-filament::badge>
<x-filament::badge color="gray" size="sm" style="display: inline-flex">{{ __("panel.statistics.types.{$row['type']}") }}</x-filament::badge>
@if ($row['url'])
<x-filament::link :href="$row['url']">{{ $row['title'] }}</x-filament::link>
@else

View File

@@ -40,7 +40,7 @@
@foreach ($pending as $item)
<tr>
<td>
<x-filament::badge color="gray" size="sm" style="display: inline-flex">{{ __('panel.statistics.types.'.$item['type']) }}</x-filament::badge>
<x-filament::badge color="gray" size="sm" style="display: inline-flex">{{ __("panel.statistics.types.{$item['type']}") }}</x-filament::badge>
{{ $item['title'] }}
</td>
<td>

View File

@@ -9,7 +9,7 @@
<li class="flex flex-wrap items-center justify-between gap-2 py-2">
<div class="flex items-center gap-2">
<x-filament::badge :color="$item['event'] === 'closes' ? 'warning' : 'gray'">
{{ __('panel.expiring.types.'.$item['type']) }}
{{ __("panel.expiring.types.{$item['type']}") }}
</x-filament::badge>
<x-filament::link :href="$item['url']">
@@ -18,7 +18,7 @@
</div>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{ __('panel.expiring.events.'.$item['event'], ['when' => $item['at']->diffForHumans(), 'date' => $item['at']->isoFormat('LL')]) }}
{{ __("panel.expiring.events.{$item['event']}", ['when' => $item['at']->diffForHumans(), 'date' => $item['at']->isoFormat('LL')]) }}
</span>
</li>
@endforeach

View File

@@ -27,6 +27,7 @@ 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\PireonWebhookController;
use App\Http\Controllers\Api\V1\PreviewController;
use App\Http\Controllers\Api\V1\ProjectController;
use App\Http\Controllers\Api\V1\RedirectController;
@@ -185,6 +186,11 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->middleware('throttle:auth-token')
->name('auth.token');
// Called by the identity server, signed with a client's webhook secret.
Route::post('webhooks/pireon', PireonWebhookController::class)
->middleware('throttle:60,1')
->name('webhooks.pireon');
Route::middleware(['auth:api', 'throttle:api'])->group(function () {
Route::get('me', MeController::class)->name('me');

View File

@@ -14,8 +14,9 @@ Route::redirect('/', '/admin');
| Authentication — Pireon OAuth2 (Authorization Code + PKCE)
|--------------------------------------------------------------------------
|
| `login` is the page Filament bounces guests to; it renders a single button
| that starts the flow at `auth.redirect`.
| `login` is the page Filament bounces guests to: an e-mail and password form
| (password grant) with «Entrar con Pireon» under it, which starts this flow at
| `auth.redirect`.
|
*/

View File

@@ -9,6 +9,7 @@ use App\Models\Member;
use App\Models\Project;
use App\Models\Repository;
use App\Models\Technology;
use App\Services\TaskStream\TaskStreamClient;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
@@ -63,6 +64,32 @@ class PublicApiTest extends TestCase
$this->getJson('/api/public/v1/projects/el-meu-projecte')->assertNotFound();
}
#[Test]
public function it_points_to_the_board_in_taskstream_when_there_is_one(): void
{
config([
'services.taskstream.api_url' => 'https://api.taskstream.test',
'services.taskstream.web_url' => 'https://taskstream.test',
]);
$this->app->forgetInstance(TaskStreamClient::class);
Project::factory()->published()->create([
'slug' => ['es' => 'con-tablero'],
'taskstream_key' => 'WEB',
]);
Project::factory()->published()->create(['slug' => ['es' => 'sin-tablero']]);
$this->getJson('/api/public/v1/projects/con-tablero')
->assertOk()
->assertJsonPath('data.taskstream', [
'key' => 'WEB',
'url' => 'https://taskstream.test/p/WEB',
'web_url' => 'https://taskstream.test',
'showcase_url' => 'https://api.taskstream.test/api/explore/projects/WEB/showcase',
]);
$this->getJson('/api/public/v1/projects/sin-tablero')
->assertJsonPath('data.taskstream', null);
}
#[Test]
public function it_returns_hreflang_alternates_for_every_translated_locale(): void
{

View File

@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Actions\Projects\OpenInTaskStream;
use App\Models\ApiToken;
use App\Models\Project;
use App\Models\User;
use App\Services\TaskStream\TaskStreamClient;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* `taskstream: true` on a new project, or on one without a board yet, opens
* its board in TaskStream too, and the answer carries the link.
*/
class TaskStreamProjectTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected string $taskstream = 'https://api.taskstream.test';
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
config([
'services.taskstream.api_url' => $this->taskstream,
'services.taskstream.web_url' => 'https://taskstream.test',
'services.taskstream.token' => null,
]);
}
protected function editor(): User
{
$user = User::factory()->create();
$user->syncRoles(['editor']);
return $user;
}
/**
* TaskStream creating whatever it is asked for, or answering $status.
*/
protected function fakeTaskStream(int $status = 201, array $body = []): void
{
Http::fake([
"{$this->taskstream}/api/projects" => fn (Request $request) => Http::response(
$status === 201 ? ['id' => 'ts-1', 'key' => $request['key'], 'version' => 1] : $body,
$status,
),
]);
}
protected function sentToTaskStream(): int
{
return Http::recorded(fn (Request $request) => str_starts_with($request->url(), $this->taskstream))->count();
}
#[Test]
public function a_new_project_gets_a_public_board_as_the_person_who_asked(): void
{
$user = $this->editor();
$this->fakeTaskStream();
$this->withHeaders($this->apiHeadersFor($user));
$this->postJson('/api/v1/projects', [
'title' => ['es' => 'Catálogo abierto'],
'slug' => ['es' => 'catalogo'],
'tagline' => ['es' => 'Una herramienta pequeña.'],
'taskstream' => true,
])
->assertCreated()
->assertJsonPath('data.taskstream_key', 'CATALOGO')
->assertJsonPath('data.taskstream_url', 'https://taskstream.test/p/CATALOGO');
Http::assertSent(fn (Request $request) => $request->url() === "{$this->taskstream}/api/projects"
&& $request->hasHeader('Authorization', 'Bearer token-for-'.$user->oauth_id)
&& $request->data() === [
'key' => 'CATALOGO',
'name' => 'Catálogo abierto',
'description' => 'Una herramienta pequeña.',
'isPublic' => true,
]);
$project = Project::query()->sole();
$this->assertSame('ts-1', $project->taskstream_project_id);
$this->assertSame('CATALOGO', $project->taskstream_key);
$this->getJson("/api/v1/projects/{$project->id}")
->assertJsonPath('data.taskstream_url', 'https://taskstream.test/p/CATALOGO');
}
#[Test]
public function the_key_can_be_chosen(): void
{
$this->fakeTaskStream();
$this->withHeaders($this->apiHeadersFor($this->editor()));
$this->postJson('/api/v1/projects', [
'title' => ['es' => 'Web'],
'slug' => ['es' => 'web'],
'taskstream' => true,
'taskstream_key' => 'web2',
])
->assertCreated()
->assertJsonPath('data.taskstream_key', 'WEB2');
$this->postJson('/api/v1/projects', [
'title' => ['es' => 'Otra'],
'slug' => ['es' => 'otra'],
'taskstream' => true,
'taskstream_key' => '1-no',
])
->assertUnprocessable()
->assertJsonValidationErrors('taskstream_key');
}
#[Test]
public function without_the_flag_taskstream_is_left_alone(): void
{
$this->fakeTaskStream();
$this->withHeaders($this->apiHeadersFor($this->editor()));
$this->postJson('/api/v1/projects', [
'title' => ['es' => 'Solo aquí'],
'slug' => ['es' => 'solo-aqui'],
'taskstream_key' => 'SOLO',
])
->assertCreated()
->assertJsonPath('data.taskstream_key', null)
->assertJsonPath('data.taskstream_url', null);
$this->assertSame(0, $this->sentToTaskStream());
}
#[Test]
public function a_refusal_from_taskstream_creates_nothing_here(): void
{
$this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key CATALOGO is already in use']);
$this->withHeaders($this->apiHeadersFor($this->editor()));
$response = $this->postJson('/api/v1/projects', [
'title' => ['es' => 'Catálogo'],
'slug' => ['es' => 'catalogo'],
'taskstream' => true,
])
->assertUnprocessable()
->assertJsonValidationErrors('taskstream_key');
// Whatever language the person reads, TaskStream's reason is passed on.
$this->assertStringContainsString('Project key CATALOGO is already in use', $response->json('errors.taskstream_key.0'));
$this->assertSame(0, Project::withTrashed()->count());
}
#[Test]
public function taskstream_being_down_creates_nothing_here(): void
{
Http::fake(["{$this->taskstream}/*" => Http::failedConnection()]);
$this->withHeaders($this->apiHeadersFor($this->editor()));
$this->postJson('/api/v1/projects', [
'title' => ['es' => 'Catálogo'],
'slug' => ['es' => 'catalogo'],
'taskstream' => true,
])->assertStatus(502);
$this->assertSame(0, Project::withTrashed()->count());
}
#[Test]
public function a_machine_token_needs_the_servers_own_taskstream_token(): void
{
$this->fakeTaskStream();
[, $plain] = ApiToken::issue($this->editor(), 'CI', ['projects.*'], now()->addDay());
$this->withHeaders(['Authorization' => 'Bearer '.$plain, 'Accept' => 'application/json']);
$this->postJson('/api/v1/projects', [
'title' => ['es' => 'Catálogo'],
'slug' => ['es' => 'catalogo'],
'taskstream' => true,
])
->assertUnprocessable()
->assertJsonValidationErrors('taskstream');
$this->assertSame(0, $this->sentToTaskStream());
$this->assertSame(0, Project::withTrashed()->count());
// With one configured, that token is the one TaskStream sees — never ours.
config(['services.taskstream.token' => 'pireon-service-token']);
$this->app->forgetInstance(TaskStreamClient::class);
$this->postJson('/api/v1/projects', [
'title' => ['es' => 'Catálogo'],
'slug' => ['es' => 'catalogo'],
'taskstream' => true,
])->assertCreated();
Http::assertSent(fn (Request $request) => str_starts_with($request->url(), $this->taskstream)
&& $request->hasHeader('Authorization', 'Bearer pireon-service-token'));
}
#[Test]
public function an_update_opens_a_board_for_a_project_without_one(): void
{
$this->fakeTaskStream();
$this->withHeaders($this->apiHeadersFor($this->editor()));
$project = Project::factory()->create(['slug' => ['es' => 'catalogo']]);
$this->patchJson("/api/v1/projects/{$project->id}", ['taskstream' => true, 'needs_help' => true])
->assertOk()
->assertJsonPath('data.taskstream_key', 'CATALOGO')
->assertJsonPath('data.taskstream_url', 'https://taskstream.test/p/CATALOGO')
->assertJsonPath('data.needs_help', true);
$this->assertSame('ts-1', $project->fresh()->taskstream_project_id);
}
#[Test]
public function a_project_with_a_board_keeps_it(): void
{
$this->fakeTaskStream();
$this->withHeaders($this->apiHeadersFor($this->editor()));
$project = Project::factory()->create(['taskstream_project_id' => 'ts-0', 'taskstream_key' => 'CAT']);
$this->patchJson("/api/v1/projects/{$project->id}", ['taskstream' => true, 'taskstream_key' => 'OTRA'])
->assertOk()
->assertJsonPath('data.taskstream_key', 'CAT');
$this->assertSame(0, $this->sentToTaskStream());
}
#[Test]
public function an_update_without_the_flag_ignores_the_key(): void
{
$this->fakeTaskStream();
$this->withHeaders($this->apiHeadersFor($this->editor()));
$project = Project::factory()->create();
// What a client sends back after reading a project.
$this->patchJson("/api/v1/projects/{$project->id}", ['taskstream_key' => 'OTRA', 'needs_help' => true])
->assertOk()
->assertJsonPath('data.taskstream_key', null);
$this->assertSame(0, $this->sentToTaskStream());
}
#[Test]
public function a_refused_board_leaves_the_update_unsaved(): void
{
$this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key WEB is already in use']);
$this->withHeaders($this->apiHeadersFor($this->editor()));
$project = Project::factory()->create(['needs_help' => false]);
$this->patchJson("/api/v1/projects/{$project->id}", ['taskstream' => true, 'taskstream_key' => 'WEB', 'needs_help' => true])
->assertUnprocessable()
->assertJsonValidationErrors('taskstream_key');
$this->assertFalse($project->fresh()->needs_help);
}
#[Test]
public function keys_are_made_from_the_slug_in_taskstreams_style(): void
{
$action = app(OpenInTaskStream::class);
$this->assertSame('CATALOGO', $action->keyFrom('catalogo'));
$this->assertSame('TRANSPAREN', $action->keyFrom('transparencia'));
$this->assertSame('MWN', $action->keyFrom('mi-web-nueva'));
$this->assertSame('ANO', $action->keyFrom('año-nuevo-ok'));
$this->assertSame('WEB', $action->keyFrom('2026web'));
$this->assertNull($action->keyFrom('2026-web'));
$this->assertNull($action->keyFrom('x'));
}
}

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api;
use App\Enums\ApplicationStatus;
use App\Models\Application;
use App\Models\Page;
use App\Models\Project;
use App\Models\Repository;
use App\Models\User;
use App\Models\Webhook;
use App\Models\WebhookDelivery;
use App\Services\Media\HostResolver;
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;
/**
* `{resource}.{action}`: every change a person makes to a record, as a
* webhook event, with wildcards to subscribe broadly.
*/
class WebhookRecordEventsTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
Queue::fake();
$this->app->instance(HostResolver::class, new class extends HostResolver
{
public function resolve(string $host): array
{
return ['93.184.216.34'];
}
});
}
protected function actingWithRole(string $role): User
{
$user = User::factory()->create();
$user->syncRoles([$role]);
$this->withHeaders($this->apiHeadersFor($user));
return $user;
}
#[Test]
public function wildcards_cover_a_kind_of_record_or_an_action(): void
{
$webhook = Webhook::factory()->make(['events' => ['projects.*', '*.deleted']]);
$this->assertTrue($webhook->listensTo('projects.updated'));
$this->assertTrue($webhook->listensTo('pages.deleted'));
$this->assertFalse($webhook->listensTo('pages.updated'));
$this->assertFalse($webhook->listensTo('content.published'));
}
#[Test]
public function editing_a_project_says_what_changed_who_did_it_and_how(): void
{
$editor = $this->actingWithRole('editor');
$webhook = Webhook::factory()->create(['events' => ['projects.*']]);
$project = Project::factory()->create(['status' => 'active']);
$this->patchJson("/api/v1/projects/{$project->id}", ['status' => 'paused'])->assertOk();
$delivery = $webhook->deliveries()->where('event', 'projects.updated')->sole();
$this->assertSame($project->id, $delivery->payload['id']);
$this->assertSame(['old' => 'active', 'new' => 'paused'], $delivery->payload['changes']['status']);
$this->assertSame($editor->id, $delivery->payload['by']['id']);
$this->assertSame('api', $delivery->payload['via']);
}
#[Test]
public function creating_and_deleting_are_events_too(): void
{
$this->actingWithRole('editor');
$webhook = Webhook::factory()->create(['events' => ['pages.created', '*.deleted']]);
$id = $this->postJson('/api/v1/pages', ['title' => ['es' => 'Hola'], 'slug' => ['es' => 'hola']])->json('data.id');
$this->deleteJson("/api/v1/pages/{$id}")->assertNoContent();
$this->assertSame(['pages.deleted', 'pages.created'], $webhook->deliveries()->pluck('event')->all());
$this->assertNull($webhook->deliveries()->first()->payload['url']);
}
#[Test]
public function what_the_application_does_by_itself_is_not_announced(): void
{
$webhook = Webhook::factory()->create(['events' => ['*']]);
Repository::factory()->create()->forceFill(['stars' => 10])->save();
$this->assertSame(0, $webhook->deliveries()->count());
}
#[Test]
public function security_records_cannot_be_subscribed_to(): void
{
$this->actingWithRole('admin');
foreach (['users.updated', 'tokens.*', 'settings.updated', 'webhooks.created', 'projects.exploded'] as $event) {
$this->postJson('/api/v1/webhooks', ['name' => 'X', 'url' => 'https://hooks.example.org/x', 'events' => [$event]])
->assertUnprocessable()
->assertJsonValidationErrors('events.0');
}
$this->postJson('/api/v1/webhooks', ['name' => 'X', 'url' => 'https://hooks.example.org/x', 'events' => ['projects.*', '*.restored']])
->assertCreated();
}
#[Test]
public function the_inbox_tells_how_it_is_handled_and_its_words_only_when_asked(): void
{
$this->actingWithRole('editor');
$plain = Webhook::factory()->create(['events' => ['applications.updated']]);
$personal = Webhook::factory()->create(['events' => ['applications.*'], 'include_personal_data' => true]);
$application = Application::factory()->create(['email' => 'ada@example.org']);
$this->patchJson("/api/v1/applications/{$application->id}", ['status' => 'contacted', 'notes' => 'Llamar el lunes'])->assertOk();
$sent = $plain->deliveries()->latest('id')->first()->payload;
$full = $personal->deliveries()->latest('id')->first()->payload;
$this->assertArrayNotHasKey('notes', $sent['changes'] ?? []);
$this->assertArrayNotHasKey('email', $sent);
$this->assertSame('ada@example.org', $full['email']);
$this->assertSame(ApplicationStatus::Contacted->value, $application->fresh()->status->value);
}
#[Test]
public function the_events_answer_as_queued_deliveries(): void
{
$this->actingWithRole('editor');
Webhook::factory()->create(['events' => ['*']]);
$page = Page::factory()->create();
$this->patchJson("/api/v1/pages/{$page->id}", ['is_featured' => ! $page->is_featured])->assertOk();
$this->assertTrue(WebhookDelivery::query()->where('event', 'pages.updated')->exists());
}
}

View File

@@ -5,8 +5,13 @@ declare(strict_types=1);
namespace Tests\Feature\Auth;
use App\Models\User;
use App\Services\Pireon\AccessToken;
use App\Services\Pireon\TokenStore;
use Carbon\CarbonImmutable;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
@@ -144,4 +149,57 @@ class PireonLoginTest extends TestCase
$this->assertGuest();
$this->assertNull(session('pireon.token'));
}
#[Test]
public function the_redirect_flow_uses_its_own_client_when_one_is_set(): void
{
config(['pireon.redirect_client_id' => 'code-client', 'pireon.redirect_client_secret' => 'code-secret']);
$response = $this->get(route('auth.redirect'));
$query = [];
parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query);
$this->assertSame('code-client', $query['client_id']);
$this->fakePireon();
$this
->withSession(['pireon.state' => 'expected-state', 'pireon.code_verifier' => 'the-verifier'])
->get(route('auth.callback', ['code' => 'auth-code', 'state' => 'expected-state']))
->assertRedirect();
// The code is redeemed with the client it was issued for.
Http::assertSent(fn (Request $request) => str_ends_with($request->url(), '/oauth/token')
&& $request['grant_type'] === 'authorization_code'
&& $request['client_id'] === 'code-client'
&& $request['client_secret'] === 'code-secret');
$this->assertSame('redirect', session('pireon.token_issuer'));
}
#[Test]
public function a_redirect_session_renews_its_token_with_the_redirect_client(): void
{
config(['pireon.redirect_client_id' => 'code-client', 'pireon.redirect_client_secret' => null]);
$this->fakePireon(token: ['access_token' => 'renewed-token']);
$store = app(TokenStore::class);
$store->put(new AccessToken('old-token', 'the-refresh-token', CarbonImmutable::now()->subMinute()), viaRedirect: true);
$this->assertSame('renewed-token', $store->accessToken());
Http::assertSent(fn (Request $request) => str_ends_with($request->url(), '/oauth/token')
&& $request['grant_type'] === 'refresh_token'
&& $request['client_id'] === 'code-client');
}
#[Test]
public function the_login_page_offers_pireon_only_with_a_redirect_client(): void
{
config(['pireon.redirect_login' => false]);
$this->get('/admin/login')->assertOk()->assertDontSee(__('pireon.continue_with_pireon'));
config(['pireon.redirect_login' => true, 'pireon.redirect_client_id' => 'code-client']);
$this->get('/admin/login')
->assertOk()
->assertSee(__('pireon.continue_with_pireon'))
->assertSee(route('auth.redirect'), escape: false);
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Auth;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Testing\TestResponse;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
class PireonWebhookTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
config(['pireon.webhook_secrets' => ['password-secret', 'redirect-secret']]);
}
/**
* @param array<string, mixed> $payload
*/
protected function deliver(array $payload, ?string $secret = 'password-secret'): TestResponse
{
$body = (string) json_encode($payload);
$headers = ['CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json'];
if ($secret !== null) {
$headers['HTTP_X_WEBHOOK_SIGNATURE'] = hash_hmac('sha256', $body, $secret);
}
return $this->call('POST', route('api.v1.webhooks.pireon'), [], [], [], $headers, $body);
}
#[Test]
public function it_refuses_everything_without_a_secret(): void
{
config(['pireon.webhook_secrets' => []]);
$this->deliver(['event' => 'token.revoked', 'data' => ['user_id' => 1]])->assertUnauthorized();
}
#[Test]
public function it_refuses_a_delivery_that_is_not_signed_with_either_secret(): void
{
$user = $this->userWithRoles('editor', ['oauth_id' => 77]);
$this->deliver(['event' => 'user.deactivated', 'data' => ['user_id' => 77]], 'someone-else')->assertUnauthorized();
$this->deliver(['event' => 'user.deactivated', 'data' => ['user_id' => 77]], null)->assertUnauthorized();
$this->assertTrue($user->fresh()->is_active);
}
#[Test]
public function a_deactivated_account_goes_inactive_and_loses_its_sessions(): void
{
$user = $this->userWithRoles('editor', ['oauth_id' => 77]);
config(['session.driver' => 'database']);
DB::table('sessions')->insert([
'id' => 'a-session', 'user_id' => $user->id, 'payload' => '', 'last_activity' => time(),
]);
// Signed by the redirect client: either secret is good.
$this->deliver(['event' => 'user.deactivated', 'data' => ['user_id' => 77]], 'redirect-secret')
->assertNoContent();
$this->assertFalse($user->fresh()->is_active);
$this->assertDatabaseMissing('sessions', ['id' => 'a-session']);
}
#[Test]
public function a_revoked_token_stops_working_at_once_instead_of_after_the_cache(): void
{
$user = $this->userWithRoles('editor', ['oauth_id' => 77]);
$headers = $this->apiHeadersFor($user);
$this->getJson(route('api.v1.me'), $headers)->assertOk();
// Revoked upstream: the cached answer would still let it through…
$this->pireonIdentities = array_fill_keys(['nobody'], []);
$this->app['auth']->forgetGuards();
$this->getJson(route('api.v1.me'), $headers)->assertOk();
// …until Pireon says so.
$this->deliver(['event' => 'token.revoked', 'data' => ['user_id' => 77]])->assertNoContent();
$this->app['auth']->forgetGuards();
$this->getJson(route('api.v1.me'), $headers)->assertUnauthorized();
$this->assertTrue($user->fresh()->is_active);
}
#[Test]
public function other_events_and_unknown_people_change_nothing(): void
{
$user = $this->userWithRoles('editor', ['oauth_id' => 77]);
$this->deliver(['event' => 'user.updated', 'data' => ['user_id' => 77]])->assertNoContent();
$this->deliver(['event' => 'user.deactivated', 'data' => ['user_id' => 999]])->assertNoContent();
$this->deliver(['data' => ['user_id' => 77]])->assertStatus(400);
$this->assertTrue($user->fresh()->is_active);
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Resources\Projects\Pages\CreateProject;
use App\Filament\Resources\Projects\Pages\EditProject;
use App\Models\Project;
use App\Services\Pireon\AccessToken;
use App\Services\Pireon\TokenStore;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* The panel's way to open a project's board in TaskStream, as the person
* signed in: a toggle on the create form, or an action on a project that
* has none yet.
*/
class TaskStreamPanelTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected string $taskstream = 'https://api.taskstream.test';
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
$this->actingAs($this->userWithRoles('editor'));
config([
'services.taskstream.api_url' => $this->taskstream,
'services.taskstream.web_url' => 'https://taskstream.test',
]);
// What signing in to the panel leaves in the session.
app(TokenStore::class)->put(new AccessToken('session-token', null, now()->addHour()->toImmutable()));
}
protected function fakeTaskStream(int $status = 201, array $body = []): void
{
Http::fake([
"{$this->taskstream}/api/projects" => fn (Request $request) => Http::response(
$status === 201 ? ['id' => 'ts-1', 'key' => $request['key'], 'version' => 1] : $body,
$status,
),
]);
}
#[Test]
public function the_toggle_opens_a_board_with_the_session_token(): void
{
$this->fakeTaskStream();
Livewire::test(CreateProject::class)
->fillForm([
'title' => ['es' => 'Catálogo abierto'],
'slug' => ['es' => 'catalogo'],
'taskstream' => true,
])
->assertFormFieldExists('taskstream_key')
->call('create')
->assertHasNoFormErrors()
->assertNotified();
Http::assertSent(fn (Request $request) => $request->url() === "{$this->taskstream}/api/projects"
&& $request->hasHeader('Authorization', 'Bearer session-token')
&& $request['key'] === 'CATALOGO'
&& $request['isPublic'] === true);
$project = Project::query()->sole();
$this->assertSame('CATALOGO', $project->taskstream_key);
Livewire::test(EditProject::class, ['record' => $project->getRouteKey()])
->assertActionVisible('taskstream')
->assertActionHasUrl('taskstream', 'https://taskstream.test/p/CATALOGO')
->assertFormFieldDoesNotExist('taskstream');
}
#[Test]
public function without_the_toggle_taskstream_is_left_alone(): void
{
$this->fakeTaskStream();
Livewire::test(CreateProject::class)
->fillForm(['title' => ['es' => 'Solo aquí'], 'slug' => ['es' => 'solo-aqui']])
->assertFormFieldHidden('taskstream_key')
->call('create')
->assertHasNoFormErrors();
Http::assertNothingSent();
Livewire::test(EditProject::class, ['record' => Project::query()->sole()->getRouteKey()])
->assertActionHidden('taskstream');
}
#[Test]
public function a_refusal_shows_on_the_key_and_saves_nothing(): void
{
$this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key WEB is already in use']);
Livewire::test(CreateProject::class)
->fillForm([
'title' => ['es' => 'Web'],
'slug' => ['es' => 'web'],
'taskstream' => true,
'taskstream_key' => 'web',
])
->call('create')
->assertHasFormErrors(['taskstream_key']);
$this->assertSame(0, Project::withTrashed()->count());
}
#[Test]
public function taskstream_being_down_saves_nothing(): void
{
Http::fake(["{$this->taskstream}/*" => Http::failedConnection()]);
Livewire::test(CreateProject::class)
->fillForm(['title' => ['es' => 'Web'], 'slug' => ['es' => 'web'], 'taskstream' => true])
->call('create')
->assertNotified(__('rules.taskstream.unavailable'));
$this->assertSame(0, Project::withTrashed()->count());
}
#[Test]
public function a_project_without_a_board_can_get_one_later(): void
{
$this->fakeTaskStream();
$project = Project::factory()->create(['slug' => ['es' => 'catalogo']]);
Livewire::test(EditProject::class, ['record' => $project->getRouteKey()])
->assertActionHidden('taskstream')
->assertActionVisible('createInTaskStream')
->callAction('createInTaskStream', data: ['key' => 'cat'])
->assertHasNoActionErrors()
->assertNotified();
Http::assertSent(fn (Request $request) => $request->hasHeader('Authorization', 'Bearer session-token')
&& $request['key'] === 'CAT'
&& $request['isPublic'] === true);
$this->assertSame('CAT', $project->fresh()->taskstream_key);
Livewire::test(EditProject::class, ['record' => $project->getRouteKey()])
->assertActionVisible('taskstream')
->assertActionHidden('createInTaskStream');
}
#[Test]
public function a_refusal_later_keeps_the_project_without_a_board(): void
{
$this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key WEB is already in use']);
$project = Project::factory()->create();
Livewire::test(EditProject::class, ['record' => $project->getRouteKey()])
->callAction('createInTaskStream', data: ['key' => 'WEB'])
->assertNotified(__('rules.taskstream.refused', ['reason' => 'Project key WEB is already in use']));
$this->assertNull($project->fresh()->taskstream_key);
}
}

View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Resources\Webhooks\Pages\CreateWebhook;
use App\Filament\Resources\Webhooks\Pages\EditWebhook;
use App\Filament\Resources\Webhooks\RelationManagers\DeliveriesRelationManager;
use App\Filament\Resources\Webhooks\WebhookResource;
use App\Models\Webhook;
use App\Services\Media\HostResolver;
use Database\Seeders\RolePermissionSeeder;
use Filament\Notifications\Notification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
/**
* Webhooks from the panel: the same rules as the API, plus the secret shown
* once and the deliveries at hand.
*/
class WebhookPanelTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
$this->app->instance(HostResolver::class, new class extends HostResolver
{
public function resolve(string $host): array
{
return ['hooks.example.org' => ['93.184.216.34'], 'internal.example.org' => ['10.0.0.5']][$host] ?? [];
}
});
}
#[Test]
public function an_admin_registers_one_and_sees_its_secret_once(): void
{
$this->actingAs($admin = $this->userWithRoles('admin'));
Livewire::test(CreateWebhook::class)
->fillForm([
'name' => 'Avisos',
'url' => 'https://hooks.example.org/pireon',
'events' => ['application.received'],
])
->call('create')
->assertHasNoFormErrors();
$webhook = Webhook::query()->sole();
$this->assertSame(40, strlen($webhook->secret));
$this->assertSame($admin->id, $webhook->created_by);
Notification::assertNotified(
Notification::make()
->title(__('panel.webhooks.secret.title'))
->body(__('panel.webhooks.secret.body', ['secret' => $webhook->secret]))
->warning()
->persistent(),
);
}
#[Test]
public function an_address_inside_the_network_is_refused(): void
{
$this->actingAs($this->userWithRoles('admin'));
Livewire::test(CreateWebhook::class)
->fillForm(['name' => 'X', 'url' => 'https://internal.example.org/x', 'events' => ['*']])
->call('create')
->assertHasFormErrors(['url']);
$this->assertSame(0, Webhook::query()->count());
}
#[Test]
public function a_test_is_sent_and_shows_among_the_deliveries(): void
{
Http::fake(['hooks.example.org/*' => Http::response('ok')]);
$this->actingAs($this->userWithRoles('admin'));
$webhook = Webhook::factory()->create(['url' => 'https://hooks.example.org/x']);
Livewire::test(EditWebhook::class, ['record' => $webhook->getRouteKey()])
->callAction('ping');
Livewire::test(DeliveriesRelationManager::class, ['ownerRecord' => $webhook, 'pageClass' => EditWebhook::class])
->assertCanSeeTableRecords($webhook->deliveries()->get())
->assertSee('ping');
}
#[Test]
public function rotating_the_secret_shows_the_new_one(): void
{
$this->actingAs($this->userWithRoles('admin'));
$webhook = Webhook::factory()->create(['secret' => 'old-secret-old-secret']);
Livewire::test(EditWebhook::class, ['record' => $webhook->getRouteKey()])
->callAction('rotate_secret');
$new = $webhook->fresh()->secret;
$this->assertNotSame('old-secret-old-secret', $new);
Notification::assertNotified(
Notification::make()
->title(__('panel.webhooks.secret.title'))
->body(__('panel.webhooks.secret.body', ['secret' => $new]))
->warning()
->persistent(),
);
}
#[Test]
public function an_editor_does_not_see_them(): void
{
$this->actingAs($this->userWithRoles('editor'));
$this->get(WebhookResource::getUrl('index'))->assertForbidden();
}
}