Initial commit: Pireon Admin — headless admin panel and public API
Laravel 13 + Filament 5 admin panel and REST API for a non-profit developer collective, backed by the Pireon OAuth identity server. The public site is a separate, server-rendered application consuming /api/public/v1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
35
app/Services/Git/Contracts/RepositoryProvider.php
Normal file
35
app/Services/Git/Contracts/RepositoryProvider.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Git\Contracts;
|
||||
|
||||
use App\Services\Git\GitException;
|
||||
use App\Services\Git\RepositorySnapshot;
|
||||
|
||||
/**
|
||||
* A git hosting provider this application can read from.
|
||||
*
|
||||
* Implementations are responsible for talking to one provider's API and
|
||||
* translating its answer into the provider-neutral shapes in
|
||||
* App\Services\Git — nothing outside this namespace should know whether a
|
||||
* repository lives on GitHub, GitLab or Gitea.
|
||||
*/
|
||||
interface RepositoryProvider
|
||||
{
|
||||
/**
|
||||
* Fetch everything the application shows about a repository.
|
||||
*
|
||||
* @param string $fullName "owner/repo"
|
||||
*
|
||||
* @throws GitException when the repository is missing,
|
||||
* inaccessible, or the provider is unreachable.
|
||||
*/
|
||||
public function fetch(string $fullName): RepositorySnapshot;
|
||||
|
||||
/**
|
||||
* Whether this driver is usable right now. A driver may work without
|
||||
* credentials but be too rate-limited to be practical.
|
||||
*/
|
||||
public function isConfigured(): bool;
|
||||
}
|
||||
64
app/Services/Git/GitException.php
Normal file
64
app/Services/Git/GitException.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Git;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class GitException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly ?int $status = null,
|
||||
/**
|
||||
* Whether retrying later might succeed. Rate limits and outages are
|
||||
* transient; a deleted repository is not, and the job should stop
|
||||
* retrying rather than hammer the provider.
|
||||
*/
|
||||
public readonly bool $isTransient = false,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $status ?? 0, $previous);
|
||||
}
|
||||
|
||||
public static function notFound(string $fullName): self
|
||||
{
|
||||
return new self(
|
||||
"The repository [{$fullName}] does not exist, or the configured token cannot see it.",
|
||||
status: 404,
|
||||
);
|
||||
}
|
||||
|
||||
public static function rateLimited(?int $resetsInSeconds = null): self
|
||||
{
|
||||
$when = $resetsInSeconds !== null
|
||||
? " Try again in about {$resetsInSeconds}s."
|
||||
: '';
|
||||
|
||||
return new self(
|
||||
"The provider's rate limit has been reached.{$when} ".
|
||||
'Configure GITHUB_TOKEN to raise it from 60 to 5000 requests per hour.',
|
||||
status: 429,
|
||||
isTransient: true,
|
||||
);
|
||||
}
|
||||
|
||||
public static function unreachable(string $provider, ?Throwable $previous = null): self
|
||||
{
|
||||
return new self(
|
||||
"The [{$provider}] API could not be reached.",
|
||||
isTransient: true,
|
||||
previous: $previous,
|
||||
);
|
||||
}
|
||||
|
||||
public static function unsupportedProvider(string $provider): self
|
||||
{
|
||||
return new self(
|
||||
"No driver is registered for the [{$provider}] provider. ".
|
||||
'Add one to config/git.php.'
|
||||
);
|
||||
}
|
||||
}
|
||||
248
app/Services/Git/GitHubProvider.php
Normal file
248
app/Services/Git/GitHubProvider.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Git;
|
||||
|
||||
use App\Services\Git\Contracts\RepositoryProvider;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Reads repositories from the GitHub REST API.
|
||||
*
|
||||
* One sync costs four calls: the repository itself, its open issues, its
|
||||
* language breakdown, and its contributor count. Everything after the first is
|
||||
* best-effort — a repository that answers but whose contributor list is
|
||||
* unavailable still produces a usable snapshot rather than failing outright.
|
||||
*/
|
||||
class GitHubProvider implements RepositoryProvider
|
||||
{
|
||||
public function __construct(
|
||||
protected string $apiUrl,
|
||||
protected ?string $token = null,
|
||||
protected ?string $defaultOwner = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public static function fromConfig(array $config): self
|
||||
{
|
||||
return new self(
|
||||
apiUrl: rtrim((string) ($config['api_url'] ?? 'https://api.github.com'), '/'),
|
||||
token: $config['token'] ?? null,
|
||||
defaultOwner: $config['default_owner'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub is usable without a token, just heavily rate limited, so this
|
||||
* reports readiness rather than the presence of credentials.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function fetch(string $fullName): RepositorySnapshot
|
||||
{
|
||||
$fullName = $this->qualify($fullName);
|
||||
|
||||
$repo = $this->get("/repos/{$fullName}");
|
||||
|
||||
if ($repo->status() === 404) {
|
||||
throw GitException::notFound($fullName);
|
||||
}
|
||||
|
||||
$this->assertNotRateLimited($repo);
|
||||
$this->assertSuccessful($repo, $fullName);
|
||||
|
||||
$data = (array) $repo->json();
|
||||
|
||||
return new RepositorySnapshot(
|
||||
fullName: (string) ($data['full_name'] ?? $fullName),
|
||||
htmlUrl: $data['html_url'] ?? null,
|
||||
description: $data['description'] ?? null,
|
||||
homepage: filled($data['homepage'] ?? null) ? $data['homepage'] : null,
|
||||
defaultBranch: $data['default_branch'] ?? null,
|
||||
primaryLanguage: $data['language'] ?? null,
|
||||
license: $data['license']['spdx_id'] ?? null,
|
||||
topics: array_values((array) ($data['topics'] ?? [])),
|
||||
languages: $this->fetchLanguages($fullName),
|
||||
stars: (int) ($data['stargazers_count'] ?? 0),
|
||||
forks: (int) ($data['forks_count'] ?? 0),
|
||||
watchers: (int) ($data['subscribers_count'] ?? 0),
|
||||
openIssuesCount: (int) ($data['open_issues_count'] ?? 0),
|
||||
contributorsCount: $this->fetchContributorCount($fullName),
|
||||
commitsLastYear: 0,
|
||||
isArchived: (bool) ($data['archived'] ?? false),
|
||||
isFork: (bool) ($data['fork'] ?? false),
|
||||
pushedAt: $this->date($data['pushed_at'] ?? null),
|
||||
createdAt: $this->date($data['created_at'] ?? null),
|
||||
issues: $this->fetchIssues($fullName),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open issues worth showing a newcomer.
|
||||
*
|
||||
* @return array<int, IssueSnapshot>
|
||||
*/
|
||||
protected function fetchIssues(string $fullName): array
|
||||
{
|
||||
$response = $this->get("/repos/{$fullName}/issues", [
|
||||
'state' => 'open',
|
||||
'per_page' => 100,
|
||||
'sort' => 'updated',
|
||||
'direction' => 'desc',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$issues = [];
|
||||
|
||||
foreach ((array) $response->json() as $issue) {
|
||||
// The issues endpoint returns pull requests too; they are not
|
||||
// something a newcomer picks up.
|
||||
if (isset($issue['pull_request'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$labels = array_values(array_map(
|
||||
fn (array $label) => (string) $label['name'],
|
||||
(array) ($issue['labels'] ?? []),
|
||||
));
|
||||
|
||||
$isGoodFirst = IssueSnapshot::matchesLabelGroup($labels, 'good_first_issue');
|
||||
$isHelpWanted = IssueSnapshot::matchesLabelGroup($labels, 'help_wanted');
|
||||
|
||||
// Keep only the issues that serve the purpose. Mirroring every
|
||||
// open issue would turn the site into a noisy tracker.
|
||||
if (! $isGoodFirst && ! $isHelpWanted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$issues[] = new IssueSnapshot(
|
||||
number: (int) $issue['number'],
|
||||
title: (string) $issue['title'],
|
||||
htmlUrl: (string) $issue['html_url'],
|
||||
labels: $labels,
|
||||
isGoodFirstIssue: $isGoodFirst,
|
||||
isHelpWanted: $isHelpWanted,
|
||||
author: $issue['user']['login'] ?? null,
|
||||
assignee: $issue['assignee']['login'] ?? null,
|
||||
comments: (int) ($issue['comments'] ?? 0),
|
||||
createdAt: $this->date($issue['created_at'] ?? null),
|
||||
updatedAt: $this->date($issue['updated_at'] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
return array_slice($issues, 0, (int) config('git.max_issues_per_repository', 25));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
protected function fetchLanguages(string $fullName): array
|
||||
{
|
||||
$response = $this->get("/repos/{$fullName}/languages");
|
||||
|
||||
return $response->successful() ? (array) $response->json() : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub does not expose a contributor count directly. Asking for a single
|
||||
* contributor per page turns the count into the last page number, which
|
||||
* costs one cheap request instead of paging through everyone.
|
||||
*/
|
||||
protected function fetchContributorCount(string $fullName): int
|
||||
{
|
||||
$response = $this->get("/repos/{$fullName}/contributors", [
|
||||
'per_page' => 1,
|
||||
'anon' => 'true',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($link = $response->header('Link')) {
|
||||
if (preg_match('/[?&]page=(\d+)>;\s*rel="last"/', $link, $matches)) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
// No Link header means the results fit on one page.
|
||||
return count((array) $response->json());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
protected function get(string $path, array $query = []): Response
|
||||
{
|
||||
try {
|
||||
return $this->request()->get($this->apiUrl.$path, $query);
|
||||
} catch (ConnectionException $e) {
|
||||
throw GitException::unreachable('github', $e);
|
||||
}
|
||||
}
|
||||
|
||||
protected function request(): PendingRequest
|
||||
{
|
||||
$request = Http::withHeaders([
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version' => '2022-11-28',
|
||||
])
|
||||
->timeout((int) config('git.http_timeout', 15))
|
||||
->retry(2, 300, throw: false);
|
||||
|
||||
return filled($this->token) ? $request->withToken($this->token) : $request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a bare "repo" into "owner/repo" using the configured organisation.
|
||||
*/
|
||||
protected function qualify(string $fullName): string
|
||||
{
|
||||
if (str_contains($fullName, '/') || blank($this->defaultOwner)) {
|
||||
return trim($fullName, '/');
|
||||
}
|
||||
|
||||
return "{$this->defaultOwner}/{$fullName}";
|
||||
}
|
||||
|
||||
protected function assertNotRateLimited(Response $response): void
|
||||
{
|
||||
$remaining = $response->header('X-RateLimit-Remaining');
|
||||
|
||||
if ($response->status() === 403 && $remaining === '0') {
|
||||
$reset = (int) $response->header('X-RateLimit-Reset');
|
||||
|
||||
throw GitException::rateLimited($reset > 0 ? max(0, $reset - time()) : null);
|
||||
}
|
||||
}
|
||||
|
||||
protected function assertSuccessful(Response $response, string $fullName): void
|
||||
{
|
||||
if ($response->failed()) {
|
||||
throw new GitException(
|
||||
"GitHub answered {$response->status()} for [{$fullName}].",
|
||||
status: $response->status(),
|
||||
// 5xx is the provider's problem and worth retrying; 4xx is ours.
|
||||
isTransient: $response->serverError(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function date(?string $value): ?CarbonImmutable
|
||||
{
|
||||
return filled($value) ? CarbonImmutable::parse($value) : null;
|
||||
}
|
||||
}
|
||||
104
app/Services/Git/GitManager.php
Normal file
104
app/Services/Git/GitManager.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Git;
|
||||
|
||||
use App\Enums\GitProvider;
|
||||
use App\Models\Repository;
|
||||
use App\Services\Git\Contracts\RepositoryProvider;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Resolves the right driver for a repository and applies what it returns.
|
||||
*
|
||||
* This is the only class that both knows about drivers and touches models,
|
||||
* which keeps the drivers free of persistence concerns and the models free of
|
||||
* HTTP concerns.
|
||||
*/
|
||||
class GitManager
|
||||
{
|
||||
/** @var array<string, RepositoryProvider> */
|
||||
protected array $drivers = [];
|
||||
|
||||
public function driver(GitProvider|string $provider): RepositoryProvider
|
||||
{
|
||||
$key = $provider instanceof GitProvider ? $provider->value : $provider;
|
||||
|
||||
if (isset($this->drivers[$key])) {
|
||||
return $this->drivers[$key];
|
||||
}
|
||||
|
||||
$config = config("git.providers.{$key}");
|
||||
|
||||
if (! is_array($config) || ! isset($config['driver'])) {
|
||||
throw GitException::unsupportedProvider($key);
|
||||
}
|
||||
|
||||
/** @var class-string $class */
|
||||
$class = $config['driver'];
|
||||
|
||||
return $this->drivers[$key] = $class::fromConfig($config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh one repository from its provider.
|
||||
*
|
||||
* Both outcomes are recorded: a success clears any previous error, and a
|
||||
* failure increments a counter so the panel can flag repositories that
|
||||
* have been broken for a while instead of quietly showing stale numbers.
|
||||
*/
|
||||
public function sync(Repository $repository): Repository
|
||||
{
|
||||
try {
|
||||
$snapshot = $this->driver($repository->provider)->fetch($repository->full_name);
|
||||
} catch (GitException $e) {
|
||||
$repository->forceFill([
|
||||
'sync_error' => $e->getMessage(),
|
||||
'sync_failures' => $repository->sync_failures + 1,
|
||||
'last_synced_at' => $repository->last_synced_at,
|
||||
])->save();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($repository, $snapshot): void {
|
||||
$repository->forceFill([
|
||||
...$snapshot->toAttributes(),
|
||||
'full_name' => $snapshot->fullName,
|
||||
'sync_error' => null,
|
||||
'sync_failures' => 0,
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->syncIssues($repository, $snapshot);
|
||||
});
|
||||
|
||||
return $repository->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the mirrored issues with what the provider currently reports.
|
||||
*
|
||||
* Issues that were closed or had their label removed upstream must
|
||||
* disappear here too — an "open" issue on the site that is actually closed
|
||||
* wastes a would-be contributor's time.
|
||||
*/
|
||||
protected function syncIssues(Repository $repository, RepositorySnapshot $snapshot): void
|
||||
{
|
||||
$seen = [];
|
||||
|
||||
foreach ($snapshot->issues as $issue) {
|
||||
$repository->issues()->updateOrCreate(
|
||||
['number' => $issue->number],
|
||||
$issue->toAttributes(),
|
||||
);
|
||||
|
||||
$seen[] = $issue->number;
|
||||
}
|
||||
|
||||
$repository->issues()
|
||||
->when($seen !== [], fn ($q) => $q->whereNotIn('number', $seen))
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
295
app/Services/Git/GiteaProvider.php
Normal file
295
app/Services/Git/GiteaProvider.php
Normal file
@@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Git;
|
||||
|
||||
use App\Services\Git\Contracts\RepositoryProvider;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/**
|
||||
* Reads repositories from a self-hosted Gitea instance.
|
||||
*
|
||||
* Gitea's REST API is modelled on GitHub's but is not identical, and the
|
||||
* differences that matter are handled here rather than leaking outwards:
|
||||
*
|
||||
* - Counters are named `stars_count` / `forks_count`, not `stargazers_count`.
|
||||
* - Topics live on their own endpoint instead of the repository payload.
|
||||
* - Issues accept `type=issues`, which filters out pull requests server-side.
|
||||
* - There is no contributors endpoint, so the count is derived from the
|
||||
* distinct authors of recent commits (see contributorCount()).
|
||||
* - Authentication uses the `token` scheme rather than `Bearer`.
|
||||
*/
|
||||
class GiteaProvider implements RepositoryProvider
|
||||
{
|
||||
/**
|
||||
* How many recent commits to inspect when approximating the contributor
|
||||
* count. One request, and enough history to be representative without
|
||||
* walking the whole log.
|
||||
*/
|
||||
protected const COMMIT_SAMPLE = 100;
|
||||
|
||||
public function __construct(
|
||||
protected string $baseUrl,
|
||||
protected ?string $token = null,
|
||||
protected ?string $defaultOwner = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
*/
|
||||
public static function fromConfig(array $config): self
|
||||
{
|
||||
$url = rtrim((string) ($config['url'] ?? ''), '/');
|
||||
|
||||
return new self(
|
||||
// Gitea always serves its API under /api/v1 of the instance root.
|
||||
baseUrl: $url === '' ? '' : $url.'/api/v1',
|
||||
token: $config['token'] ?? null,
|
||||
defaultOwner: $config['default_owner'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlike a hosted service, a self-hosted instance has no default address:
|
||||
* without GITEA_URL there is nothing to talk to.
|
||||
*/
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled($this->baseUrl);
|
||||
}
|
||||
|
||||
public function fetch(string $fullName): RepositorySnapshot
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
throw new GitException(
|
||||
'The Gitea instance is not configured: set GITEA_URL to your instance root, e.g. https://git.example.org.'
|
||||
);
|
||||
}
|
||||
|
||||
$fullName = $this->qualify($fullName);
|
||||
|
||||
$response = $this->get("/repos/{$fullName}");
|
||||
|
||||
if ($response->status() === 404) {
|
||||
throw GitException::notFound($fullName);
|
||||
}
|
||||
|
||||
// A private repository on a self-hosted instance answers 403 without a
|
||||
// token, which is a configuration problem rather than a missing repo.
|
||||
if ($response->status() === 403) {
|
||||
throw new GitException(
|
||||
"Gitea denied access to [{$fullName}]. If it is private, set GITEA_TOKEN to a token that can read it.",
|
||||
status: 403,
|
||||
);
|
||||
}
|
||||
|
||||
$this->assertSuccessful($response, $fullName);
|
||||
|
||||
$data = (array) $response->json();
|
||||
|
||||
return new RepositorySnapshot(
|
||||
fullName: (string) ($data['full_name'] ?? $fullName),
|
||||
htmlUrl: $data['html_url'] ?? null,
|
||||
description: filled($data['description'] ?? null) ? $data['description'] : null,
|
||||
homepage: filled($data['website'] ?? null) ? $data['website'] : null,
|
||||
defaultBranch: $data['default_branch'] ?? null,
|
||||
primaryLanguage: filled($data['language'] ?? null) ? $data['language'] : null,
|
||||
// Gitea does not report a licence in the repository payload.
|
||||
license: null,
|
||||
topics: $this->topics($fullName, $data),
|
||||
languages: $this->languages($fullName),
|
||||
stars: (int) ($data['stars_count'] ?? 0),
|
||||
forks: (int) ($data['forks_count'] ?? 0),
|
||||
watchers: (int) ($data['watchers_count'] ?? 0),
|
||||
openIssuesCount: (int) ($data['open_issues_count'] ?? 0),
|
||||
contributorsCount: $this->contributorCount($fullName),
|
||||
commitsLastYear: 0,
|
||||
isArchived: (bool) ($data['archived'] ?? false),
|
||||
isFork: (bool) ($data['fork'] ?? false),
|
||||
// Gitea has no `pushed_at`; `updated_at` moves on push.
|
||||
pushedAt: $this->date($data['updated_at'] ?? null),
|
||||
createdAt: $this->date($data['created_at'] ?? null),
|
||||
issues: $this->issues($fullName),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open issues worth showing a newcomer.
|
||||
*
|
||||
* @return array<int, IssueSnapshot>
|
||||
*/
|
||||
protected function issues(string $fullName): array
|
||||
{
|
||||
$response = $this->get("/repos/{$fullName}/issues", [
|
||||
'state' => 'open',
|
||||
// Gitea can exclude pull requests server-side, unlike GitHub.
|
||||
'type' => 'issues',
|
||||
'limit' => 100,
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$issues = [];
|
||||
|
||||
foreach ((array) $response->json() as $issue) {
|
||||
$labels = array_values(array_map(
|
||||
fn (array $label) => (string) ($label['name'] ?? ''),
|
||||
(array) ($issue['labels'] ?? []),
|
||||
));
|
||||
|
||||
$isGoodFirst = IssueSnapshot::matchesLabelGroup($labels, 'good_first_issue');
|
||||
$isHelpWanted = IssueSnapshot::matchesLabelGroup($labels, 'help_wanted');
|
||||
|
||||
// Mirroring every open issue would turn the site into a noisy
|
||||
// tracker; only the ones that are an invitation are kept.
|
||||
if (! $isGoodFirst && ! $isHelpWanted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$issues[] = new IssueSnapshot(
|
||||
number: (int) $issue['number'],
|
||||
title: (string) $issue['title'],
|
||||
htmlUrl: (string) ($issue['html_url'] ?? ''),
|
||||
labels: $labels,
|
||||
isGoodFirstIssue: $isGoodFirst,
|
||||
isHelpWanted: $isHelpWanted,
|
||||
author: $issue['user']['login'] ?? null,
|
||||
// Gitea exposes `assignees` as well as the legacy `assignee`.
|
||||
assignee: $issue['assignee']['login']
|
||||
?? ($issue['assignees'][0]['login'] ?? null),
|
||||
comments: (int) ($issue['comments'] ?? 0),
|
||||
createdAt: $this->date($issue['created_at'] ?? null),
|
||||
updatedAt: $this->date($issue['updated_at'] ?? null),
|
||||
);
|
||||
}
|
||||
|
||||
return array_slice($issues, 0, (int) config('git.max_issues_per_repository', 25));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $repo
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function topics(string $fullName, array $repo): array
|
||||
{
|
||||
// Some Gitea versions do include topics inline; prefer that over a
|
||||
// second request.
|
||||
if (is_array($repo['topics'] ?? null) && $repo['topics'] !== []) {
|
||||
return array_values(array_map('strval', $repo['topics']));
|
||||
}
|
||||
|
||||
$response = $this->get("/repos/{$fullName}/topics");
|
||||
|
||||
if ($response->failed()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_map('strval', (array) ($response->json('topics') ?? [])));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
protected function languages(string $fullName): array
|
||||
{
|
||||
$response = $this->get("/repos/{$fullName}/languages");
|
||||
|
||||
return $response->successful() ? array_map('intval', (array) $response->json()) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea exposes no contributors endpoint, so this counts the distinct
|
||||
* authors of the most recent commits.
|
||||
*
|
||||
* It is an approximation by design: for a small collective's repositories
|
||||
* the recent history covers everyone who is actually involved, and it
|
||||
* costs one request instead of walking the entire log.
|
||||
*/
|
||||
protected function contributorCount(string $fullName): int
|
||||
{
|
||||
$response = $this->get("/repos/{$fullName}/commits", [
|
||||
'limit' => self::COMMIT_SAMPLE,
|
||||
'stat' => 'false',
|
||||
'verification' => 'false',
|
||||
'files' => 'false',
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$authors = [];
|
||||
|
||||
foreach ((array) $response->json() as $commit) {
|
||||
// Prefer the Gitea account; fall back to the raw commit email so
|
||||
// contributors without an account still count once each.
|
||||
$identity = $commit['author']['login']
|
||||
?? $commit['commit']['author']['email']
|
||||
?? null;
|
||||
|
||||
if (filled($identity)) {
|
||||
$authors[mb_strtolower((string) $identity)] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return count($authors);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $query
|
||||
*/
|
||||
protected function get(string $path, array $query = []): Response
|
||||
{
|
||||
try {
|
||||
return $this->request()->get($this->baseUrl.$path, $query);
|
||||
} catch (ConnectionException $e) {
|
||||
throw GitException::unreachable('gitea', $e);
|
||||
}
|
||||
}
|
||||
|
||||
protected function request(): PendingRequest
|
||||
{
|
||||
$request = Http::acceptJson()
|
||||
->timeout((int) config('git.http_timeout', 15))
|
||||
->retry(2, 300, throw: false);
|
||||
|
||||
// Gitea's own scheme. It also accepts Bearer on recent versions, but
|
||||
// `token` works across every release we might meet.
|
||||
return filled($this->token)
|
||||
? $request->withHeaders(['Authorization' => 'token '.$this->token])
|
||||
: $request;
|
||||
}
|
||||
|
||||
protected function qualify(string $fullName): string
|
||||
{
|
||||
if (str_contains($fullName, '/') || blank($this->defaultOwner)) {
|
||||
return trim($fullName, '/');
|
||||
}
|
||||
|
||||
return "{$this->defaultOwner}/{$fullName}";
|
||||
}
|
||||
|
||||
protected function assertSuccessful(Response $response, string $fullName): void
|
||||
{
|
||||
if ($response->failed()) {
|
||||
throw new GitException(
|
||||
"Gitea answered {$response->status()} for [{$fullName}].",
|
||||
status: $response->status(),
|
||||
// A self-hosted instance being down is temporary; a 4xx is not.
|
||||
isTransient: $response->serverError(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function date(?string $value): ?CarbonImmutable
|
||||
{
|
||||
return filled($value) ? CarbonImmutable::parse($value) : null;
|
||||
}
|
||||
}
|
||||
62
app/Services/Git/IssueSnapshot.php
Normal file
62
app/Services/Git/IssueSnapshot.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Git;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* A provider-neutral open issue.
|
||||
*/
|
||||
final readonly class IssueSnapshot
|
||||
{
|
||||
public function __construct(
|
||||
public int $number,
|
||||
public string $title,
|
||||
public string $htmlUrl,
|
||||
/** @var array<int, string> */
|
||||
public array $labels = [],
|
||||
public bool $isGoodFirstIssue = false,
|
||||
public bool $isHelpWanted = false,
|
||||
public ?string $author = null,
|
||||
public ?string $assignee = null,
|
||||
public int $comments = 0,
|
||||
public ?CarbonImmutable $createdAt = null,
|
||||
public ?CarbonImmutable $updatedAt = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Decide whether a label set marks an issue as newcomer-friendly, using
|
||||
* the spellings configured in config/git.php.
|
||||
*
|
||||
* @param array<int, string> $labels
|
||||
*/
|
||||
public static function matchesLabelGroup(array $labels, string $group): bool
|
||||
{
|
||||
$wanted = array_map('mb_strtolower', config("git.labels.{$group}", []));
|
||||
$present = array_map('mb_strtolower', $labels);
|
||||
|
||||
return array_intersect($wanted, $present) !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toAttributes(): array
|
||||
{
|
||||
return [
|
||||
'number' => $this->number,
|
||||
'title' => $this->title,
|
||||
'html_url' => $this->htmlUrl,
|
||||
'labels' => $this->labels,
|
||||
'is_good_first_issue' => $this->isGoodFirstIssue,
|
||||
'is_help_wanted' => $this->isHelpWanted,
|
||||
'author' => $this->author,
|
||||
'assignee' => $this->assignee,
|
||||
'comments' => $this->comments,
|
||||
'issue_created_at' => $this->createdAt,
|
||||
'issue_updated_at' => $this->updatedAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
71
app/Services/Git/RepositorySnapshot.php
Normal file
71
app/Services/Git/RepositorySnapshot.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Git;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* A provider-neutral picture of a repository at one moment in time.
|
||||
*
|
||||
* Every driver returns one of these, so the models, jobs and UI never learn
|
||||
* the shape of any particular provider's JSON.
|
||||
*/
|
||||
final readonly class RepositorySnapshot
|
||||
{
|
||||
public function __construct(
|
||||
public string $fullName,
|
||||
public ?string $htmlUrl = null,
|
||||
public ?string $description = null,
|
||||
public ?string $homepage = null,
|
||||
public ?string $defaultBranch = null,
|
||||
public ?string $primaryLanguage = null,
|
||||
public ?string $license = null,
|
||||
/** @var array<int, string> */
|
||||
public array $topics = [],
|
||||
/** @var array<string, int> Bytes of code per language. */
|
||||
public array $languages = [],
|
||||
public int $stars = 0,
|
||||
public int $forks = 0,
|
||||
public int $watchers = 0,
|
||||
public int $openIssuesCount = 0,
|
||||
public int $contributorsCount = 0,
|
||||
public int $commitsLastYear = 0,
|
||||
public bool $isArchived = false,
|
||||
public bool $isFork = false,
|
||||
public ?CarbonImmutable $pushedAt = null,
|
||||
public ?CarbonImmutable $createdAt = null,
|
||||
/** @var array<int, IssueSnapshot> */
|
||||
public array $issues = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The columns this snapshot writes onto a Repository model.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toAttributes(): array
|
||||
{
|
||||
return [
|
||||
'html_url' => $this->htmlUrl,
|
||||
'description' => $this->description,
|
||||
'homepage' => $this->homepage,
|
||||
'default_branch' => $this->defaultBranch,
|
||||
'primary_language' => $this->primaryLanguage,
|
||||
'license' => $this->license,
|
||||
'topics' => $this->topics,
|
||||
'languages' => $this->languages,
|
||||
'stars' => $this->stars,
|
||||
'forks' => $this->forks,
|
||||
'watchers' => $this->watchers,
|
||||
'open_issues_count' => $this->openIssuesCount,
|
||||
'contributors_count' => $this->contributorsCount,
|
||||
'commits_last_year' => $this->commitsLastYear,
|
||||
'is_archived' => $this->isArchived,
|
||||
'is_fork' => $this->isFork,
|
||||
'pushed_at' => $this->pushedAt,
|
||||
'repo_created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
318
app/Services/Media/ImageProcessor.php
Normal file
318
app/Services/Media/ImageProcessor.php
Normal file
@@ -0,0 +1,318 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Media;
|
||||
|
||||
use App\Models\Media;
|
||||
use GdImage;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Turns an uploaded file into everything the frontend needs to render it well:
|
||||
* its real dimensions, a set of narrower versions in modern formats, and a
|
||||
* placeholder to paint while the real thing loads.
|
||||
*
|
||||
* Built on GD, which ships with essentially every PHP, rather than on an image
|
||||
* library the project would have to carry. Formats are asked for rather than
|
||||
* assumed — a runtime without AVIF simply produces WebP and says so.
|
||||
*/
|
||||
class ImageProcessor
|
||||
{
|
||||
/**
|
||||
* Fill in everything derivable about a stored file.
|
||||
*
|
||||
* Never throws: a failure is recorded on the row and the image keeps
|
||||
* working as a plain one, because half an image is better than a page
|
||||
* that will not render.
|
||||
*/
|
||||
public function process(Media $media): void
|
||||
{
|
||||
try {
|
||||
$this->run($media);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
$media->forceFill([
|
||||
'error' => $e->getMessage(),
|
||||
'processed_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
protected function run(Media $media): void
|
||||
{
|
||||
$disk = Storage::disk($media->disk);
|
||||
|
||||
if (! $disk->exists($media->path)) {
|
||||
throw new RuntimeException("Media file is missing: {$media->disk}:{$media->path}");
|
||||
}
|
||||
|
||||
$contents = (string) $disk->get($media->path);
|
||||
$mime = $disk->mimeType($media->path) ?: null;
|
||||
|
||||
$attributes = [
|
||||
'mime' => $mime,
|
||||
'size' => strlen($contents),
|
||||
'variants' => [],
|
||||
'placeholder' => null,
|
||||
'error' => null,
|
||||
'processed_at' => now(),
|
||||
];
|
||||
|
||||
// A vector or an animated GIF is copied through: resampling the first
|
||||
// loses the point of it, and the second loses the animation.
|
||||
if (in_array($mime, (array) config('media.passthrough_mimes'), true)) {
|
||||
[$width, $height] = $this->passthroughDimensions($mime, $contents);
|
||||
|
||||
$media->forceFill([...$attributes, 'width' => $width, 'height' => $height])->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$size = @getimagesizefromstring($contents);
|
||||
|
||||
if ($size === false) {
|
||||
throw new RuntimeException("Not a readable image: {$media->path}");
|
||||
}
|
||||
|
||||
[$width, $height] = [(int) $size[0], (int) $size[1]];
|
||||
|
||||
// Above the ceiling the dimensions are still recorded — they are what
|
||||
// stops the page reflowing — but nothing is resampled. A 100-megapixel
|
||||
// upload is nearly always a mistake, and resizing one costs more than
|
||||
// a small panel is worth.
|
||||
if ($width * $height > (int) config('media.max_pixels', 50_000_000)) {
|
||||
$media->forceFill([...$attributes, 'width' => $width, 'height' => $height])->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$image = @imagecreatefromstring($contents);
|
||||
|
||||
if (! $image instanceof GdImage) {
|
||||
throw new RuntimeException("GD could not read: {$media->path}");
|
||||
}
|
||||
|
||||
// A photo taken sideways carries its rotation in EXIF rather than in
|
||||
// the pixels, and everything downstream would inherit the mistake.
|
||||
[$image, $width, $height] = $this->applyOrientation($image, $contents, $width, $height);
|
||||
|
||||
$this->preserveTransparency($image);
|
||||
|
||||
try {
|
||||
$media->deleteDerived();
|
||||
|
||||
$media->forceFill([
|
||||
...$attributes,
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'variants' => $this->variants($media, $image, $width),
|
||||
'placeholder' => $this->placeholder($image, $width, $height),
|
||||
])->save();
|
||||
} finally {
|
||||
imagedestroy($image);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One file per width and format, never wider than the original.
|
||||
*
|
||||
* @return array<int, array{format: string, width: int, path: string, size: int}>
|
||||
*/
|
||||
protected function variants(Media $media, GdImage $image, int $width): array
|
||||
{
|
||||
$disk = Storage::disk($media->disk);
|
||||
$formats = array_filter((array) config('media.formats'), $this->canEncode(...));
|
||||
|
||||
$variants = [];
|
||||
|
||||
foreach ((array) config('media.widths') as $target) {
|
||||
$target = (int) $target;
|
||||
|
||||
// Never upscale: a 900px original must not be republished as a
|
||||
// blurry "1600px" version.
|
||||
if ($target >= $width) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$scaled = imagescale($image, $target);
|
||||
|
||||
if (! $scaled instanceof GdImage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->preserveTransparency($scaled);
|
||||
|
||||
foreach ($formats as $format) {
|
||||
$encoded = $this->encode($scaled, $format);
|
||||
|
||||
if ($encoded === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $this->variantPath($media->path, $target, $format);
|
||||
$disk->put($path, $encoded);
|
||||
|
||||
$variants[] = [
|
||||
'format' => $format,
|
||||
'width' => $target,
|
||||
'path' => $path,
|
||||
'size' => strlen($encoded),
|
||||
];
|
||||
}
|
||||
|
||||
imagedestroy($scaled);
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tiny blurred version, inlined as a data URI.
|
||||
*
|
||||
* It travels inside every JSON response that mentions the image, so it is
|
||||
* kept to a few hundred bytes on purpose.
|
||||
*/
|
||||
protected function placeholder(GdImage $image, int $width, int $height): ?string
|
||||
{
|
||||
if (! config('media.placeholder.enabled', true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$target = min((int) config('media.placeholder.width', 20), $width);
|
||||
$scaled = imagescale($image, $target);
|
||||
|
||||
if (! $scaled instanceof GdImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->preserveTransparency($scaled);
|
||||
|
||||
$format = $this->canEncode('webp') ? 'webp' : 'jpeg';
|
||||
$encoded = $this->encode($scaled, $format, quality: 40);
|
||||
|
||||
imagedestroy($scaled);
|
||||
|
||||
if ($encoded === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "data:image/{$format};base64,".base64_encode($encoded);
|
||||
}
|
||||
|
||||
protected function encode(GdImage $image, string $format, ?int $quality = null): ?string
|
||||
{
|
||||
$quality ??= (int) config("media.quality.{$format}", 80);
|
||||
|
||||
ob_start();
|
||||
|
||||
$ok = match ($format) {
|
||||
'webp' => imagewebp($image, null, $quality),
|
||||
'avif' => imageavif($image, null, $quality),
|
||||
'jpeg' => imagejpeg($image, null, $quality),
|
||||
default => false,
|
||||
};
|
||||
|
||||
$contents = (string) ob_get_clean();
|
||||
|
||||
return $ok && $contents !== '' ? $contents : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this PHP can actually write the format, rather than whether the
|
||||
* configuration would like it to.
|
||||
*/
|
||||
protected function canEncode(string $format): bool
|
||||
{
|
||||
return match ($format) {
|
||||
'webp' => function_exists('imagewebp'),
|
||||
'avif' => function_exists('imageavif'),
|
||||
'jpeg' => function_exists('imagejpeg'),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `projects/cover.jpg` at 800px in WebP becomes `projects/cover-800.webp`,
|
||||
* which keeps derived files next to their original and makes them easy to
|
||||
* recognise and to clean up.
|
||||
*/
|
||||
protected function variantPath(string $path, int $width, string $format): string
|
||||
{
|
||||
$directory = trim(pathinfo($path, PATHINFO_DIRNAME), '.');
|
||||
$name = pathinfo($path, PATHINFO_FILENAME);
|
||||
|
||||
return ltrim("{$directory}/{$name}-{$width}.{$format}", '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* PNGs with transparency turn black when resampled without this.
|
||||
*/
|
||||
protected function preserveTransparency(GdImage $image): void
|
||||
{
|
||||
imagealphablending($image, false);
|
||||
imagesavealpha($image, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: GdImage, 1: int, 2: int}
|
||||
*/
|
||||
protected function applyOrientation(GdImage $image, string $contents, int $width, int $height): array
|
||||
{
|
||||
if (! function_exists('exif_read_data')) {
|
||||
return [$image, $width, $height];
|
||||
}
|
||||
|
||||
$exif = @exif_read_data('data://image/jpeg;base64,'.base64_encode($contents));
|
||||
$orientation = (int) ($exif['Orientation'] ?? 0);
|
||||
|
||||
$degrees = match ($orientation) {
|
||||
3 => 180,
|
||||
6 => 270,
|
||||
8 => 90,
|
||||
default => 0,
|
||||
};
|
||||
|
||||
if ($degrees === 0) {
|
||||
return [$image, $width, $height];
|
||||
}
|
||||
|
||||
$rotated = imagerotate($image, $degrees, 0);
|
||||
|
||||
if (! $rotated instanceof GdImage) {
|
||||
return [$image, $width, $height];
|
||||
}
|
||||
|
||||
imagedestroy($image);
|
||||
|
||||
return [$rotated, imagesx($rotated), imagesy($rotated)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dimensions for the formats that are copied through. An SVG carries them
|
||||
* in its markup, which is worth reading: without a size the frontend
|
||||
* cannot reserve the box either.
|
||||
*
|
||||
* @return array{0: ?int, 1: ?int}
|
||||
*/
|
||||
protected function passthroughDimensions(?string $mime, string $contents): array
|
||||
{
|
||||
if ($mime !== 'image/svg+xml') {
|
||||
$size = @getimagesizefromstring($contents);
|
||||
|
||||
return $size === false ? [null, null] : [(int) $size[0], (int) $size[1]];
|
||||
}
|
||||
|
||||
if (preg_match('/viewBox\s*=\s*["\']\s*[\d.+-]+[,\s]+[\d.+-]+[,\s]+([\d.]+)[,\s]+([\d.]+)/i', $contents, $m)) {
|
||||
return [(int) round((float) $m[1]), (int) round((float) $m[2])];
|
||||
}
|
||||
|
||||
$width = preg_match('/<svg[^>]*\swidth\s*=\s*["\']([\d.]+)/i', $contents, $m) ? (int) round((float) $m[1]) : null;
|
||||
$height = preg_match('/<svg[^>]*\sheight\s*=\s*["\']([\d.]+)/i', $contents, $m) ? (int) round((float) $m[1]) : null;
|
||||
|
||||
return [$width, $height];
|
||||
}
|
||||
}
|
||||
86
app/Services/Pireon/AccessToken.php
Normal file
86
app/Services/Pireon/AccessToken.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pireon;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
|
||||
/**
|
||||
* A token set returned by the identity server's /oauth/token endpoint.
|
||||
*
|
||||
* @implements Arrayable<string, mixed>
|
||||
*/
|
||||
final readonly class AccessToken implements Arrayable
|
||||
{
|
||||
public function __construct(
|
||||
public string $accessToken,
|
||||
public ?string $refreshToken,
|
||||
public ?CarbonImmutable $expiresAt,
|
||||
public string $tokenType = 'Bearer',
|
||||
/** @var array<int, string> */
|
||||
public array $scopes = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function fromResponse(array $payload): self
|
||||
{
|
||||
$expiresIn = isset($payload['expires_in']) ? (int) $payload['expires_in'] : null;
|
||||
|
||||
return new self(
|
||||
accessToken: (string) $payload['access_token'],
|
||||
refreshToken: isset($payload['refresh_token']) ? (string) $payload['refresh_token'] : null,
|
||||
// Shave a minute off so we refresh slightly before the server
|
||||
// would start rejecting the token mid-request.
|
||||
expiresAt: $expiresIn !== null ? CarbonImmutable::now()->addSeconds($expiresIn)->subMinute() : null,
|
||||
tokenType: (string) ($payload['token_type'] ?? 'Bearer'),
|
||||
scopes: array_values(array_filter(explode(' ', (string) ($payload['scope'] ?? '')))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $state
|
||||
*/
|
||||
public static function fromArray(array $state): self
|
||||
{
|
||||
return new self(
|
||||
accessToken: (string) $state['access_token'],
|
||||
refreshToken: $state['refresh_token'] ?? null,
|
||||
expiresAt: isset($state['expires_at']) ? CarbonImmutable::parse($state['expires_at']) : null,
|
||||
tokenType: (string) ($state['token_type'] ?? 'Bearer'),
|
||||
scopes: $state['scopes'] ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
public function hasExpired(): bool
|
||||
{
|
||||
return $this->expiresAt !== null && $this->expiresAt->isPast();
|
||||
}
|
||||
|
||||
public function isRefreshable(): bool
|
||||
{
|
||||
return $this->refreshToken !== null;
|
||||
}
|
||||
|
||||
public function hasScope(string $scope): bool
|
||||
{
|
||||
return in_array($scope, $this->scopes, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'access_token' => $this->accessToken,
|
||||
'refresh_token' => $this->refreshToken,
|
||||
'expires_at' => $this->expiresAt?->toIso8601String(),
|
||||
'token_type' => $this->tokenType,
|
||||
'scopes' => $this->scopes,
|
||||
];
|
||||
}
|
||||
}
|
||||
252
app/Services/Pireon/PireonClient.php
Normal file
252
app/Services/Pireon/PireonClient.php
Normal file
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pireon;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* HTTP client for the Pireon OAuth2 identity server (Laravel Passport).
|
||||
*
|
||||
* Covers the three grants the server advertises in its OpenAPI document:
|
||||
* authorization code + PKCE (used by the panel), password (used by tests and
|
||||
* first-party clients), and client credentials (machine-to-machine).
|
||||
*/
|
||||
class PireonClient
|
||||
{
|
||||
public function __construct(
|
||||
protected string $server,
|
||||
protected ?string $clientId,
|
||||
protected ?string $clientSecret,
|
||||
protected string $redirectUri,
|
||||
) {}
|
||||
|
||||
public static function fromConfig(): self
|
||||
{
|
||||
return new self(
|
||||
server: (string) config('pireon.server'),
|
||||
clientId: config('pireon.client_id'),
|
||||
clientSecret: config('pireon.client_secret'),
|
||||
redirectUri: (string) config('pireon.redirect_uri'),
|
||||
);
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled($this->clientId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the URL the browser is sent to, together with the one-time secrets
|
||||
* that must be stashed in the session to complete the flow.
|
||||
*
|
||||
* The verifier is what makes this PKCE: it never leaves our server, and
|
||||
* only its SHA-256 hash travels through the browser. An attacker who
|
||||
* intercepts the returned code cannot redeem it without the verifier.
|
||||
*
|
||||
* @param array<int, string>|null $scopes
|
||||
* @return array{url: string, state: string, code_verifier: string}
|
||||
*/
|
||||
public function authorizationUrl(?array $scopes = null): array
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$state = Str::random(40);
|
||||
$verifier = Str::random(128);
|
||||
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
|
||||
|
||||
$query = http_build_query([
|
||||
'client_id' => $this->clientId,
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'response_type' => 'code',
|
||||
'scope' => implode(' ', $scopes ?? config('pireon.scopes', [])),
|
||||
'state' => $state,
|
||||
'code_challenge' => $challenge,
|
||||
'code_challenge_method' => 'S256',
|
||||
]);
|
||||
|
||||
return [
|
||||
'url' => $this->server.config('pireon.endpoints.authorize').'?'.$query,
|
||||
'state' => $state,
|
||||
'code_verifier' => $verifier,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem an authorization code for tokens.
|
||||
*/
|
||||
public function exchangeCode(string $code, string $codeVerifier): AccessToken
|
||||
{
|
||||
return $this->token([
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $this->redirectUri,
|
||||
'code_verifier' => $codeVerifier,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource owner password grant. Kept for automated tests and first-party
|
||||
* clients that cannot open a browser; gated by `pireon.password_grant_enabled`.
|
||||
*
|
||||
* @param array<int, string>|null $scopes
|
||||
*/
|
||||
public function requestPasswordToken(string $username, string $password, ?array $scopes = null): AccessToken
|
||||
{
|
||||
if (! config('pireon.password_grant_enabled')) {
|
||||
throw new PireonException('The password grant is disabled for this application.', status: 400);
|
||||
}
|
||||
|
||||
return $this->token([
|
||||
'grant_type' => 'password',
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'scope' => implode(' ', $scopes ?? config('pireon.scopes', [])),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Machine-to-machine token representing the application itself rather than
|
||||
* any user. Cached until shortly before it expires.
|
||||
*
|
||||
* @param array<int, string>|null $scopes
|
||||
*/
|
||||
public function clientCredentialsToken(?array $scopes = null): AccessToken
|
||||
{
|
||||
$scopes ??= config('pireon.scopes', []);
|
||||
$key = 'pireon:cc:'.md5($this->clientId.'|'.implode(' ', $scopes));
|
||||
|
||||
$cached = Cache::get($key);
|
||||
|
||||
if (is_array($cached)) {
|
||||
$token = AccessToken::fromArray($cached);
|
||||
|
||||
if (! $token->hasExpired()) {
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
||||
$token = $this->token([
|
||||
'grant_type' => 'client_credentials',
|
||||
'scope' => implode(' ', $scopes),
|
||||
]);
|
||||
|
||||
Cache::put($key, $token->toArray(), $token->expiresAt ?? now()->addMinutes(5));
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
public function refresh(string $refreshToken): AccessToken
|
||||
{
|
||||
return $this->token([
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'scope' => '',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Who does this access token belong to? Doubles as token validation: the
|
||||
* server answers 401 for a token that is expired, revoked, or not tied to
|
||||
* a user (as with client_credentials).
|
||||
*/
|
||||
public function identity(string $accessToken): PireonIdentity
|
||||
{
|
||||
$response = $this->request()
|
||||
->withToken($accessToken)
|
||||
->get($this->apiUrl(config('pireon.endpoints.user')));
|
||||
|
||||
if ($response->status() === 401) {
|
||||
throw new PireonException('The access token is not valid for any user.', status: 401);
|
||||
}
|
||||
|
||||
$this->assertSuccessful($response);
|
||||
|
||||
return PireonIdentity::fromResponse($response->json());
|
||||
}
|
||||
|
||||
/**
|
||||
* Call any endpoint of the identity server's REST API on behalf of a token.
|
||||
* Used by the optional remote-user screens.
|
||||
*
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function call(string $accessToken, string $method, string $path, array $data = []): Response
|
||||
{
|
||||
$response = $this->request()
|
||||
->withToken($accessToken)
|
||||
->send(strtoupper($method), $this->apiUrl($path), [
|
||||
strtoupper($method) === 'GET' ? 'query' : 'json' => $data,
|
||||
]);
|
||||
|
||||
$this->assertSuccessful($response);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
protected function token(array $payload): AccessToken
|
||||
{
|
||||
$this->assertConfigured();
|
||||
|
||||
$payload['client_id'] = $this->clientId;
|
||||
|
||||
// Public clients (PKCE from a browser) have no secret. Confidential
|
||||
// clients must authenticate, so only send it when one is configured.
|
||||
if (filled($this->clientSecret)) {
|
||||
$payload['client_secret'] = $this->clientSecret;
|
||||
}
|
||||
|
||||
$url = $this->server.config('pireon.endpoints.token');
|
||||
|
||||
try {
|
||||
$response = $this->request()->asForm()->post($url, $payload);
|
||||
} catch (ConnectionException $e) {
|
||||
throw PireonException::unreachable($url, $e);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
throw PireonException::tokenRequestFailed($response->status(), (array) $response->json());
|
||||
}
|
||||
|
||||
return AccessToken::fromResponse((array) $response->json());
|
||||
}
|
||||
|
||||
protected function request(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->timeout((int) config('pireon.http_timeout', 10))
|
||||
->retry(2, 200, throw: false);
|
||||
}
|
||||
|
||||
protected function apiUrl(string $path): string
|
||||
{
|
||||
return $this->server.config('pireon.api_prefix').'/'.ltrim($path, '/');
|
||||
}
|
||||
|
||||
protected function assertSuccessful(Response $response): void
|
||||
{
|
||||
if ($response->failed()) {
|
||||
throw new PireonException(
|
||||
"The identity server answered {$response->status()} for this request.",
|
||||
status: $response->status(),
|
||||
context: (array) $response->json(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected function assertConfigured(): void
|
||||
{
|
||||
if (blank($this->clientId)) {
|
||||
throw PireonException::misconfigured('PIREON_CLIENT_ID');
|
||||
}
|
||||
}
|
||||
}
|
||||
80
app/Services/Pireon/PireonException.php
Normal file
80
app/Services/Pireon/PireonException.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pireon;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Raised when the identity server cannot be reached, rejects our credentials,
|
||||
* or answers with something we cannot interpret.
|
||||
*/
|
||||
class PireonException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly ?int $status = null,
|
||||
/** @var array<string, mixed> */
|
||||
public readonly array $context = [],
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $status ?? 0, $previous);
|
||||
}
|
||||
|
||||
public static function misconfigured(string $key): self
|
||||
{
|
||||
return new self(
|
||||
"The Pireon OAuth client is not configured: [{$key}] is missing. ".
|
||||
'Register this app on the identity server and fill in the PIREON_* keys in .env.'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
*/
|
||||
public static function tokenRequestFailed(int $status, array $body): self
|
||||
{
|
||||
$error = $body['error'] ?? 'unknown_error';
|
||||
$description = $body['error_description'] ?? $body['message'] ?? 'no description given';
|
||||
|
||||
return new self(
|
||||
"The identity server rejected the token request [{$error}]: {$description}",
|
||||
status: $status,
|
||||
context: $body,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the identity server actually judged the credentials, as opposed
|
||||
* to never getting far enough to look at them.
|
||||
*
|
||||
* The difference matters to whoever is staring at the screen: "those
|
||||
* credentials are wrong" sends them to check their password, and if the
|
||||
* truth is that the server answered 500 — or did not answer at all — that
|
||||
* is an hour of their life spent on the wrong problem.
|
||||
*/
|
||||
public function isRejection(): bool
|
||||
{
|
||||
return in_array($this->status, [400, 401, 403], true);
|
||||
}
|
||||
|
||||
public static function unreachable(string $url, ?Throwable $previous = null): self
|
||||
{
|
||||
return new self(
|
||||
"The identity server at [{$url}] could not be reached.",
|
||||
context: ['url' => $url],
|
||||
previous: $previous,
|
||||
);
|
||||
}
|
||||
|
||||
public static function invalidState(): self
|
||||
{
|
||||
return new self(
|
||||
'The OAuth callback state did not match the one we issued. '.
|
||||
'The login attempt was discarded; please start again.',
|
||||
status: 400,
|
||||
);
|
||||
}
|
||||
}
|
||||
93
app/Services/Pireon/PireonIdentity.php
Normal file
93
app/Services/Pireon/PireonIdentity.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pireon;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Support\Arrayable;
|
||||
|
||||
/**
|
||||
* The identity server's view of a person, as returned by GET /api/user.
|
||||
*
|
||||
* `roles` and `appRoles` are carried for display and support purposes only.
|
||||
* They deliberately grant nothing: this application resolves authorisation
|
||||
* from its own spatie roles (see App\Actions\Auth\SyncPireonUser).
|
||||
*
|
||||
* @implements Arrayable<string, mixed>
|
||||
*/
|
||||
final readonly class PireonIdentity implements Arrayable
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public string $name,
|
||||
public string $email,
|
||||
public bool $isActive,
|
||||
/** @var array<int, string> */
|
||||
public array $roles = [],
|
||||
/** @var array<int, string> */
|
||||
public array $appRoles = [],
|
||||
public ?CarbonImmutable $approvedAt = null,
|
||||
public ?CarbonImmutable $createdAt = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function fromResponse(array $payload): self
|
||||
{
|
||||
// The server wraps some responses in a "data" envelope and returns
|
||||
// others bare; accept either shape.
|
||||
$payload = $payload['data'] ?? $payload;
|
||||
|
||||
return new self(
|
||||
id: (int) $payload['id'],
|
||||
name: (string) ($payload['name'] ?? ''),
|
||||
email: (string) ($payload['email'] ?? ''),
|
||||
isActive: (bool) ($payload['is_active'] ?? true),
|
||||
roles: array_values(array_map('strval', $payload['roles'] ?? [])),
|
||||
appRoles: array_values(array_map('strval', $payload['app_roles'] ?? [])),
|
||||
approvedAt: isset($payload['approved_at']) ? CarbonImmutable::parse($payload['approved_at']) : null,
|
||||
createdAt: isset($payload['created_at']) ? CarbonImmutable::parse($payload['created_at']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-registered accounts stay unapproved until an administrator on the
|
||||
* identity server approves them.
|
||||
*/
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->approvedAt !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this identity is allowed to open a session here at all, per the
|
||||
* `require_active_account` / `require_approved_account` settings.
|
||||
*/
|
||||
public function isUsable(): bool
|
||||
{
|
||||
if (config('pireon.require_active_account') && ! $this->isActive) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! (config('pireon.require_approved_account') && ! $this->isApproved());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'email' => $this->email,
|
||||
'is_active' => $this->isActive,
|
||||
'roles' => $this->roles,
|
||||
'app_roles' => $this->appRoles,
|
||||
'approved_at' => $this->approvedAt?->toIso8601String(),
|
||||
'created_at' => $this->createdAt?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
81
app/Services/Pireon/TokenStore.php
Normal file
81
app/Services/Pireon/TokenStore.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pireon;
|
||||
|
||||
use Illuminate\Contracts\Session\Session;
|
||||
|
||||
/**
|
||||
* Keeps the signed-in panel user's token set in the session and transparently
|
||||
* refreshes it when it ages out.
|
||||
*
|
||||
* Tokens live in the session rather than the database on purpose: they are
|
||||
* per-session credentials, and logging out or the session expiring should take
|
||||
* them with it.
|
||||
*/
|
||||
class TokenStore
|
||||
{
|
||||
protected const KEY = 'pireon.token';
|
||||
|
||||
public function __construct(
|
||||
protected Session $session,
|
||||
protected PireonClient $client,
|
||||
) {}
|
||||
|
||||
public function put(AccessToken $token): void
|
||||
{
|
||||
$this->session->put(self::KEY, $token->toArray());
|
||||
}
|
||||
|
||||
public function get(): ?AccessToken
|
||||
{
|
||||
$state = $this->session->get(self::KEY);
|
||||
|
||||
return is_array($state) ? AccessToken::fromArray($state) : null;
|
||||
}
|
||||
|
||||
public function forget(): void
|
||||
{
|
||||
$this->session->forget(self::KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* The current access token, refreshed first if it has expired.
|
||||
*
|
||||
* Returns null when there is no token, or when the refresh failed — the
|
||||
* caller should treat that as "this session is no longer authenticated"
|
||||
* and send the user back through the login flow.
|
||||
*/
|
||||
public function accessToken(): ?string
|
||||
{
|
||||
$token = $this->get();
|
||||
|
||||
if ($token === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $token->hasExpired()) {
|
||||
return $token->accessToken;
|
||||
}
|
||||
|
||||
if (! $token->isRefreshable()) {
|
||||
$this->forget();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$refreshed = $this->client->refresh($token->refreshToken);
|
||||
} catch (PireonException) {
|
||||
// The refresh token was revoked or expired upstream.
|
||||
$this->forget();
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->put($refreshed);
|
||||
|
||||
return $refreshed->accessToken;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user