feat: add NetBox plugin store
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
.git
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
vendor/
|
||||
tests/
|
||||
test/
|
||||
.phpunit.cache/
|
||||
coverage/
|
||||
*.log
|
||||
@@ -0,0 +1,59 @@
|
||||
# HTTP / sessions
|
||||
APP_ENV=production
|
||||
STORE_PUBLIC_URL=https://plugins.example.com
|
||||
# Nur aktivieren, wenn direkte Zugriffe per Firewall ausgeschlossen sind und
|
||||
# ausschließlich ein vertrauenswürdiger Proxy X-Forwarded-For setzt.
|
||||
STORE_TRUST_PROXY=false
|
||||
STORE_TRUSTED_PROXY_IPS=127.0.0.1,::1
|
||||
STORE_COOKIE_SECURE=true
|
||||
STORE_SESSION_NAME=netbox_plugin_store
|
||||
|
||||
# Admin: generate with `php bin/console hash-password 'a-long-random-password'`.
|
||||
# There are deliberately no default credentials. All three values are required.
|
||||
STORE_ADMIN_USERNAME=admin
|
||||
STORE_ADMIN_PASSWORD_HASH=$argon2id$REPLACE_WITH_GENERATED_HASH
|
||||
STORE_SESSION_SECRET=replace-with-at-least-32-random-characters
|
||||
STORE_ADMIN_SESSION_TTL=28800
|
||||
STORE_LOGIN_MAX_ATTEMPTS=5
|
||||
STORE_LOGIN_WINDOW_SECONDS=900
|
||||
|
||||
# Storage: zero-setup JSON or MariaDB via PDO. JSON uses flock + fsync + rename.
|
||||
STORE_DB_DRIVER=json
|
||||
STORE_JSON_PATH=./data/store.json
|
||||
STORE_MARIADB_DSN=mysql:host=mariadb;port=3306;dbname=netbox_store;charset=utf8mb4
|
||||
STORE_MARIADB_USER=netbox_store
|
||||
STORE_MARIADB_PASSWORD=replace-me
|
||||
|
||||
# Strict outbound policy. Add internal hosts explicitly and only enable private
|
||||
# networks when the deployment intentionally uses an internal Forgejo.
|
||||
STORE_ALLOWED_SOURCE_HOSTS=git.mrblake.cc,github.com,api.github.com,*.github.com,*.githubusercontent.com
|
||||
STORE_ALLOW_PRIVATE_NETWORKS=false
|
||||
STORE_HTTP_TIMEOUT_SECONDS=20
|
||||
STORE_MAX_METADATA_BYTES=2097152
|
||||
STORE_MAX_ARTIFACT_BYTES=536870912
|
||||
STORE_USER_AGENT=MrBlake-NetBox-Plugin-Store/1.0
|
||||
|
||||
# Zero-setup Forgejo source (created on first web/CLI start).
|
||||
STORE_DEFAULT_PROVIDER=forgejo
|
||||
STORE_DEFAULT_SOURCE_NAME=MrBlake Forgejo
|
||||
STORE_DEFAULT_SOURCE_SLUG=mrblake-forgejo
|
||||
STORE_DEFAULT_BASE_URL=https://git.mrblake.cc
|
||||
STORE_DEFAULT_API_URL=https://git.mrblake.cc/api/v1
|
||||
STORE_DEFAULT_OWNER=MrBlake
|
||||
STORE_DEFAULT_OWNER_KIND=user
|
||||
STORE_DEFAULT_TOPIC=netbox-plugin
|
||||
STORE_DEFAULT_TOKEN_ENV=GITEA_TOKEN
|
||||
GITEA_TOKEN=
|
||||
GITHUB_TOKEN=
|
||||
|
||||
# In-container scheduler. CLI alternative: php bin/console sync --watch.
|
||||
STORE_SCHEDULER_ENABLED=true
|
||||
STORE_SYNC_INTERVAL_SECONDS=900
|
||||
STORE_SYNC_MAX_SECONDS=900
|
||||
STORE_SYNC_MAX_REQUESTS=2500
|
||||
STORE_SYNC_MAX_BYTES=1073741824
|
||||
STORE_SYNC_MAX_REPOSITORIES=2000
|
||||
STORE_SYNC_MAX_RELEASES=1000
|
||||
STORE_PAGE_SIZE=12
|
||||
STORE_API_PAGE_SIZE=50
|
||||
STORE_API_MAX_PAGE_SIZE=100
|
||||
@@ -0,0 +1,7 @@
|
||||
/vendor/
|
||||
/.env
|
||||
/data/*.json
|
||||
/data/*.lock
|
||||
/data/*.tmp-*
|
||||
!/data/.gitkeep
|
||||
composer.phar
|
||||
@@ -0,0 +1,27 @@
|
||||
FROM composer:2 AS vendor
|
||||
WORKDIR /app
|
||||
COPY composer.json composer.lock ./
|
||||
RUN composer install --no-dev --no-interaction --no-progress --prefer-dist --optimize-autoloader \
|
||||
--ignore-platform-req=ext-curl --ignore-platform-req=ext-dom --ignore-platform-req=ext-iconv --ignore-platform-req=ext-intl \
|
||||
--ignore-platform-req=ext-mbstring --ignore-platform-req=ext-pdo
|
||||
|
||||
FROM php:8.4-apache
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libcurl4-openssl-dev libicu-dev libonig-dev libxml2-dev util-linux \
|
||||
&& docker-php-ext-install -j"$(nproc)" curl dom intl mbstring opcache pcntl pdo_mysql \
|
||||
&& a2enmod rewrite headers expires \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY deploy/apache-docker.conf /etc/apache2/sites-available/000-default.conf
|
||||
COPY deploy/php-production.ini /usr/local/etc/php/conf.d/store-production.ini
|
||||
WORKDIR /var/www/html
|
||||
COPY --from=vendor /app/vendor ./vendor
|
||||
COPY . .
|
||||
RUN mkdir -p data \
|
||||
&& chown -R www-data:www-data data \
|
||||
&& chmod +x bin/console docker-entrypoint-store.sh
|
||||
EXPOSE 80
|
||||
VOLUME ["/var/www/html/data"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD php -r '$c=@file_get_contents("http://127.0.0.1/healthz"); if ($c===false) exit(1);'
|
||||
ENTRYPOINT ["/var/www/html/docker-entrypoint-store.sh"]
|
||||
CMD ["apache2-foreground"]
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
# NetBox Plugin Store
|
||||
|
||||
Der Store ist eine eigenständige PHP-Anwendung für einen kuratierten NetBox-Plugin-Katalog. Er liest Repositories von Forgejo/Gitea (standardmäßig `git.mrblake.cc`) ein, erkennt Plugin-Metadaten, rendert die README der exakt synchronisierten Commit-Revision und veröffentlicht erst nach einer getrennten Admin-Freigabe einen API-v1-Katalog.
|
||||
|
||||
Die native Installation mit PHP und Apache ist der primäre Betriebsweg. Docker ist optional.
|
||||
|
||||
## Funktionsumfang
|
||||
|
||||
- responsive deutschsprachige Store-, Such-, Filter- und Plugin-Detailseiten;
|
||||
- vollständige Forgejo-/Gitea- und GitHub-Discovery für Benutzer und Organisationen;
|
||||
- Erkennung über Topic, `netbox-plugin.json|yaml`, `pyproject.toml`, `setup.py` und `PluginConfig`;
|
||||
- commitgenaue Reads für Manifest, Projektmetadaten, Tree, PluginConfig und README;
|
||||
- CommonMark-Rendering ohne eingebettetes HTML; relative Links und Bilder werden auf die Raw-URL des Commit-SHA umgeschrieben;
|
||||
- eigener Admin-Login mit Argon2id, CSRF-Schutz, Session-Härtung und persistentem Login-Rate-Limit;
|
||||
- Freigabe/Ablehnung von Quellen, Plugins und jedem Release-Artefakt; Admin-Metadatenkorrekturen und Audit-Log;
|
||||
- tatsächlicher Download jedes öffentlichen Wheel-Artefakts mit SHA-256 und Größe; sicherheitsrelevante Änderungen setzen die Freigabe zurück;
|
||||
- öffentlicher, freigegebener JSON-Katalog unter `/api/v1/`;
|
||||
- atomar geschriebener JSON-Datastore als Zero-Setup-Standard oder optional MariaDB über PDO;
|
||||
- einmaliger Sync sowie signalverträglicher Watch-Modus ohne Cron/Celery;
|
||||
- pro Source eine prozessübergreifende exklusive Sync-Lease sowie aggregierte Limits für Laufzeit, Requests, Bytes, Repositories und Releases.
|
||||
|
||||
Wichtig: Ein eingelesenes und freigegebenes Repository ist noch nicht automatisch installierbar. API v1 veröffentlicht ausschließlich separat freigegebene `.whl`-Artefakte. Fehlt ein Wheel, bleibt das Plugin als Katalogeintrag sichtbar und `latest_version` ist `null`; die UI zeigt „Kein installierbares Release“.
|
||||
|
||||
## 1. Native Installation (empfohlen)
|
||||
|
||||
### Voraussetzungen
|
||||
|
||||
- PHP 8.3 oder neuer (getestet mit PHP 8.4);
|
||||
- Apache 2.4 mit `mod_rewrite` und `mod_headers`;
|
||||
- Composer 2;
|
||||
- PHP-Erweiterungen: `curl`, `dom`, `iconv`, `intl`, `json`, `mbstring`, `pdo` und für MariaDB zusätzlich `pdo_mysql`; für einen graceful beendbaren dauerhaften `--watch`-Prozess wird `pcntl` empfohlen;
|
||||
- Schreibzugriff des Apache-/Sync-Benutzers auf `store/data`.
|
||||
|
||||
Beispiel für Debian/Ubuntu (Paketnamen können je Distribution/PHP-Repository abweichen):
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install apache2 libapache2-mod-php php-cli php-curl php-xml php-intl php-mbstring php-mysql composer
|
||||
sudo a2enmod rewrite headers
|
||||
```
|
||||
|
||||
### Anwendung installieren
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/netbox-plugin-store
|
||||
sudo chown "$USER":"$USER" /opt/netbox-plugin-store
|
||||
git clone <REPOSITORY-URL> /opt/netbox-plugin-store
|
||||
cd /opt/netbox-plugin-store/store
|
||||
composer install --no-dev --no-interaction --prefer-dist --classmap-authoritative
|
||||
cp .env.example .env
|
||||
mkdir -p data
|
||||
sudo chown -R www-data:www-data data
|
||||
sudo chmod 750 data
|
||||
```
|
||||
|
||||
Der JSON-Pfad in `.env.example` ist relativ zum `store/`-Verzeichnis und funktioniert nativ ohne Änderung:
|
||||
|
||||
```dotenv
|
||||
STORE_DB_DRIVER=json
|
||||
STORE_JSON_PATH=./data/store.json
|
||||
```
|
||||
|
||||
Der Datastore verwendet einen prozessübergreifenden `flock`, schreibt in eine temporäre Datei, synchronisiert sie und ersetzt anschließend die alte Datei atomar. Eine separate nicht-blockierende Lease-Datei hält zusätzlich jeden Source-Sync für seine gesamte Laufzeit exklusiv; ein Prozessabbruch gibt die Kernel-Sperre frei. Verwende ein lokales Dateisystem; geteilte/NFS-Dateisysteme bieten nicht immer die benötigten Lock-/Rename-Garantien. Für mehrere Web-Hosts ist MariaDB die bessere Wahl; dort übernimmt `GET_LOCK` die Sync-Lease.
|
||||
|
||||
### Admin-Zugang sicher konfigurieren
|
||||
|
||||
Es gibt absichtlich keine Standard-Zugangsdaten. Erzeuge einen langen Zufallswert als Session-Secret und einen Argon2id-Hash:
|
||||
|
||||
```bash
|
||||
php -r 'echo bin2hex(random_bytes(32)), PHP_EOL;'
|
||||
php bin/console hash-password 'ein-langes-zufaelliges-passwort'
|
||||
```
|
||||
|
||||
Trage Benutzername, ausgegebenen Hash und Secret vollständig in `.env` ein:
|
||||
|
||||
```dotenv
|
||||
STORE_ADMIN_USERNAME=admin
|
||||
STORE_ADMIN_PASSWORD_HASH=$argon2id$...
|
||||
STORE_SESSION_SECRET=<mindestens-32-zeichen>
|
||||
```
|
||||
|
||||
Sind alle drei Werte leer, bleibt `/admin` deaktiviert. Ist nur ein Teil gesetzt, bricht die Anwendung fail-closed mit einer Konfigurationsmeldung ab. Vermeide Passwörter in der Shell-History; alternativ liest `hash-password` den Wert aus `STORE_ADMIN_PASSWORD_TO_HASH`.
|
||||
|
||||
Setze außerdem die öffentliche URL. Sichere Cookies richten sich standardmäßig nach ihrem Protokoll:
|
||||
|
||||
```dotenv
|
||||
APP_ENV=production
|
||||
STORE_PUBLIC_URL=https://plugins.example.com
|
||||
# Optionaler expliziter Override:
|
||||
STORE_COOKIE_SECURE=true
|
||||
```
|
||||
|
||||
Ein Production-Quickstart über `http://localhost` funktioniert mit `STORE_PUBLIC_URL=http://localhost:3000` und `STORE_COOKIE_SECURE=false`; für einen öffentlichen Betrieb ist HTTPS erforderlich.
|
||||
|
||||
### Apache konfigurieren
|
||||
|
||||
Nur `store/public` darf DocumentRoot sein. Passe Pfad und Servernamen in [deploy/apache-vhost.conf.example](deploy/apache-vhost.conf.example) an:
|
||||
|
||||
```bash
|
||||
sudo cp deploy/apache-vhost.conf.example /etc/apache2/sites-available/netbox-plugin-store.conf
|
||||
sudo editor /etc/apache2/sites-available/netbox-plugin-store.conf
|
||||
sudo a2ensite netbox-plugin-store
|
||||
sudo apache2ctl configtest
|
||||
sudo systemctl reload apache2
|
||||
```
|
||||
|
||||
Die Beispielkonfiguration erlaubt `.htaccess` ausschließlich im Store-DocumentRoot. Alternativ können Rewrite- und Header-Regeln aus `public/.htaccess` direkt in den vHost übernommen und `AllowOverride None` gesetzt werden. Für Produktion HTTPS direkt mit Apache `mod_ssl`/ACME konfigurieren und `STORE_PUBLIC_URL` auf `https://…` setzen; ein Reverse Proxy ist nicht erforderlich.
|
||||
|
||||
Übernimm für den produktiven PHP-SAPI außerdem die sicherheitsrelevanten Werte aus `deploy/php-production.ini` (insbesondere `display_errors=Off`, `log_errors=On` und `expose_php=Off`) in deine PHP-Konfiguration und lade Apache neu. Der Front Controller deaktiviert die Fehlerausgabe zusätzlich selbst.
|
||||
|
||||
### Bootstrap und erster Sync
|
||||
|
||||
```bash
|
||||
cd /opt/netbox-plugin-store/store
|
||||
sudo -u www-data php bin/console bootstrap
|
||||
sudo -u www-data php bin/console sync
|
||||
```
|
||||
|
||||
Wenn noch keine Quelle existiert, legt `bootstrap` (und standardmäßig auch `sync`) aus den `STORE_DEFAULT_*`-Werten eine aktive, freigegebene Forgejo-Quelle an. Standardwerte:
|
||||
|
||||
- Provider `forgejo`;
|
||||
- Basis `https://git.mrblake.cc`;
|
||||
- API `https://git.mrblake.cc/api/v1`;
|
||||
- Benutzer `MrBlake` – entsprechend `/api/v1/users/MrBlake/repos`.
|
||||
|
||||
Mit `--no-bootstrap` wird die automatische Anlage unterdrückt. Einzelne Quellen lassen sich mit `--source=slug` synchronisieren; `--fail-fast` beendet den Lauf beim ersten Repositoryfehler.
|
||||
|
||||
Jeder Source-Lauf ist mit konservativen, konfigurierbaren Gesamtbudgets begrenzt. Eine Überschreitung beendet ihn fail-closed als `failed`; neue Freigaben entstehen dabei nicht. Die Defaults stehen auch in `.env.example`:
|
||||
|
||||
```dotenv
|
||||
STORE_SYNC_MAX_SECONDS=900
|
||||
STORE_SYNC_MAX_REQUESTS=2500
|
||||
STORE_SYNC_MAX_BYTES=1073741824
|
||||
STORE_SYNC_MAX_REPOSITORIES=2000
|
||||
STORE_SYNC_MAX_RELEASES=1000
|
||||
```
|
||||
|
||||
Die Limits zählen Redirects und Retries als weitere Requests sowie Metadaten und Artefakte gemeinsam gegen das Byte-Budget. Repository- und Release-Zähler werden nach jeder validierten Provider-API-Seite vor deren Übernahme in den Akkumulator geprüft; dadurch kann höchstens die gerade empfangene Seite zusätzlich im Speicher liegen. `STORE_SYNC_MAX_RELEASES` ist zugleich auf die API-v1-Grenze von 1.000 begrenzt. Ein zweiter Lauf derselben Source wird unabhängig vom Alter eines sichtbaren Run-Eintrags abgewiesen; nach einem Prozessabbruch wird der verwaiste Eintrag erst nach erfolgreichem Erwerb der exklusiven Lease geschlossen.
|
||||
|
||||
### Regelmäßiger Sync mit systemd
|
||||
|
||||
Die mitgelieferte Unit ist ein `oneshot`-Dienst mit einem 15-Minuten-Timer. Passe gegebenenfalls `/opt/netbox-plugin-store` und `www-data` an:
|
||||
|
||||
```bash
|
||||
sudo cp deploy/netbox-plugin-store-sync.service /etc/systemd/system/
|
||||
sudo cp deploy/netbox-plugin-store-sync.timer /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now netbox-plugin-store-sync.timer
|
||||
systemctl list-timers netbox-plugin-store-sync.timer
|
||||
journalctl -u netbox-plugin-store-sync.service
|
||||
```
|
||||
|
||||
Alternativ kann ein dauerhafter Prozess verwendet werden:
|
||||
|
||||
```bash
|
||||
sudo -u www-data php bin/console sync --watch --interval=900
|
||||
```
|
||||
|
||||
Oder per Cron (keine überlappenden Läufe; der Store blockiert parallele Syncs zusätzlich):
|
||||
|
||||
```cron
|
||||
*/15 * * * * cd /opt/netbox-plugin-store/store && /usr/bin/php bin/console sync >>/var/log/netbox-plugin-store-sync.log 2>&1
|
||||
```
|
||||
|
||||
## 2. MariaDB statt JSON (optional)
|
||||
|
||||
Der MariaDB-Adapter nutzt dieselbe Repository-/State-Schnittstelle. Er legt beim ersten Start automatisch eine einzelne, per InnoDB-Transaktion und `SELECT … FOR UPDATE` geschützte State-Zeile an. Das äquivalente Schema liegt unter `config/schema.sql`.
|
||||
|
||||
```dotenv
|
||||
STORE_DB_DRIVER=mariadb
|
||||
STORE_MARIADB_DSN=mysql:host=127.0.0.1;port=3306;dbname=netbox_store;charset=utf8mb4
|
||||
STORE_MARIADB_USER=netbox_store
|
||||
STORE_MARIADB_PASSWORD=<starkes-passwort>
|
||||
```
|
||||
|
||||
Der Datenbankbenutzer benötigt `CREATE`, `SELECT`, `INSERT` und `UPDATE` auf der Store-Datenbank. Sichere die Datenbank wie andere Produktionsdaten regelmäßig.
|
||||
|
||||
## 3. Quellen und automatische Erkennung
|
||||
|
||||
Neue Quellen werden in `/admin` zunächst als `pending` angelegt und müssen per POST/CSRF freigegeben werden. Verfügbare Provider:
|
||||
|
||||
- `forgejo` für Forgejo und Gitea;
|
||||
- `github` für GitHub-User oder -Organisationen.
|
||||
|
||||
Tokens werden nur aus dem in der Source hinterlegten Namen einer Umgebungsvariable gelesen, beispielsweise `GITEA_TOKEN` oder `GITHUB_TOKEN`; Tokenwerte werden nie im Datastore gespeichert. Ein Authorization-Header wird nur gesendet, wenn die Ziel-Origin exakt der `apiUrl`-Origin entspricht, und nach jedem Redirect neu bewertet.
|
||||
|
||||
Private GitHub-Repositories dürfen mit einem entsprechend berechtigten Token für Metadaten und README eingelesen werden. Ihre Release-Assets werden in v1 bewusst fail-closed übersprungen: Der Store betreibt keinen Artefakt-Cache, und der Host-Agent besitzt keine GitHub-Credentials für einen späteren Download. Der Sync zeigt dafür einen Moderationshinweis und zieht eventuell ältere installierbare Releases dieses Repositorys zurück. Installierbar sind von GitHub ausschließlich öffentliche `.whl`-Assets über deren `browser_download_url`; Provider-Tokens werden niemals an Asset-Hosts weitergereicht.
|
||||
|
||||
Die zuverlässigste Discovery gelingt mit einem Manifest im Repository-Root:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Mein NetBox Plugin",
|
||||
"summary": "Kurze Beschreibung",
|
||||
"package_name": "netbox-mein-plugin",
|
||||
"import_name": "netbox_mein_plugin",
|
||||
"version": "1.2.3",
|
||||
"min_netbox_version": "4.6.5",
|
||||
"max_netbox_version": "4.6.8"
|
||||
}
|
||||
```
|
||||
|
||||
Alternativ werden PEP-621/Poetry/Setuptools-Metadaten, Entry Points, `setup.py` und `PluginConfig` ausgewertet. Dynamische Setuptools-Versionen über `tool.setuptools.dynamic.version.attr` werden aus dem am gleichen Commit gepinnten Modul gelesen. Bleiben Paket, Top-Level-Import oder Kompatibilitätsgrenzen unbekannt, blockiert die Admin-Seite die Freigabe mit einer klaren Meldung. Dort können die Felder korrigiert werden; diese Overrides bleiben bei späteren Syncs erhalten.
|
||||
|
||||
## 4. Ein Wheel veröffentlichen und freigeben
|
||||
|
||||
Die aktuell eingelesenen MrBlake-Repositories besitzen überwiegend keine Forgejo-Release-Wheels. Sie können nach Plugin-Freigabe im Store erscheinen, sind aber nicht automatisch installierbar.
|
||||
|
||||
Empfohlener Release-Ablauf im Plugin-Repository:
|
||||
|
||||
```bash
|
||||
python -m pip install --upgrade build
|
||||
python -m build
|
||||
```
|
||||
|
||||
1. Einen unveränderlichen Git-Tag setzen.
|
||||
2. In Forgejo einen Release für diesen Tag erstellen.
|
||||
3. Das erzeugte `dist/*.whl` als Release-Asset hochladen.
|
||||
4. Den Store synchronisieren.
|
||||
5. Im Admin zunächst fehlende Plugin-Metadaten korrigieren/freigeben und anschließend genau das neue Release-Artefakt freigeben.
|
||||
|
||||
Beim Sync lädt der Store das Wheel tatsächlich, berechnet SHA-256 und `artifact_size` und speichert den Commit-SHA. Neue Releases erben niemals die Plugin-Freigabe. Ändern sich URL, Hash, Größe, Commit, Paket-/Importname oder Kompatibilitätsgrenzen, werden Plugin bzw. Release wieder `pending`. Entfernte Upstream-Releases werden als zurückgezogen markiert und verschwinden sofort aus dem öffentlichen Katalog.
|
||||
|
||||
Ein Git-Tag oder Quellcode-Archiv allein erzeugt kein installierbares Release. Der Store erzeugt keine Ersatzartefakte; NetBox-Client und Host-Agent installieren ausschließlich separat veröffentlichte und freigegebene Wheels.
|
||||
|
||||
## 5. Moderationsablauf
|
||||
|
||||
Unter `/admin` stehen ausschließlich POST-Aktionen mit CSRF-Schutz bereit:
|
||||
|
||||
1. Source prüfen und freigeben;
|
||||
2. Source synchronisieren;
|
||||
3. Plugin-Metadaten prüfen/korrigieren und Plugin freigeben;
|
||||
4. Wheel-Hash, Größe, Version und Kompatibilität prüfen und Release separat freigeben.
|
||||
|
||||
Der Store erzeugt beim Release-Approval einen kanonischen `approved_payload_sha256`. Die öffentliche API liefert ein Artefakt nur, wenn der aktuelle Payload weiterhin exakt zu diesem Approval-Hash passt. Das ist unabhängig von den sichtbaren Statusfeldern eine zusätzliche Defense-in-Depth-Prüfung.
|
||||
|
||||
## 6. API v1
|
||||
|
||||
Alle Routen tolerieren einen abschließenden Slash:
|
||||
|
||||
```text
|
||||
GET /api/v1/plugins/
|
||||
GET /api/v1/plugins/:slug/
|
||||
GET /api/v1/plugins/:slug/releases/:version/
|
||||
```
|
||||
|
||||
Die Liste ist paginiert (`page`, `page_size`) und unterstützt `q`, `source` und `netbox_version`. Sie enthält ausschließlich aktive, vollständig validierte und freigegebene Source-/Plugin-Ketten.
|
||||
|
||||
Plugin-Detail (gekürzt auf ein Release):
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": "v1",
|
||||
"slug": "demo-plugin",
|
||||
"name": "Demo Plugin",
|
||||
"summary": "Kurzbeschreibung",
|
||||
"description": "Beschreibung",
|
||||
"repository_url": "https://git.example.com/team/demo",
|
||||
"latest_version": "1.2.3",
|
||||
"package_name": "netbox-demo",
|
||||
"import_name": "netbox_demo",
|
||||
"min_netbox_version": "4.6.5",
|
||||
"max_netbox_version": "4.6.8",
|
||||
"approved": true,
|
||||
"status": "approved",
|
||||
"releases": [
|
||||
{
|
||||
"version": "1.2.3",
|
||||
"download_url": "https://git.example.com/assets/netbox_demo-1.2.3-py3-none-any.whl",
|
||||
"sha256": "<64-hex>",
|
||||
"artifact_size": 12345,
|
||||
"commit_sha": "<git-sha>",
|
||||
"min_netbox_version": "4.6.5",
|
||||
"max_netbox_version": "4.6.8",
|
||||
"published_at": "2026-08-20T10:00:00+00:00",
|
||||
"approved": true,
|
||||
"status": "approved",
|
||||
"immutable": true,
|
||||
"approved_payload_sha256": "<64-hex>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Die Release-Detailroute liefert exakt das flache Release-Objekt aus `releases[]`, ohne interne Admin-ID. Ohne freigegebenes Wheel ist `releases` leer und `latest_version` `null`. Ein Plugin-Detail enthält höchstens 1.000 Releases. `commit_sha` ist entweder leer oder exakt 40 kleingeschriebene Hex-Zeichen. Vor der Freigabe validiert der Store außerdem den Wheel-Dateinamen und verlangt, dass dessen normalisierte Distribution und Version exakt zu `package_name` und `version` passen; damit scheitert ein Artefakt nicht erst später im Host-Agent.
|
||||
|
||||
## 7. Netzwerk- und SSRF-Schutz
|
||||
|
||||
Ausgehende Ziele müssen credential-freies HTTPS verwenden und in `STORE_ALLOWED_SOURCE_HOSTS` stehen. Jeder DNS-A/AAAA-Wert wird geprüft; Loopback, private, Link-Local- und reservierte Adressen sind standardmäßig gesperrt. cURL wird an die geprüfte Adresse gepinnt, Redirects werden einzeln erneut aufgelöst/geprüft, und Provider-Tokens bleiben auf der exakten API-Origin.
|
||||
|
||||
```dotenv
|
||||
STORE_ALLOWED_SOURCE_HOSTS=git.mrblake.cc,github.com,api.github.com,*.github.com,*.githubusercontent.com
|
||||
STORE_ALLOW_PRIVATE_NETWORKS=false
|
||||
```
|
||||
|
||||
Für ein bewusst internes Forgejo muss dessen Host explizit allowgelistet und `STORE_ALLOW_PRIVATE_NETWORKS=true` gesetzt werden. Diese Ausnahme erweitert den SSRF-Radius und sollte nur in einem kontrollierten Netz verwendet werden.
|
||||
|
||||
`STORE_TRUST_PROXY=true` darf nur genutzt werden, wenn direkter Zugriff auf Apache per Firewall ausgeschlossen ist. Zusätzlich werden Forwarded-IPs nur von exakt gelisteten Peers akzeptiert:
|
||||
|
||||
```dotenv
|
||||
STORE_TRUST_PROXY=true
|
||||
STORE_TRUSTED_PROXY_IPS=10.20.0.10,2001:db8::10
|
||||
```
|
||||
|
||||
## 8. Optionale Docker-Installation
|
||||
|
||||
Das Image enthält Apache und PHP 8.4; ein Proxy ist nicht erforderlich. Der Scheduler läuft im Container als `www-data`, damit Webprozess und Watcher dieselben geschützten JSON-Dateien lesen können.
|
||||
|
||||
```bash
|
||||
cd store
|
||||
docker build -t netbox-plugin-store .
|
||||
docker volume create netbox-plugin-store-data
|
||||
docker run -d --name netbox-plugin-store \
|
||||
-p 3000:80 \
|
||||
--env-file .env \
|
||||
-e STORE_PUBLIC_URL=http://localhost:3000 \
|
||||
-e STORE_COOKIE_SECURE=false \
|
||||
-e STORE_JSON_PATH=/var/www/html/data/store.json \
|
||||
-e STORE_SCHEDULER_ENABLED=true \
|
||||
-v netbox-plugin-store-data:/var/www/html/data \
|
||||
netbox-plugin-store
|
||||
```
|
||||
|
||||
Der Healthcheck ist `GET /healthz`. Für Produktion HTTPS konfigurieren und `STORE_PUBLIC_URL`/Cookie-Einstellung entsprechend setzen.
|
||||
|
||||
## 9. Update, Backup und Diagnose
|
||||
|
||||
Native Aktualisierung:
|
||||
|
||||
```bash
|
||||
cd /opt/netbox-plugin-store
|
||||
git pull --ff-only
|
||||
cd store
|
||||
composer install --no-dev --no-interaction --prefer-dist --classmap-authoritative
|
||||
sudo systemctl reload apache2
|
||||
sudo -u www-data php bin/console sync
|
||||
```
|
||||
|
||||
Vor Updates den JSON-Datastore oder MariaDB sichern. Der JSON-Store darf nur kopiert werden, während kein Schreibvorgang läuft; am einfachsten Apache/Timer kurz stoppen oder ein konsistentes dateisystemseitiges Backup-Verfahren verwenden.
|
||||
|
||||
Nützliche Prüfungen:
|
||||
|
||||
```bash
|
||||
php -l public/index.php
|
||||
composer validate --strict
|
||||
composer test
|
||||
curl -fsS http://127.0.0.1/healthz
|
||||
curl -fsS http://127.0.0.1/api/v1/plugins/
|
||||
```
|
||||
|
||||
Sync-Ergebnisse stehen im Admin-Dashboard und werden als JSON vom CLI ausgegeben. Einzelne Repository-Fehler führen zu einem `partial`-Lauf und archivieren den letzten bekannten Eintrag nicht; eine erfolgreich festgestellte Nicht-Kandidatur oder ein erfolgreich festgestellter Release-Rückzug wird dagegen fail-closed aus dem öffentlichen Katalog entfernt.
|
||||
|
||||
## Tests
|
||||
|
||||
Der zero-dependency Runner nutzt PHPs Laufzeit direkt; die Anwendungsabhängigkeiten müssen per Composer installiert sein:
|
||||
|
||||
```bash
|
||||
composer install
|
||||
composer test
|
||||
```
|
||||
|
||||
Abgedeckt sind unter anderem API-Feldvertrag und 1.000er-Grenze, Approval-Payload, Wheel-Distribution/-Version/-Dateiname, 40-stellige Commit-SHAs, strikte Versions-/Metadatenvalidierung, README-Sanitizing und Commit-Pinning, Origin-Token-Isolation, private GitHub-Assets, private-IP-SSRF, atomare JSON-Grenzen und exklusive Leases, aggregierte Sync-Budgets, Trusted-Proxy-Auswertung, sticky Admin-Overrides, Rehash bei ersetzten Assets, Withdrawal und Nicht-Kandidaten-Archivierung.
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use NetBoxStore\Config;
|
||||
use NetBoxStore\Database\RepositoryFactory;
|
||||
use NetBoxStore\Security\Auth;
|
||||
use NetBoxStore\Security\HttpClient;
|
||||
use NetBoxStore\Security\SsrfGuard;
|
||||
use NetBoxStore\Sync\SyncService;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
require $root . '/vendor/autoload.php';
|
||||
|
||||
$command = $argv[1] ?? '';
|
||||
if ($command === 'hash-password') {
|
||||
try {
|
||||
$password = $argv[2] ?? getenv('STORE_ADMIN_PASSWORD_TO_HASH') ?: '';
|
||||
if ($password === '') {
|
||||
throw new RuntimeException("Usage: php bin/console hash-password 'a-long-random-password'");
|
||||
}
|
||||
fwrite(STDOUT, Auth::passwordHash($password) . PHP_EOL);
|
||||
exit(0);
|
||||
} catch (Throwable $exception) {
|
||||
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
if (!in_array($command, ['sync', 'bootstrap'], true)) {
|
||||
fwrite(STDERR, "Usage: php bin/console <hash-password|bootstrap|sync> [--watch] [--source=slug] [--interval=900] [--fail-fast]" . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
$config = Config::load($root);
|
||||
$repository = RepositoryFactory::create($config);
|
||||
$repository->initialize();
|
||||
$guard = new SsrfGuard($config);
|
||||
$http = new HttpClient($config, $guard);
|
||||
$service = new SyncService($repository, $config, $http, $guard);
|
||||
if (!in_array('--no-bootstrap', $argv, true)) {
|
||||
$bootstrapped = $service->ensureDefaultSource();
|
||||
if ($bootstrapped['created']) {
|
||||
fwrite(STDOUT, 'Default source created: ' . $bootstrapped['source']['slug'] . PHP_EOL);
|
||||
}
|
||||
}
|
||||
if ($command === 'bootstrap') {
|
||||
exit(0);
|
||||
}
|
||||
$watch = in_array('--watch', $argv, true);
|
||||
$failFast = in_array('--fail-fast', $argv, true);
|
||||
$sources = [];
|
||||
$interval = $config->scheduler['interval'];
|
||||
foreach ($argv as $argument) {
|
||||
if (str_starts_with($argument, '--source=')) {
|
||||
$sources[] = substr($argument, 9);
|
||||
} elseif (str_starts_with($argument, '--interval=')) {
|
||||
$interval = max(30, min(604_800, (int) substr($argument, 11)));
|
||||
}
|
||||
}
|
||||
$stopped = false;
|
||||
if (function_exists('pcntl_async_signals')) {
|
||||
pcntl_async_signals(true);
|
||||
pcntl_signal(SIGINT, static function () use (&$stopped): void { $stopped = true; });
|
||||
pcntl_signal(SIGTERM, static function () use (&$stopped): void { $stopped = true; });
|
||||
}
|
||||
do {
|
||||
try {
|
||||
$runs = $service->syncAll($sources === [] ? null : $sources, $failFast, $watch ? 'scheduled' : 'command');
|
||||
fwrite(STDOUT, json_encode($runs, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . PHP_EOL);
|
||||
if (!$watch && array_filter($runs, static fn (array $run): bool => $run['status'] === 'failed')) {
|
||||
exit(1);
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
fwrite(STDERR, 'Sync failed: ' . $exception->getMessage() . PHP_EOL);
|
||||
if (!$watch) {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
if ($watch && !$stopped) {
|
||||
for ($second = 0; $second < $interval && !$stopped; $second++) {
|
||||
sleep(1);
|
||||
}
|
||||
}
|
||||
} while ($watch && !$stopped);
|
||||
exit(0);
|
||||
} catch (Throwable $exception) {
|
||||
fwrite(STDERR, $exception->getMessage() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "mrblake/netbox-plugin-store",
|
||||
"description": "Curated, approval-gated NetBox plugin catalogue",
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"require": {
|
||||
"php": ">=8.3 <9.0",
|
||||
"ext-curl": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-intl": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-pdo": "*",
|
||||
"devium/toml": "^1.1",
|
||||
"league/commonmark": "^2.7",
|
||||
"symfony/yaml": "^7.2"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"NetBoxStore\\": "src/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "php tests/run.php"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {},
|
||||
"optimize-autoloader": true,
|
||||
"sort-packages": true
|
||||
}
|
||||
}
|
||||
Generated
+1023
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS store_state (
|
||||
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
document LONGTEXT NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT store_state_document_json CHECK (JSON_VALID(document))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO store_state (id, document)
|
||||
VALUES (1, '{"schemaVersion":1,"sources":[],"plugins":[],"releases":[],"syncRuns":[],"auditLog":[],"authAttempts":[],"meta":{}}');
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<VirtualHost *:80>
|
||||
ServerName localhost
|
||||
DocumentRoot /var/www/html/public
|
||||
|
||||
<Directory /var/www/html/public>
|
||||
Options -Indexes
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/access.log combined
|
||||
</VirtualHost>
|
||||
|
||||
ServerTokens Prod
|
||||
ServerSignature Off
|
||||
@@ -0,0 +1,19 @@
|
||||
<VirtualHost *:80>
|
||||
ServerName plugins.example.com
|
||||
DocumentRoot /opt/netbox-plugin-store/store/public
|
||||
|
||||
<Directory /opt/netbox-plugin-store/store/public>
|
||||
Options -Indexes
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
</Directory>
|
||||
|
||||
ErrorLog ${APACHE_LOG_DIR}/netbox-plugin-store-error.log
|
||||
CustomLog ${APACHE_LOG_DIR}/netbox-plugin-store-access.log combined
|
||||
</VirtualHost>
|
||||
|
||||
ServerTokens Prod
|
||||
ServerSignature Off
|
||||
|
||||
# Für Produktion HTTPS direkt mit mod_ssl konfigurieren (oder den vHost durch
|
||||
# certbot ergänzen lassen). STORE_PUBLIC_URL muss anschließend https:// nutzen.
|
||||
@@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=NetBox Plugin Store synchronization
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/opt/netbox-plugin-store/store
|
||||
ExecStart=/usr/bin/php /opt/netbox-plugin-store/store/bin/console sync
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectHome=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=/opt/netbox-plugin-store/store/data
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Synchronize NetBox Plugin Store every 15 minutes
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitActiveSec=15min
|
||||
AccuracySec=30s
|
||||
Persistent=true
|
||||
Unit=netbox-plugin-store-sync.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,6 @@
|
||||
display_errors=Off
|
||||
display_startup_errors=Off
|
||||
log_errors=On
|
||||
expose_php=Off
|
||||
session.cookie_httponly=1
|
||||
session.use_strict_mode=1
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
mkdir -p /var/www/html/data
|
||||
chown -R www-data:www-data /var/www/html/data
|
||||
|
||||
if [ "${STORE_SCHEDULER_ENABLED:-false}" = "true" ]; then
|
||||
interval="${STORE_SYNC_INTERVAL_SECONDS:-900}"
|
||||
case "$interval" in
|
||||
*[!0-9]*) interval=900 ;;
|
||||
esac
|
||||
runuser -u www-data -- php /var/www/html/bin/console sync --watch --interval="$interval" &
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
@@ -0,0 +1,16 @@
|
||||
Options -Indexes
|
||||
DirectoryIndex index.php
|
||||
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.php [QSA,L]
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
Header always set X-Frame-Options "DENY"
|
||||
Header always set Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
||||
</IfModule>
|
||||
@@ -0,0 +1,274 @@
|
||||
:root {
|
||||
--ink: #102a43;
|
||||
--ink-soft: #334e68;
|
||||
--muted: #627d98;
|
||||
--line: #d9e2ec;
|
||||
--surface: #fff;
|
||||
--surface-soft: #f4f7fa;
|
||||
--navy: #102a43;
|
||||
--blue: #1769e0;
|
||||
--blue-dark: #1254b5;
|
||||
--cyan: #21b6c7;
|
||||
--green: #147d64;
|
||||
--green-bg: #e6f6f1;
|
||||
--amber: #9a6700;
|
||||
--amber-bg: #fff5d6;
|
||||
--red: #b42318;
|
||||
--red-bg: #ffebe9;
|
||||
--shadow: 0 16px 40px rgba(16, 42, 67, .09);
|
||||
--radius: 18px;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background: #f8fafc;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body { margin: 0; min-height: 100vh; background: #f8fafc; color: var(--ink); line-height: 1.55; }
|
||||
a { color: var(--blue); text-decoration: none; }
|
||||
a:hover { color: var(--blue-dark); }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:disabled { cursor: not-allowed; opacity: .48; }
|
||||
code { border-radius: 6px; background: #edf2f7; color: #243b53; padding: .12rem .35rem; font: .88em ui-monospace, SFMono-Regular, Consolas, monospace; overflow-wrap: anywhere; }
|
||||
.shell { width: min(1180px, calc(100% - 40px)); margin-inline: auto; }
|
||||
.skip-link { position: fixed; top: -100px; left: 16px; z-index: 100; padding: 10px 16px; background: #fff; border-radius: 8px; }
|
||||
.skip-link:focus { top: 12px; }
|
||||
.site-header { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid rgba(217,226,236,.9); background: rgba(255,255,255,.92); backdrop-filter: blur(14px); }
|
||||
.header-inner { min-height: 76px; display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||
.brand { display: inline-flex; align-items: center; gap: 11px; color: var(--ink); }
|
||||
.brand:hover { color: var(--ink); }
|
||||
.brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 11px; color: #fff; background: linear-gradient(145deg, var(--blue), var(--cyan)); font-weight: 800; box-shadow: 0 7px 18px rgba(23,105,224,.24); }
|
||||
.brand span:last-child { display: grid; line-height: 1.05; }
|
||||
.brand small { margin-top: 4px; color: var(--muted); font-size: .68rem; font-weight: 700; letter-spacing: .12em; text-transform: uppercase; }
|
||||
.main-nav { display: flex; align-items: center; gap: 5px; }
|
||||
.main-nav a { padding: 9px 13px; border-radius: 9px; color: var(--ink-soft); font-size: .92rem; font-weight: 650; }
|
||||
.main-nav a:hover, .main-nav a.active { color: var(--blue); background: #edf5ff; }
|
||||
.hero { overflow: hidden; color: #fff; background: radial-gradient(circle at 80% 10%, rgba(33,182,199,.28), transparent 34%), linear-gradient(130deg, #0b2035, #123c64 62%, #15566f); }
|
||||
.hero-grid { min-height: 435px; display: grid; grid-template-columns: minmax(0, 1.8fr) minmax(240px, .55fr); align-items: center; gap: 70px; padding-block: 70px; }
|
||||
.eyebrow { display: block; margin-bottom: 12px; color: #50b9ff; font-size: .72rem; font-weight: 800; letter-spacing: .16em; text-transform: uppercase; }
|
||||
.hero h1 { max-width: 760px; margin: 0; font-size: clamp(2.6rem, 6vw, 5.2rem); line-height: .98; letter-spacing: -.055em; }
|
||||
.hero h1 span { color: #6bd7e1; }
|
||||
.hero-copy { max-width: 730px; margin: 27px 0 0; color: #c8d9e8; font-size: clamp(1rem, 1.5vw, 1.18rem); }
|
||||
.hero-stat { position: relative; display: grid; padding: 34px; border: 1px solid rgba(255,255,255,.16); border-radius: 22px; background: rgba(255,255,255,.08); box-shadow: inset 0 1px 0 rgba(255,255,255,.12); }
|
||||
.hero-stat strong { font-size: 4.4rem; line-height: 1; letter-spacing: -.06em; }
|
||||
.hero-stat span { margin-top: 8px; font-weight: 700; }
|
||||
.hero-stat small { margin-top: 22px; color: #b7d1e5; }
|
||||
.catalog-section { padding-block: 0 80px; }
|
||||
.filter-panel { position: relative; z-index: 2; display: grid; grid-template-columns: 2fr 1fr 1fr auto auto; align-items: end; gap: 12px; margin-top: -35px; padding: 20px; border: 1px solid var(--line); border-radius: 16px; background: #fff; box-shadow: var(--shadow); }
|
||||
label { display: grid; gap: 6px; color: var(--ink-soft); font-size: .82rem; font-weight: 700; }
|
||||
input, select, textarea { width: 100%; min-height: 43px; padding: 9px 12px; border: 1px solid #bcccdc; border-radius: 9px; outline: none; background: #fff; color: var(--ink); transition: border-color .15s, box-shadow .15s; }
|
||||
textarea { resize: vertical; }
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--blue); box-shadow: 0 0 0 3px rgba(23,105,224,.12); }
|
||||
.button { min-height: 42px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; padding: 9px 15px; border: 1px solid transparent; border-radius: 9px; font-weight: 750; font-size: .88rem; white-space: nowrap; }
|
||||
.button.primary { border-color: var(--blue); color: #fff; background: var(--blue); }
|
||||
.button.primary:hover { border-color: var(--blue-dark); color: #fff; background: var(--blue-dark); }
|
||||
.button.secondary { border-color: #b6c6d8; color: var(--ink); background: #fff; }
|
||||
.button.quiet { color: var(--ink-soft); background: #edf2f7; }
|
||||
.button.danger { color: var(--red); background: var(--red-bg); }
|
||||
.button.wide { width: 100%; }
|
||||
.notice { margin: 20px 0; padding: 13px 16px; border: 1px solid; border-radius: 10px; font-weight: 650; }
|
||||
.notice.error { border-color: #f3b7b2; color: var(--red); background: var(--red-bg); }
|
||||
.notice.success { border-color: #9dd8c8; color: var(--green); background: var(--green-bg); }
|
||||
.catalog-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin: 55px 0 22px; }
|
||||
.catalog-heading h2 { margin: 0; font-size: 2rem; letter-spacing: -.035em; }
|
||||
.catalog-heading .eyebrow { margin-bottom: 5px; color: var(--blue); }
|
||||
.muted { color: var(--muted); }
|
||||
.plugin-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
|
||||
.plugin-card { min-width: 0; display: flex; flex-direction: column; min-height: 305px; padding: 23px; border: 1px solid var(--line); border-radius: var(--radius); background: #fff; box-shadow: 0 3px 12px rgba(16,42,67,.035); transition: transform .18s, box-shadow .18s, border-color .18s; }
|
||||
.plugin-card:hover { transform: translateY(-3px); border-color: #b6cce1; box-shadow: var(--shadow); }
|
||||
.card-topline, .release-tags, .summary-status { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; }
|
||||
.card-topline { justify-content: space-between; }
|
||||
.provider-pill, .status-pill { display: inline-flex; align-items: center; width: fit-content; padding: 4px 8px; border-radius: 999px; font-size: .66rem; font-weight: 800; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.provider-pill { color: #526d82; background: #edf2f7; }
|
||||
.status-pill.success { color: var(--green); background: var(--green-bg); }
|
||||
.status-pill.warning { color: var(--amber); background: var(--amber-bg); }
|
||||
.status-pill.danger { color: var(--red); background: var(--red-bg); }
|
||||
.status-pill.neutral { color: #526d82; background: #edf2f7; }
|
||||
.plugin-card h3 { margin: 22px 0 9px; font-size: 1.3rem; letter-spacing: -.025em; }
|
||||
.plugin-card h3 a { color: var(--ink); }
|
||||
.plugin-card > p { flex: 1; margin: 0; color: var(--muted); font-size: .92rem; }
|
||||
.card-meta { display: flex; gap: 24px; margin: 22px 0 18px; }
|
||||
.card-meta div { display: grid; }
|
||||
.card-meta dt { color: var(--muted); font-size: .67rem; font-weight: 800; letter-spacing: .09em; text-transform: uppercase; }
|
||||
.card-meta dd { margin: 3px 0 0; color: var(--ink-soft); font-size: .86rem; font-weight: 700; }
|
||||
.card-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-top: 16px; border-top: 1px solid #e9eff5; }
|
||||
.card-footer code { max-width: 62%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.arrow-link { font-size: .85rem; font-weight: 750; }
|
||||
.empty-state, .error-page { display: grid; justify-items: center; padding: 80px 30px; border: 1px dashed #bcccdc; border-radius: var(--radius); text-align: center; background: #fff; }
|
||||
.empty-state h2, .error-page h1 { margin: 12px 0 4px; }
|
||||
.empty-state p, .error-page p { max-width: 600px; color: var(--muted); }
|
||||
.empty-icon { font-size: 3rem; color: var(--blue); }
|
||||
.pagination { display: flex; justify-content: center; align-items: center; gap: 20px; margin-top: 38px; }
|
||||
.pagination a { padding: 8px 12px; border-radius: 8px; background: #fff; font-weight: 700; }
|
||||
.pagination span { color: var(--muted); font-size: .88rem; }
|
||||
.detail-hero { padding: 56px 0 62px; color: #fff; background: linear-gradient(130deg, #0c253d, #174f73); }
|
||||
.back-link { display: inline-block; margin-bottom: 36px; color: #a8d8fb; font-size: .86rem; font-weight: 700; }
|
||||
.back-link:hover { color: #fff; }
|
||||
.detail-title-row { display: flex; align-items: end; justify-content: space-between; gap: 40px; }
|
||||
.detail-title-row h1 { margin: 0; font-size: clamp(2.35rem, 5vw, 4.3rem); line-height: 1; letter-spacing: -.05em; }
|
||||
.detail-title-row p { max-width: 730px; margin: 18px 0 0; color: #c6d9e9; font-size: 1.08rem; }
|
||||
.detail-actions { display: flex; flex-wrap: wrap; gap: 9px; }
|
||||
.detail-actions .secondary { border-color: rgba(255,255,255,.28); color: #fff; background: rgba(255,255,255,.08); }
|
||||
.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 330px; align-items: start; gap: 24px; padding-block: 34px 80px; }
|
||||
.readme-card, .side-card { border: 1px solid var(--line); border-radius: var(--radius); background: #fff; }
|
||||
.section-label { padding: 14px 23px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: .68rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.readme-content { padding: clamp(24px, 5vw, 48px); color: #243b53; overflow-wrap: anywhere; }
|
||||
.readme-content > :first-child { margin-top: 0; }
|
||||
.readme-content > :last-child { margin-bottom: 0; }
|
||||
.readme-content h1, .readme-content h2, .readme-content h3 { margin-top: 1.7em; color: var(--ink); line-height: 1.2; letter-spacing: -.025em; }
|
||||
.readme-content h1 { padding-bottom: .35em; border-bottom: 1px solid var(--line); font-size: 2rem; }
|
||||
.readme-content h2 { padding-bottom: .3em; border-bottom: 1px solid #e9eff5; font-size: 1.5rem; }
|
||||
.readme-content pre { max-width: 100%; padding: 17px; overflow: auto; border-radius: 11px; background: #0e2438; color: #e3edf5; }
|
||||
.readme-content pre code { padding: 0; background: transparent; color: inherit; }
|
||||
.readme-content img { max-width: 100%; height: auto; }
|
||||
.readme-content blockquote { margin-inline: 0; padding: 2px 18px; border-left: 4px solid var(--cyan); color: var(--muted); }
|
||||
.readme-content table { width: 100%; border-collapse: collapse; }
|
||||
.readme-content th, .readme-content td { padding: 8px 10px; border: 1px solid var(--line); text-align: left; }
|
||||
.detail-sidebar { display: grid; gap: 18px; }
|
||||
.side-card { padding: 21px; }
|
||||
.side-card h2 { margin: 0 0 17px; font-size: 1rem; }
|
||||
.side-list { margin: 0; }
|
||||
.side-list div { display: grid; gap: 3px; padding: 12px 0; border-top: 1px solid #e9eff5; }
|
||||
.side-list dt { color: var(--muted); font-size: .69rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.side-list dd { margin: 0; font-size: .88rem; font-weight: 650; }
|
||||
.side-heading { display: flex; align-items: center; justify-content: space-between; }
|
||||
.side-heading span { display: grid; place-items: center; width: 25px; height: 25px; border-radius: 50%; background: #edf2f7; font-size: .75rem; font-weight: 800; }
|
||||
.release-warning { padding: 13px; border: 1px solid #f1d38a; border-radius: 10px; color: #714b00; background: var(--amber-bg); }
|
||||
.release-warning p { margin: 6px 0 0; font-size: .82rem; }
|
||||
.release-list { margin: 0; padding: 0; list-style: none; }
|
||||
.release-list li { display: grid; gap: 9px; padding: 14px 0; border-top: 1px solid #e9eff5; }
|
||||
.release-list li > div:first-child { display: flex; justify-content: space-between; gap: 8px; }
|
||||
.release-list span { color: var(--muted); font-size: .75rem; }
|
||||
.hash { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.site-footer { padding: 38px 0; border-top: 1px solid var(--line); color: #829ab1; background: #0b2035; }
|
||||
.footer-inner { display: flex; justify-content: space-between; gap: 30px; }
|
||||
.footer-inner strong { color: #fff; }
|
||||
.footer-inner p { margin: 6px 0 0; font-size: .85rem; }
|
||||
.footer-links { display: flex; align-items: center; gap: 20px; }
|
||||
.footer-links a { color: #b8d3e6; font-size: .86rem; }
|
||||
.error-page { min-height: 500px; margin-block: 45px; align-content: center; }
|
||||
.error-code { color: var(--blue); font-size: 4.4rem; font-weight: 850; line-height: 1; }
|
||||
.login-page { min-height: 680px; display: grid; place-items: center; padding-block: 60px; }
|
||||
.login-card { width: min(440px, 100%); padding: 38px; border: 1px solid var(--line); border-radius: 20px; background: #fff; box-shadow: var(--shadow); }
|
||||
.login-card h1 { margin: 0; letter-spacing: -.035em; }
|
||||
.login-card > p { color: var(--muted); }
|
||||
.stack-form { display: grid; gap: 17px; margin-top: 25px; }
|
||||
.admin-hero { padding: 40px 0; border-bottom: 1px solid #214761; color: #fff; background: #102a43; }
|
||||
.admin-title-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||
.admin-title-row h1 { margin: 0; font-size: 2.25rem; letter-spacing: -.04em; }
|
||||
.admin-title-row p { margin: 7px 0 0; color: #b8cfdf; }
|
||||
.admin-layout { padding-block: 28px 80px; }
|
||||
.admin-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 13px; margin-bottom: 24px; }
|
||||
.admin-stats div { display: grid; padding: 19px; border: 1px solid var(--line); border-radius: 13px; background: #fff; }
|
||||
.admin-stats strong { font-size: 1.9rem; line-height: 1; }
|
||||
.admin-stats span { margin-top: 7px; color: var(--muted); font-size: .76rem; font-weight: 750; text-transform: uppercase; }
|
||||
.admin-section { margin-top: 20px; border: 1px solid var(--line); border-radius: 15px; background: #fff; box-shadow: 0 2px 8px rgba(16,42,67,.03); }
|
||||
.admin-section-heading { min-height: 72px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 15px 20px; border-bottom: 1px solid var(--line); }
|
||||
.admin-section-heading > div { display: flex; align-items: baseline; gap: 12px; }
|
||||
.admin-section-heading .eyebrow { margin: 0; color: var(--blue); }
|
||||
.admin-section-heading h2 { margin: 0; font-size: 1.18rem; }
|
||||
.admin-section-heading > p { margin: 0; color: var(--muted); font-size: .82rem; }
|
||||
.create-source { position: relative; }
|
||||
.create-source > summary { list-style: none; }
|
||||
.create-source > summary::-webkit-details-marker { display: none; }
|
||||
.popover-form { position: absolute; top: calc(100% + 10px); right: 0; z-index: 10; width: min(650px, calc(100vw - 50px)); padding: 20px; border: 1px solid var(--line); border-radius: 13px; background: #fff; box-shadow: 0 20px 50px rgba(16,42,67,.18); }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.span-2 { grid-column: 1 / -1; }
|
||||
.check-field { display: flex; grid-template-columns: auto 1fr; align-items: center; align-self: end; min-height: 43px; }
|
||||
.check-field input { width: 18px; min-height: 18px; }
|
||||
.form-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.form-actions small { color: var(--muted); }
|
||||
.table-wrap { width: 100%; overflow-x: auto; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: .84rem; }
|
||||
.admin-table th { padding: 11px 15px; color: var(--muted); background: #f6f8fa; font-size: .67rem; letter-spacing: .08em; text-align: left; text-transform: uppercase; }
|
||||
.admin-table td { padding: 14px 15px; border-top: 1px solid #e8eef4; vertical-align: middle; }
|
||||
.admin-table tbody tr:hover { background: #fbfdff; }
|
||||
.admin-table td > strong, .admin-table td > small { display: block; }
|
||||
.admin-table td > small { margin-top: 3px; color: var(--muted); }
|
||||
.action-row { display: flex; align-items: center; flex-wrap: wrap; gap: 5px; }
|
||||
.action-row form { margin: 0; }
|
||||
.mini-button { padding: 5px 8px; border: 1px solid #b8c7d5; border-radius: 7px; color: var(--ink-soft); background: #fff; font-size: .72rem; font-weight: 750; }
|
||||
.mini-button.approve { border-color: #8bcab9; color: var(--green); background: var(--green-bg); }
|
||||
.mini-button.reject { border-color: #efb1aa; color: var(--red); background: var(--red-bg); }
|
||||
.moderation-list { display: grid; }
|
||||
.moderation-item { border-bottom: 1px solid var(--line); }
|
||||
.moderation-item:last-child { border-bottom: 0; }
|
||||
.moderation-item > summary { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 17px 20px; cursor: pointer; list-style: none; }
|
||||
.moderation-item > summary::-webkit-details-marker { display: none; }
|
||||
.moderation-item > summary::before { content: "+"; order: 3; display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; color: var(--blue); background: #edf5ff; font-size: 1.15rem; }
|
||||
.moderation-item[open] > summary::before { content: "−"; }
|
||||
.moderation-item > summary > span:first-of-type { display: grid; }
|
||||
.moderation-item summary small { color: var(--muted); }
|
||||
.moderation-body { padding: 20px; border-top: 1px solid #e8eef4; background: #fbfcfe; }
|
||||
.validation-box { margin-bottom: 17px; padding: 12px 15px; border-left: 4px solid var(--amber); border-radius: 6px; color: #714b00; background: var(--amber-bg); }
|
||||
.validation-box ul { margin: 6px 0 0; padding-left: 20px; font-size: .82rem; }
|
||||
.moderation-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; padding-top: 17px; border-top: 1px solid var(--line); }
|
||||
.moderation-actions form { margin: 0; }
|
||||
.release-admin-table { min-width: 960px; }
|
||||
.artifact-details { max-width: 330px; margin-top: 7px; }
|
||||
.artifact-details summary { color: var(--blue); cursor: pointer; font-size: .72rem; font-weight: 750; }
|
||||
.artifact-details a { display: block; margin-top: 5px; font-size: .7rem; }
|
||||
.artifact-details strong { display: block; margin-top: 7px; color: var(--muted); font-size: .64rem; text-transform: uppercase; }
|
||||
.break-value, .full-hash { white-space: normal; overflow-wrap: anywhere; word-break: break-all; }
|
||||
.full-hash { display: block; max-width: 270px; font-size: .7rem; }
|
||||
.text-danger { color: var(--red) !important; }
|
||||
.empty-row { padding: 30px !important; color: var(--muted); text-align: center !important; }
|
||||
.admin-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.compact-section { min-width: 0; }
|
||||
.timeline { max-height: 430px; margin: 0; padding: 7px 20px 18px; overflow: auto; list-style: none; }
|
||||
.timeline li { display: grid; grid-template-columns: 13px minmax(0, 1fr); gap: 12px; padding: 14px 0; border-bottom: 1px solid #e8eef4; }
|
||||
.timeline li:last-child { border-bottom: 0; }
|
||||
.timeline-dot { width: 9px; height: 9px; margin-top: 6px; border-radius: 50%; background: var(--muted); }
|
||||
.timeline-dot.success, .timeline-dot.audit { background: var(--green); }
|
||||
.timeline-dot.partial { background: var(--amber); }
|
||||
.timeline-dot.failed { background: var(--red); }
|
||||
.timeline strong { font-size: .84rem; text-transform: capitalize; }
|
||||
.timeline p { margin: 2px 0; color: var(--ink-soft); font-size: .8rem; }
|
||||
.timeline small { color: var(--muted); font-size: .72rem; }
|
||||
.sync-errors { margin-top: 7px; color: var(--ink-soft); font-size: .72rem; }
|
||||
.sync-errors summary { color: var(--blue); cursor: pointer; font-weight: 750; }
|
||||
.sync-errors ul { margin: 7px 0 0; padding-left: 18px; }
|
||||
.sync-errors li { display: list-item; padding: 3px 0; border: 0; overflow-wrap: anywhere; }
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.hero-grid { grid-template-columns: 1fr; gap: 35px; }
|
||||
.hero-stat { width: min(350px, 100%); }
|
||||
.filter-panel { grid-template-columns: 2fr 1fr; }
|
||||
.plugin-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.detail-layout { grid-template-columns: 1fr; }
|
||||
.detail-sidebar { grid-template-columns: 1fr 1fr; }
|
||||
.admin-columns { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
.shell { width: min(100% - 24px, 1180px); }
|
||||
.header-inner { min-height: 66px; }
|
||||
.brand small { display: none; }
|
||||
.main-nav a { padding: 8px; font-size: .82rem; }
|
||||
.hero-grid { min-height: 0; padding-block: 60px 75px; }
|
||||
.hero h1 { font-size: 2.65rem; }
|
||||
.hero-stat { padding: 23px; }
|
||||
.filter-panel { grid-template-columns: 1fr; margin-top: -25px; }
|
||||
.plugin-grid { grid-template-columns: 1fr; }
|
||||
.catalog-heading, .detail-title-row, .admin-title-row, .footer-inner { align-items: flex-start; flex-direction: column; }
|
||||
.detail-actions { width: 100%; }
|
||||
.detail-actions .button { flex: 1; }
|
||||
.detail-sidebar { grid-template-columns: 1fr; }
|
||||
.readme-content { padding: 22px; }
|
||||
.footer-links { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.admin-stats { grid-template-columns: 1fr 1fr; }
|
||||
.admin-section-heading { align-items: flex-start; flex-direction: column; }
|
||||
.create-source { width: 100%; }
|
||||
.create-source > summary { width: 100%; }
|
||||
.popover-form { position: static; width: 100%; margin-top: 10px; box-shadow: none; }
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.span-2 { grid-column: auto; }
|
||||
.moderation-item > summary { align-items: flex-start; }
|
||||
.summary-status { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="8" y1="4" x2="56" y2="60" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1769e0"/>
|
||||
<stop offset="1" stop-color="#21b6c7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="4" y="4" width="56" height="56" rx="15" fill="url(#g)"/>
|
||||
<path d="M18 46V18h7l14 18V18h7v28h-7L25 28v18z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 412 B |
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
// Web responses never expose PHP diagnostics; errors are logged and the
|
||||
// application returns a generic error page/JSON envelope.
|
||||
ini_set('display_errors', '0');
|
||||
ini_set('display_startup_errors', '0');
|
||||
ini_set('log_errors', '1');
|
||||
header_remove('X-Powered-By');
|
||||
|
||||
use NetBoxStore\Config;
|
||||
use NetBoxStore\Database\RepositoryFactory;
|
||||
use NetBoxStore\Http\Application;
|
||||
use NetBoxStore\Http\Request;
|
||||
use NetBoxStore\Security\Auth;
|
||||
use NetBoxStore\Security\HttpClient;
|
||||
use NetBoxStore\Security\SsrfGuard;
|
||||
use NetBoxStore\Sync\SyncService;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
require $root . '/vendor/autoload.php';
|
||||
|
||||
$config = Config::load($root);
|
||||
$repository = RepositoryFactory::create($config);
|
||||
$repository->initialize();
|
||||
$request = Request::fromGlobals($config);
|
||||
$guard = new SsrfGuard($config);
|
||||
$http = new HttpClient($config, $guard);
|
||||
$sync = new SyncService($repository, $config, $http, $guard);
|
||||
$sync->ensureDefaultSource();
|
||||
$auth = new Auth($config, $repository);
|
||||
$adminPath = $request->path === '/admin' || str_starts_with($request->path, '/admin/');
|
||||
if ($auth->enabled() && $adminPath) {
|
||||
// Public catalog/API/health traffic must not allocate anonymous sessions.
|
||||
$auth->startSession();
|
||||
}
|
||||
|
||||
$application = new Application($config, $repository, $auth, $sync, $guard);
|
||||
$application->handle($request)->send();
|
||||
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class Config
|
||||
{
|
||||
/** @param list<string> $allowedHosts */
|
||||
private function __construct(
|
||||
public readonly string $root,
|
||||
public readonly string $environment,
|
||||
public readonly string $publicUrl,
|
||||
public readonly bool $trustProxy,
|
||||
public readonly bool $secureCookies,
|
||||
public readonly string $sessionName,
|
||||
public readonly array $admin,
|
||||
public readonly array $database,
|
||||
public readonly array $network,
|
||||
public readonly array $defaults,
|
||||
public readonly array $scheduler,
|
||||
public readonly array $syncLimits,
|
||||
public readonly array $pagination,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function load(string $root): self
|
||||
{
|
||||
self::loadDotEnv($root . '/.env');
|
||||
$publicUrl = self::env('STORE_PUBLIC_URL', 'http://localhost');
|
||||
$publicParts = parse_url($publicUrl);
|
||||
if (filter_var($publicUrl, FILTER_VALIDATE_URL) === false || !is_array($publicParts)
|
||||
|| !in_array($publicParts['scheme'] ?? '', ['http', 'https'], true)
|
||||
|| isset($publicParts['user']) || isset($publicParts['pass'])) {
|
||||
throw new RuntimeException('STORE_PUBLIC_URL is invalid.');
|
||||
}
|
||||
$environment = self::env('APP_ENV', 'development');
|
||||
if ($environment === 'production' && ($publicParts['scheme'] ?? '') !== 'https') {
|
||||
error_log('WARNING: APP_ENV=production is using an HTTP STORE_PUBLIC_URL; secure transport is strongly recommended.');
|
||||
}
|
||||
$sessionName = self::env('STORE_SESSION_NAME', 'netbox_plugin_store');
|
||||
if (!preg_match('/^[A-Za-z0-9_-]{1,64}$/', $sessionName)) {
|
||||
throw new RuntimeException('STORE_SESSION_NAME is invalid.');
|
||||
}
|
||||
$admin = [
|
||||
'username' => self::env('STORE_ADMIN_USERNAME'),
|
||||
'passwordHash' => self::env('STORE_ADMIN_PASSWORD_HASH'),
|
||||
'sessionSecret' => self::env('STORE_SESSION_SECRET'),
|
||||
'sessionTtl' => self::integer('STORE_ADMIN_SESSION_TTL', 28_800, 900, 86_400),
|
||||
'maxAttempts' => self::integer('STORE_LOGIN_MAX_ATTEMPTS', 5, 2, 50),
|
||||
'attemptWindow' => self::integer('STORE_LOGIN_WINDOW_SECONDS', 900, 60, 86_400),
|
||||
];
|
||||
$configured = count(array_filter(array_slice($admin, 0, 3), static fn (mixed $v): bool => $v !== ''));
|
||||
if ($configured > 0 && $configured < 3) {
|
||||
throw new RuntimeException('All admin credential environment variables must be set together.');
|
||||
}
|
||||
$admin['enabled'] = $configured === 3;
|
||||
if ($admin['enabled'] && strlen($admin['sessionSecret']) < 32) {
|
||||
throw new RuntimeException('STORE_SESSION_SECRET must contain at least 32 characters.');
|
||||
}
|
||||
if ($admin['enabled'] && (password_get_info($admin['passwordHash'])['algoName'] ?? 'unknown') !== 'argon2id') {
|
||||
throw new RuntimeException('STORE_ADMIN_PASSWORD_HASH must be a valid Argon2id hash.');
|
||||
}
|
||||
|
||||
$driver = strtolower(self::env('STORE_DB_DRIVER', 'json'));
|
||||
if (!in_array($driver, ['json', 'mariadb'], true)) {
|
||||
throw new RuntimeException('STORE_DB_DRIVER must be json or mariadb.');
|
||||
}
|
||||
$jsonPath = self::env('STORE_JSON_PATH', $root . '/data/store.json');
|
||||
if (!str_starts_with($jsonPath, '/') && !preg_match('/^[A-Za-z]:[\\\\\/]/', $jsonPath)) {
|
||||
$jsonPath = $root . '/' . ltrim($jsonPath, '/');
|
||||
}
|
||||
|
||||
return new self(
|
||||
root: $root,
|
||||
environment: $environment,
|
||||
publicUrl: rtrim($publicUrl, '/'),
|
||||
trustProxy: self::boolean('STORE_TRUST_PROXY'),
|
||||
secureCookies: getenv('STORE_COOKIE_SECURE') === false
|
||||
? str_starts_with($publicUrl, 'https://')
|
||||
: self::boolean('STORE_COOKIE_SECURE'),
|
||||
sessionName: $sessionName,
|
||||
admin: $admin,
|
||||
database: [
|
||||
'driver' => $driver,
|
||||
'jsonPath' => $jsonPath,
|
||||
'dsn' => self::env('STORE_MARIADB_DSN', 'mysql:host=127.0.0.1;dbname=netbox_store;charset=utf8mb4'),
|
||||
'user' => self::env('STORE_MARIADB_USER', 'netbox_store'),
|
||||
'password' => self::env('STORE_MARIADB_PASSWORD'),
|
||||
],
|
||||
network: [
|
||||
'allowedHosts' => self::list('STORE_ALLOWED_SOURCE_HOSTS', 'git.mrblake.cc,github.com,api.github.com,*.github.com,*.githubusercontent.com'),
|
||||
'allowPrivate' => self::boolean('STORE_ALLOW_PRIVATE_NETWORKS'),
|
||||
'trustedProxyIps' => self::ipList('STORE_TRUSTED_PROXY_IPS', '127.0.0.1,::1'),
|
||||
'timeout' => self::integer('STORE_HTTP_TIMEOUT_SECONDS', 20, 2, 120),
|
||||
'maxMetadataBytes' => self::integer('STORE_MAX_METADATA_BYTES', 2 * 1024 * 1024, 65_536, 20 * 1024 * 1024),
|
||||
'maxArtifactBytes' => self::integer('STORE_MAX_ARTIFACT_BYTES', 512 * 1024 * 1024, 1_024, 2 * 1024 * 1024 * 1024),
|
||||
'userAgent' => self::env('STORE_USER_AGENT', 'MrBlake-NetBox-Plugin-Store/1.0'),
|
||||
],
|
||||
defaults: [
|
||||
'provider' => strtolower(self::env('STORE_DEFAULT_PROVIDER', 'forgejo')),
|
||||
'name' => self::env('STORE_DEFAULT_SOURCE_NAME', 'MrBlake Forgejo'),
|
||||
'slug' => self::env('STORE_DEFAULT_SOURCE_SLUG', 'mrblake-forgejo'),
|
||||
'baseUrl' => rtrim(self::env('STORE_DEFAULT_BASE_URL', 'https://git.mrblake.cc'), '/'),
|
||||
'apiUrl' => rtrim(self::env('STORE_DEFAULT_API_URL', 'https://git.mrblake.cc/api/v1'), '/'),
|
||||
'owner' => self::env('STORE_DEFAULT_OWNER', 'MrBlake'),
|
||||
'ownerKind' => strtolower(self::env('STORE_DEFAULT_OWNER_KIND', 'user')),
|
||||
'topic' => self::env('STORE_DEFAULT_TOPIC', 'netbox-plugin'),
|
||||
'tokenEnv' => self::env('STORE_DEFAULT_TOKEN_ENV', 'GITEA_TOKEN'),
|
||||
],
|
||||
scheduler: [
|
||||
'enabled' => self::boolean('STORE_SCHEDULER_ENABLED'),
|
||||
'interval' => self::integer('STORE_SYNC_INTERVAL_SECONDS', 900, 30, 604_800),
|
||||
],
|
||||
syncLimits: [
|
||||
'seconds' => self::integer('STORE_SYNC_MAX_SECONDS', 900, 30, 7_200),
|
||||
'requests' => self::integer('STORE_SYNC_MAX_REQUESTS', 2_500, 50, 20_000),
|
||||
'bytes' => self::integer('STORE_SYNC_MAX_BYTES', 1_073_741_824, 16_777_216, 17_179_869_184),
|
||||
'repositories' => self::integer('STORE_SYNC_MAX_REPOSITORIES', 2_000, 1, 10_000),
|
||||
'releases' => self::integer('STORE_SYNC_MAX_RELEASES', 1_000, 1, 1_000),
|
||||
],
|
||||
pagination: [
|
||||
'pageSize' => self::integer('STORE_PAGE_SIZE', 12, 1, 100),
|
||||
'apiPageSize' => self::integer('STORE_API_PAGE_SIZE', 50, 1, 100),
|
||||
'apiMaxPageSize' => self::integer('STORE_API_MAX_PAGE_SIZE', 100, 1, 250),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private static function env(string $name, string $default = ''): string
|
||||
{
|
||||
$value = getenv($name);
|
||||
return $value === false ? $default : trim($value);
|
||||
}
|
||||
|
||||
private static function boolean(string $name, bool $default = false): bool
|
||||
{
|
||||
$value = getenv($name);
|
||||
if ($value === false) {
|
||||
return $default;
|
||||
}
|
||||
return in_array(strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private static function integer(string $name, int $default, int $minimum, int $maximum): int
|
||||
{
|
||||
$value = filter_var(getenv($name), FILTER_VALIDATE_INT);
|
||||
return max($minimum, min($maximum, $value === false ? $default : $value));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function list(string $name, string $default): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (string $part): string => strtolower(trim($part)),
|
||||
explode(',', self::env($name, $default)),
|
||||
)));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private static function ipList(string $name, string $default): array
|
||||
{
|
||||
$values = self::list($name, $default);
|
||||
foreach ($values as $value) {
|
||||
if (filter_var($value, FILTER_VALIDATE_IP) === false) {
|
||||
throw new RuntimeException($name . ' must contain exact IP addresses.');
|
||||
}
|
||||
}
|
||||
return $values;
|
||||
}
|
||||
|
||||
private static function loadDotEnv(string $path): void
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return;
|
||||
}
|
||||
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
|
||||
continue;
|
||||
}
|
||||
[$name, $value] = explode('=', $line, 2);
|
||||
$name = trim($name);
|
||||
if (getenv($name) !== false || !preg_match('/^[A-Z][A-Z0-9_]*$/', $name)) {
|
||||
continue;
|
||||
}
|
||||
$value = trim($value);
|
||||
if (strlen($value) >= 2 && in_array($value[0], ['"', "'"], true) && $value[-1] === $value[0]) {
|
||||
$value = substr($value, 1, -1);
|
||||
}
|
||||
putenv($name . '=' . $value);
|
||||
$_ENV[$name] = $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Database;
|
||||
|
||||
use Closure;
|
||||
|
||||
final class CallbackLease implements ExclusiveLease
|
||||
{
|
||||
private bool $released = false;
|
||||
|
||||
public function __construct(private readonly Closure $releaser)
|
||||
{
|
||||
}
|
||||
|
||||
public function release(): void
|
||||
{
|
||||
if ($this->released) {
|
||||
return;
|
||||
}
|
||||
$this->released = true;
|
||||
($this->releaser)();
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
$this->release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Database;
|
||||
|
||||
interface ExclusiveLease
|
||||
{
|
||||
public function release(): void;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Database;
|
||||
|
||||
use JsonException;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class JsonStoreRepository implements StoreRepository
|
||||
{
|
||||
private const MAX_DATABASE_BYTES = 134_217_728;
|
||||
private string $lockPath;
|
||||
|
||||
public function __construct(private readonly string $path, private readonly int $maxDatabaseBytes = self::MAX_DATABASE_BYTES)
|
||||
{
|
||||
$this->lockPath = $path . '.lock';
|
||||
}
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
$directory = dirname($this->path);
|
||||
if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) {
|
||||
throw new RuntimeException('Could not create datastore directory.');
|
||||
}
|
||||
if (!is_file($this->path)) {
|
||||
$lock = $this->lock(LOCK_EX);
|
||||
try {
|
||||
if (!is_file($this->path)) {
|
||||
$this->atomicWrite(State::empty());
|
||||
}
|
||||
} finally {
|
||||
$this->unlock($lock);
|
||||
}
|
||||
}
|
||||
@chmod($this->path, 0600);
|
||||
$this->read();
|
||||
}
|
||||
|
||||
public function read(): array
|
||||
{
|
||||
$lock = $this->lock(LOCK_SH);
|
||||
try {
|
||||
return $this->readUnlocked();
|
||||
} finally {
|
||||
$this->unlock($lock);
|
||||
}
|
||||
}
|
||||
|
||||
public function acquireLease(string $name): ?ExclusiveLease
|
||||
{
|
||||
$leasePath = $this->path . '.lease-' . substr(hash('sha256', $name), 0, 24) . '.lock';
|
||||
$handle = fopen($leasePath, 'c+b');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Could not open exclusive lease file.');
|
||||
}
|
||||
@chmod($leasePath, 0600);
|
||||
if (!flock($handle, LOCK_EX | LOCK_NB)) {
|
||||
fclose($handle);
|
||||
return null;
|
||||
}
|
||||
return new CallbackLease(static function () use ($handle): void {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
});
|
||||
}
|
||||
|
||||
public function transaction(callable $callback): mixed
|
||||
{
|
||||
$lock = $this->lock(LOCK_EX);
|
||||
try {
|
||||
$draft = $this->readUnlocked();
|
||||
$result = $callback($draft);
|
||||
State::validate($draft);
|
||||
$this->atomicWrite($draft);
|
||||
return $result;
|
||||
} finally {
|
||||
$this->unlock($lock);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return resource */
|
||||
private function lock(int $operation): mixed
|
||||
{
|
||||
$handle = fopen($this->lockPath, 'c+b');
|
||||
if ($handle === false || !flock($handle, $operation)) {
|
||||
throw new RuntimeException('Could not acquire datastore lock.');
|
||||
}
|
||||
@chmod($this->lockPath, 0600);
|
||||
return $handle;
|
||||
}
|
||||
|
||||
/** @param resource $handle */
|
||||
private function unlock(mixed $handle): void
|
||||
{
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function readUnlocked(): array
|
||||
{
|
||||
$size = @filesize($this->path);
|
||||
if ($size === false || $size > $this->maxDatabaseBytes) {
|
||||
throw new RuntimeException('JSON datastore is missing or exceeds 128 MB.');
|
||||
}
|
||||
$contents = file_get_contents($this->path);
|
||||
if ($contents === false) {
|
||||
throw new RuntimeException('Could not read JSON datastore.');
|
||||
}
|
||||
try {
|
||||
$state = json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $exception) {
|
||||
throw new RuntimeException('JSON datastore is malformed.', 0, $exception);
|
||||
}
|
||||
if (!is_array($state)) {
|
||||
throw new RuntimeException('JSON datastore root is invalid.');
|
||||
}
|
||||
State::validate($state);
|
||||
return $state;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
private function atomicWrite(array $state): void
|
||||
{
|
||||
$payload = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n";
|
||||
if (strlen($payload) > $this->maxDatabaseBytes) {
|
||||
throw new RuntimeException('JSON datastore would exceed its configured 128 MB safety limit.');
|
||||
}
|
||||
$temporary = dirname($this->path) . '/.' . basename($this->path) . '.tmp-' . bin2hex(random_bytes(8));
|
||||
$handle = fopen($temporary, 'x+b');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Could not create temporary datastore file.');
|
||||
}
|
||||
try {
|
||||
$written = 0;
|
||||
while ($written < strlen($payload)) {
|
||||
$chunk = fwrite($handle, substr($payload, $written));
|
||||
if ($chunk === false || $chunk === 0) {
|
||||
throw new RuntimeException('Could not write temporary datastore file.');
|
||||
}
|
||||
$written += $chunk;
|
||||
}
|
||||
if (!fflush($handle)) {
|
||||
throw new RuntimeException('Could not flush temporary datastore file.');
|
||||
}
|
||||
if (function_exists('fsync')) {
|
||||
fsync($handle);
|
||||
}
|
||||
fclose($handle);
|
||||
$handle = null;
|
||||
@chmod($temporary, 0600);
|
||||
if (!rename($temporary, $this->path)) {
|
||||
throw new RuntimeException('Atomic datastore rename failed.');
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
if (is_resource($handle)) {
|
||||
fclose($handle);
|
||||
}
|
||||
@unlink($temporary);
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Database;
|
||||
|
||||
use PDO;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class MariaDbStoreRepository implements StoreRepository
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $dsn,
|
||||
private readonly string $username,
|
||||
private readonly string $password,
|
||||
) {
|
||||
}
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
$this->pdo = new PDO($this->dsn, $this->username, $this->password, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
]);
|
||||
$this->pdo->exec(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS store_state (
|
||||
id TINYINT UNSIGNED NOT NULL PRIMARY KEY,
|
||||
document LONGTEXT NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT store_state_document_json CHECK (JSON_VALID(document))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
SQL);
|
||||
$statement = $this->pdo->prepare('INSERT IGNORE INTO store_state (id, document) VALUES (1, ?)');
|
||||
$statement->execute([json_encode(State::empty(), JSON_THROW_ON_ERROR)]);
|
||||
}
|
||||
|
||||
public function read(): array
|
||||
{
|
||||
$row = $this->pdo->query('SELECT document FROM store_state WHERE id = 1')->fetch();
|
||||
if (!is_array($row)) {
|
||||
throw new RuntimeException('MariaDB datastore row is missing.');
|
||||
}
|
||||
$state = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
State::validate($state);
|
||||
return $state;
|
||||
}
|
||||
|
||||
public function acquireLease(string $name): ?ExclusiveLease
|
||||
{
|
||||
$key = 'netbox-store:' . substr(hash('sha256', $name), 0, 48);
|
||||
$statement = $this->pdo->prepare('SELECT GET_LOCK(?, 0) AS acquired');
|
||||
$statement->execute([$key]);
|
||||
if ((int) $statement->fetchColumn() !== 1) {
|
||||
return null;
|
||||
}
|
||||
return new CallbackLease(function () use ($key): void {
|
||||
$statement = $this->pdo->prepare('SELECT RELEASE_LOCK(?)');
|
||||
$statement->execute([$key]);
|
||||
});
|
||||
}
|
||||
|
||||
public function transaction(callable $callback): mixed
|
||||
{
|
||||
$this->pdo->beginTransaction();
|
||||
try {
|
||||
$row = $this->pdo->query('SELECT document FROM store_state WHERE id = 1 FOR UPDATE')->fetch();
|
||||
if (!is_array($row)) {
|
||||
throw new RuntimeException('MariaDB datastore row is missing.');
|
||||
}
|
||||
$draft = json_decode($row['document'], true, 512, JSON_THROW_ON_ERROR);
|
||||
$result = $callback($draft);
|
||||
State::validate($draft);
|
||||
$statement = $this->pdo->prepare('UPDATE store_state SET document = ? WHERE id = 1');
|
||||
$statement->execute([json_encode($draft, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)]);
|
||||
$this->pdo->commit();
|
||||
return $result;
|
||||
} catch (Throwable $exception) {
|
||||
if ($this->pdo->inTransaction()) {
|
||||
$this->pdo->rollBack();
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Database;
|
||||
|
||||
use NetBoxStore\Config;
|
||||
|
||||
final class RepositoryFactory
|
||||
{
|
||||
public static function create(Config $config): StoreRepository
|
||||
{
|
||||
if ($config->database['driver'] === 'mariadb') {
|
||||
return new MariaDbStoreRepository(
|
||||
$config->database['dsn'],
|
||||
$config->database['user'],
|
||||
$config->database['password'],
|
||||
);
|
||||
}
|
||||
return new JsonStoreRepository($config->database['jsonPath']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Database;
|
||||
|
||||
use NetBoxStore\Support;
|
||||
use RuntimeException;
|
||||
|
||||
final class State
|
||||
{
|
||||
/** @return array<string,mixed> */
|
||||
public static function empty(): array
|
||||
{
|
||||
return [
|
||||
'schemaVersion' => 1,
|
||||
'sources' => [],
|
||||
'plugins' => [],
|
||||
'releases' => [],
|
||||
'syncRuns' => [],
|
||||
'auditLog' => [],
|
||||
'authAttempts' => [],
|
||||
'meta' => ['createdAt' => Support::now(), 'updatedAt' => Support::now()],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
public static function validate(array &$state): void
|
||||
{
|
||||
if (($state['schemaVersion'] ?? null) !== 1) {
|
||||
throw new RuntimeException('Unsupported datastore schema version.');
|
||||
}
|
||||
foreach (['sources', 'plugins', 'releases', 'syncRuns', 'auditLog', 'authAttempts'] as $collection) {
|
||||
if (!isset($state[$collection]) || !is_array($state[$collection])) {
|
||||
throw new RuntimeException('Invalid datastore collection: ' . $collection);
|
||||
}
|
||||
}
|
||||
$state['meta'] ??= ['createdAt' => Support::now()];
|
||||
$state['meta']['updatedAt'] = Support::now();
|
||||
if (count($state['syncRuns']) > 2_000) {
|
||||
$state['syncRuns'] = array_slice($state['syncRuns'], -2_000);
|
||||
}
|
||||
if (count($state['auditLog']) > 10_000) {
|
||||
$state['auditLog'] = array_slice($state['auditLog'], -10_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Database;
|
||||
|
||||
interface StoreRepository
|
||||
{
|
||||
public function initialize(): void;
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function read(): array;
|
||||
|
||||
/**
|
||||
* Acquire a process-wide, non-blocking exclusive lease. The returned
|
||||
* object must remain alive for the complete protected operation.
|
||||
*/
|
||||
public function acquireLease(string $name): ?ExclusiveLease;
|
||||
|
||||
/**
|
||||
* The callback receives the draft by reference.
|
||||
*
|
||||
* @template T
|
||||
* @param callable(array<string,mixed>&):T $callback
|
||||
* @return T
|
||||
*/
|
||||
public function transaction(callable $callback): mixed;
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Domain;
|
||||
|
||||
use NetBoxStore\Support;
|
||||
use RuntimeException;
|
||||
|
||||
final class Approval
|
||||
{
|
||||
/** @param array<string,mixed> $plugin @param array<string,mixed> $release */
|
||||
public static function payload(array $plugin, array $release): array
|
||||
{
|
||||
return [
|
||||
'artifactSize' => (int) ($release['artifactSize'] ?? 0),
|
||||
'commitSha' => strtolower((string) ($release['commitSha'] ?? '')),
|
||||
'downloadUrl' => (string) ($release['downloadUrl'] ?? ''),
|
||||
'artifactKind' => (string) ($release['artifactKind'] ?? ''),
|
||||
'importName' => (string) ($plugin['importName'] ?? ''),
|
||||
'maxNetboxVersion' => (string) (($release['maxNetboxVersion'] ?? '') ?: ($plugin['maxNetboxVersion'] ?? '')),
|
||||
'minNetboxVersion' => (string) (($release['minNetboxVersion'] ?? '') ?: ($plugin['minNetboxVersion'] ?? '')),
|
||||
'packageName' => (string) ($plugin['packageName'] ?? ''),
|
||||
'pluginMaxNetboxVersion' => (string) ($plugin['maxNetboxVersion'] ?? ''),
|
||||
'pluginMinNetboxVersion' => (string) ($plugin['minNetboxVersion'] ?? ''),
|
||||
'sha256' => strtolower((string) ($release['sha256'] ?? '')),
|
||||
'version' => (string) ($release['version'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $plugin @param array<string,mixed> $release */
|
||||
public static function payloadHash(array $plugin, array $release): string
|
||||
{
|
||||
return hash('sha256', Support::canonicalJson(self::payload($plugin, $release)));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $release */
|
||||
public static function immutable(array $release): bool
|
||||
{
|
||||
return preg_match('/^[a-f0-9]{64}$/i', (string) ($release['sha256'] ?? '')) === 1
|
||||
&& (int) ($release['artifactSize'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $plugin @param array<string,mixed> $release */
|
||||
public static function current(array $plugin, array $release): bool
|
||||
{
|
||||
$approved = (string) ($release['approvedPayloadSha256'] ?? '');
|
||||
return ($release['status'] ?? '') === 'approved'
|
||||
&& preg_match('/^[a-f0-9]{64}$/', $approved) === 1
|
||||
&& hash_equals($approved, self::payloadHash($plugin, $release));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $plugin @return list<string> */
|
||||
public static function pluginErrors(array $plugin): array
|
||||
{
|
||||
$errors = [];
|
||||
if (trim((string) ($plugin['name'] ?? '')) === '') {
|
||||
$errors[] = 'Name fehlt.';
|
||||
}
|
||||
if (!preg_match('/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/', (string) ($plugin['slug'] ?? ''))) {
|
||||
$errors[] = 'Slug ist ungültig.';
|
||||
}
|
||||
if (!preg_match('/^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,126}[A-Za-z0-9])?$/', (string) ($plugin['packageName'] ?? ''))) {
|
||||
$errors[] = 'Package-Name fehlt oder ist ungültig.';
|
||||
}
|
||||
if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]{0,127}$/', (string) ($plugin['importName'] ?? ''))) {
|
||||
$errors[] = 'Importname fehlt oder ist ungültig.';
|
||||
}
|
||||
if (mb_strlen((string) ($plugin['description'] ?? '')) > 65_535) {
|
||||
$errors[] = 'Beschreibung ist zu lang.';
|
||||
}
|
||||
$minimum = Support::safeVersion($plugin['minNetboxVersion'] ?? '');
|
||||
$maximum = Support::safeVersion($plugin['maxNetboxVersion'] ?? '');
|
||||
if ($minimum === '') {
|
||||
$errors[] = 'Minimale NetBox-Version fehlt.';
|
||||
}
|
||||
if ($maximum === '') {
|
||||
$errors[] = 'Maximale NetBox-Version fehlt.';
|
||||
}
|
||||
if ($minimum !== '' && $maximum !== '' && version_compare($minimum, $maximum, '>')) {
|
||||
$errors[] = 'Minimale Version liegt über der Maximalversion.';
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $plugin @param array<string,mixed> $release @return list<string> */
|
||||
public static function releaseErrors(array $plugin, array $release): array
|
||||
{
|
||||
$errors = self::pluginErrors($plugin);
|
||||
$version = Support::safeVersion($release['version'] ?? '');
|
||||
if ($version === '' || $version !== (string) ($release['version'] ?? '')) {
|
||||
$errors[] = 'Release-Version ist ungültig oder nicht normalisiert.';
|
||||
}
|
||||
$url = parse_url((string) ($release['downloadUrl'] ?? ''));
|
||||
if (!is_array($url) || ($url['scheme'] ?? '') !== 'https' || empty($url['host']) || isset($url['user']) || isset($url['pass'])) {
|
||||
$errors[] = 'Download-URL muss credential-freies HTTPS sein.';
|
||||
} else {
|
||||
$errors = array_merge($errors, self::wheelErrors($plugin, $release, (string) ($url['path'] ?? '')));
|
||||
}
|
||||
$commit = (string) ($release['commitSha'] ?? '');
|
||||
if ($commit !== '' && preg_match('/^[a-f0-9]{40}$/', $commit) !== 1) {
|
||||
$errors[] = 'Commit-SHA muss leer oder exakt 40-stellig und kleingeschrieben sein.';
|
||||
}
|
||||
$minimum = Support::safeVersion($release['minNetboxVersion'] ?? $plugin['minNetboxVersion'] ?? '');
|
||||
$maximum = Support::safeVersion($release['maxNetboxVersion'] ?? $plugin['maxNetboxVersion'] ?? '');
|
||||
if ($minimum === '' || $maximum === '' || version_compare($minimum, $maximum, '>')) {
|
||||
$errors[] = 'Release-Kompatibilitätsgrenzen sind ungültig.';
|
||||
}
|
||||
if (!self::immutable($release)) {
|
||||
$errors[] = 'Artefakt-SHA oder Größe fehlt.';
|
||||
}
|
||||
if (($release['artifactKind'] ?? '') !== 'wheel') {
|
||||
$errors[] = 'Im API-v1-Katalog sind ausschließlich Wheel-Artefakte freigabefähig.';
|
||||
}
|
||||
if (!empty($release['draft']) || !empty($release['withdrawn'])) {
|
||||
$errors[] = 'Drafts oder zurückgezogene Releases sind nicht freigabefähig.';
|
||||
}
|
||||
return array_values(array_unique($errors));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $release */
|
||||
public static function resetRelease(array &$release, string $note): void
|
||||
{
|
||||
$release['status'] = 'pending';
|
||||
$release['approvedAt'] = null;
|
||||
$release['approvedBy'] = null;
|
||||
$release['approvedPayloadSha256'] = '';
|
||||
$release['moderationNote'] = $note;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
public static function approve(array &$state, string $collection, string $id, string $actor): void
|
||||
{
|
||||
if (!in_array($collection, ['sources', 'plugins', 'releases'], true)) {
|
||||
throw new RuntimeException('Ungültiger Objekttyp.');
|
||||
}
|
||||
$index = self::findIndex($state[$collection], $id);
|
||||
if ($index === null) {
|
||||
throw new RuntimeException('Objekt wurde nicht gefunden.');
|
||||
}
|
||||
if ($collection === 'plugins') {
|
||||
$errors = self::pluginErrors($state['plugins'][$index]);
|
||||
if ($errors !== []) {
|
||||
throw new RuntimeException('Plugin kann nicht freigegeben werden: ' . implode(' ', $errors));
|
||||
}
|
||||
}
|
||||
if ($collection === 'releases') {
|
||||
$release = &$state['releases'][$index];
|
||||
$pluginIndex = self::findIndex($state['plugins'], (string) $release['pluginId']);
|
||||
if ($pluginIndex === null) {
|
||||
throw new RuntimeException('Zugehöriges Plugin wurde nicht gefunden.');
|
||||
}
|
||||
$errors = self::releaseErrors($state['plugins'][$pluginIndex], $release);
|
||||
if ($errors !== []) {
|
||||
throw new RuntimeException('Release kann nicht freigegeben werden: ' . implode(' ', $errors));
|
||||
}
|
||||
$approvedCount = 0;
|
||||
foreach ($state['releases'] as $other) {
|
||||
if (($other['id'] ?? '') !== $id
|
||||
&& ($other['pluginId'] ?? '') === ($release['pluginId'] ?? '')
|
||||
&& ($other['version'] ?? '') === ($release['version'] ?? '')
|
||||
&& self::current($state['plugins'][$pluginIndex], $other)) {
|
||||
throw new RuntimeException('Für diese Plugin-Version existiert bereits ein freigegebenes Artefakt.');
|
||||
}
|
||||
if (($other['id'] ?? '') !== $id
|
||||
&& ($other['pluginId'] ?? '') === ($release['pluginId'] ?? '')
|
||||
&& self::current($state['plugins'][$pluginIndex], $other)) {
|
||||
$approvedCount++;
|
||||
}
|
||||
}
|
||||
if ($approvedCount >= 1_000) {
|
||||
throw new RuntimeException('Pro Plugin sind höchstens 1.000 freigegebene Releases zulässig.');
|
||||
}
|
||||
$release['status'] = 'approved';
|
||||
$release['approvedAt'] = Support::now();
|
||||
$release['approvedBy'] = $actor;
|
||||
$release['approvedPayloadSha256'] = self::payloadHash($state['plugins'][$pluginIndex], $release);
|
||||
$release['moderationNote'] = '';
|
||||
return;
|
||||
}
|
||||
$state[$collection][$index]['status'] = 'approved';
|
||||
$state[$collection][$index]['approvedAt'] = Support::now();
|
||||
$state[$collection][$index]['approvedBy'] = $actor;
|
||||
$state[$collection][$index]['moderationNote'] = '';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
public static function reject(array &$state, string $collection, string $id, string $actor): void
|
||||
{
|
||||
if (!in_array($collection, ['sources', 'plugins', 'releases'], true)) {
|
||||
throw new RuntimeException('Ungültiger Objekttyp.');
|
||||
}
|
||||
$index = self::findIndex($state[$collection], $id);
|
||||
if ($index === null) {
|
||||
throw new RuntimeException('Objekt wurde nicht gefunden.');
|
||||
}
|
||||
$state[$collection][$index]['status'] = 'rejected';
|
||||
$state[$collection][$index]['approvedAt'] = null;
|
||||
$state[$collection][$index]['approvedBy'] = null;
|
||||
$state[$collection][$index]['approvedPayloadSha256'] = '';
|
||||
$state[$collection][$index]['moderationNote'] = 'Von ' . $actor . ' abgelehnt.';
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state @param array<string,mixed> $details */
|
||||
public static function audit(array &$state, string $actor, string $action, string $targetType, string $targetId, string $ip, array $details = []): void
|
||||
{
|
||||
$state['auditLog'][] = [
|
||||
'id' => Support::uuid(),
|
||||
'timestamp' => Support::now(),
|
||||
'actor' => $actor,
|
||||
'action' => $action,
|
||||
'targetType' => $targetType,
|
||||
'targetId' => $targetId,
|
||||
'ip' => Support::clip($ip, 100),
|
||||
'details' => $details,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $items */
|
||||
private static function findIndex(array $items, string $id): ?int
|
||||
{
|
||||
foreach ($items as $index => $item) {
|
||||
if (($item['id'] ?? '') === $id) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $plugin @param array<string,mixed> $release @return list<string> */
|
||||
private static function wheelErrors(array $plugin, array $release, string $path): array
|
||||
{
|
||||
$filename = basename($path);
|
||||
if ($filename === '' || strlen($filename) > 255 || !str_ends_with($filename, '.whl')
|
||||
|| preg_match('/^[A-Za-z0-9_!+.]+(?:-[A-Za-z0-9_!+.]+){4,5}\.whl$/', $filename) !== 1) {
|
||||
return ['Download-URL enthält keinen sicheren, gültigen Wheel-Dateinamen.'];
|
||||
}
|
||||
$parts = explode('-', substr($filename, 0, -4));
|
||||
if (!in_array(count($parts), [5, 6], true)) {
|
||||
return ['Wheel-Dateiname entspricht nicht dem Wheel-Standard.'];
|
||||
}
|
||||
[$distribution, $wheelVersion] = $parts;
|
||||
$build = count($parts) === 6 ? $parts[2] : null;
|
||||
$tags = count($parts) === 6 ? array_slice($parts, 3) : array_slice($parts, 2);
|
||||
if (preg_match('/^[A-Za-z0-9_]+$/', $distribution) !== 1
|
||||
|| ($build !== null && preg_match('/^[0-9][A-Za-z0-9_]*$/', $build) !== 1)
|
||||
|| count($tags) !== 3
|
||||
|| array_filter($tags, static fn (string $tag): bool => preg_match('/^[A-Za-z0-9_]+(?:\.[A-Za-z0-9_]+)*$/', $tag) !== 1) !== []) {
|
||||
return ['Wheel-Dateiname entspricht nicht dem Wheel-Standard.'];
|
||||
}
|
||||
$errors = [];
|
||||
$canonical = static fn (string $name): string => strtolower((string) preg_replace('/[-_.]+/', '-', $name));
|
||||
if ($canonical($distribution) !== $canonical((string) ($plugin['packageName'] ?? ''))) {
|
||||
$errors[] = 'Wheel-Distribution stimmt nicht mit dem Package-Namen überein.';
|
||||
}
|
||||
$normalizedWheelVersion = Support::safeVersion($wheelVersion);
|
||||
if ($normalizedWheelVersion === '' || $normalizedWheelVersion !== (string) ($release['version'] ?? '')) {
|
||||
$errors[] = 'Wheel-Version stimmt nicht mit der Release-Version überein.';
|
||||
}
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Domain;
|
||||
|
||||
use NetBoxStore\Support;
|
||||
|
||||
final class Catalog
|
||||
{
|
||||
/** @param array<string,mixed> $state @param array<string,string> $filters @return list<array<string,mixed>> */
|
||||
public static function approvedPlugins(array $state, array $filters = []): array
|
||||
{
|
||||
$sources = [];
|
||||
foreach ($state['sources'] as $source) {
|
||||
$sources[$source['id']] = $source;
|
||||
}
|
||||
$query = mb_strtolower(trim($filters['query'] ?? ''));
|
||||
$plugins = array_values(array_filter($state['plugins'], static function (array $plugin) use ($sources, $filters, $query): bool {
|
||||
$source = $sources[$plugin['sourceId']] ?? null;
|
||||
if ($source === null || ($source['status'] ?? '') !== 'approved' || empty($source['active'])
|
||||
|| ($plugin['status'] ?? '') !== 'approved' || empty($plugin['active']) || !empty($plugin['archived'])
|
||||
|| Approval::pluginErrors($plugin) !== []) {
|
||||
return false;
|
||||
}
|
||||
if (($filters['source'] ?? '') !== '' && $source['slug'] !== $filters['source']) {
|
||||
return false;
|
||||
}
|
||||
if (($filters['netboxVersion'] ?? '') !== '' && !self::supports($plugin, $filters['netboxVersion'])) {
|
||||
return false;
|
||||
}
|
||||
if ($query !== '') {
|
||||
$haystack = mb_strtolower(implode(' ', [
|
||||
$plugin['name'] ?? '', $plugin['summary'] ?? '', $plugin['description'] ?? '',
|
||||
$plugin['packageName'] ?? '', $plugin['repositoryName'] ?? '',
|
||||
]));
|
||||
return str_contains($haystack, $query);
|
||||
}
|
||||
return true;
|
||||
}));
|
||||
usort($plugins, static fn (array $a, array $b): int => strnatcasecmp($a['name'], $b['name']));
|
||||
return $plugins;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
public static function findPlugin(array $state, string $slug): ?array
|
||||
{
|
||||
foreach (self::approvedPlugins($state) as $plugin) {
|
||||
if ($plugin['slug'] === $slug) {
|
||||
return $plugin;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state @param array<string,mixed> $plugin @return list<array<string,mixed>> */
|
||||
public static function releases(array $state, array $plugin): array
|
||||
{
|
||||
$source = null;
|
||||
foreach ($state['sources'] as $candidate) {
|
||||
if ($candidate['id'] === $plugin['sourceId']) {
|
||||
$source = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($source === null || ($source['status'] ?? '') !== 'approved' || empty($source['active'])) {
|
||||
return [];
|
||||
}
|
||||
$releases = array_values(array_filter($state['releases'], static function (array $release) use ($plugin): bool {
|
||||
if (($release['pluginId'] ?? '') !== $plugin['id'] || Approval::releaseErrors($plugin, $release) !== [] || !Approval::current($plugin, $release)) {
|
||||
return false;
|
||||
}
|
||||
return filter_var($release['downloadUrl'] ?? '', FILTER_VALIDATE_URL) !== false
|
||||
&& str_starts_with((string) $release['downloadUrl'], 'https://');
|
||||
}));
|
||||
usort($releases, static function (array $a, array $b): int {
|
||||
$version = version_compare((string) $b['version'], (string) $a['version']);
|
||||
return $version !== 0 ? $version : strcmp((string) ($b['publishedAt'] ?? ''), (string) ($a['publishedAt'] ?? ''));
|
||||
});
|
||||
// One version maps to exactly one detail URL in the public API. Should
|
||||
// inconsistent legacy data exist, keep only the newest approved payload.
|
||||
$unique = [];
|
||||
foreach ($releases as $release) {
|
||||
$unique[$release['version']] ??= $release;
|
||||
}
|
||||
return array_slice(array_values($unique), 0, 1_000);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $plugin @param array<string,mixed> $release @return array<string,mixed> */
|
||||
public static function serializeRelease(array $plugin, array $release): array
|
||||
{
|
||||
return [
|
||||
'version' => (string) $release['version'],
|
||||
'download_url' => (string) $release['downloadUrl'],
|
||||
'sha256' => strtolower((string) $release['sha256']),
|
||||
'artifact_size' => (int) $release['artifactSize'],
|
||||
'commit_sha' => (string) ($release['commitSha'] ?? ''),
|
||||
'min_netbox_version' => (string) (($release['minNetboxVersion'] ?? '') ?: ($plugin['minNetboxVersion'] ?? '')),
|
||||
'max_netbox_version' => (string) (($release['maxNetboxVersion'] ?? '') ?: ($plugin['maxNetboxVersion'] ?? '')),
|
||||
'published_at' => $release['publishedAt'] ?? null,
|
||||
'approved' => true,
|
||||
'status' => 'approved',
|
||||
'immutable' => true,
|
||||
'approved_payload_sha256' => (string) $release['approvedPayloadSha256'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state @param array<string,mixed> $plugin @return array<string,mixed> */
|
||||
public static function serializePlugin(array $state, array $plugin): array
|
||||
{
|
||||
$releases = self::releases($state, $plugin);
|
||||
return [
|
||||
'api_version' => 'v1',
|
||||
'slug' => (string) $plugin['slug'],
|
||||
'name' => (string) $plugin['name'],
|
||||
'summary' => (string) ($plugin['summary'] ?? ''),
|
||||
'description' => (string) ($plugin['description'] ?? ''),
|
||||
'repository_url' => (string) $plugin['repositoryUrl'],
|
||||
'latest_version' => isset($releases[0]['version']) ? (string) $releases[0]['version'] : null,
|
||||
'package_name' => (string) $plugin['packageName'],
|
||||
'import_name' => (string) $plugin['importName'],
|
||||
'min_netbox_version' => (string) $plugin['minNetboxVersion'],
|
||||
'max_netbox_version' => (string) $plugin['maxNetboxVersion'],
|
||||
'approved' => true,
|
||||
'status' => 'approved',
|
||||
'releases' => array_map(static fn (array $release): array => self::serializeRelease($plugin, $release), $releases),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $entity */
|
||||
private static function supports(array $entity, string $version): bool
|
||||
{
|
||||
$requested = Support::safeVersion($version);
|
||||
return $requested !== ''
|
||||
&& (($entity['minNetboxVersion'] ?? '') === '' || version_compare($requested, $entity['minNetboxVersion'], '>='))
|
||||
&& (($entity['maxNetboxVersion'] ?? '') === '' || version_compare($requested, $entity['maxNetboxVersion'], '<='));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Http;
|
||||
|
||||
use NetBoxStore\Config;
|
||||
use NetBoxStore\Database\StoreRepository;
|
||||
use NetBoxStore\Domain\Approval;
|
||||
use NetBoxStore\Domain\Catalog;
|
||||
use NetBoxStore\Security\Auth;
|
||||
use NetBoxStore\Security\SsrfGuard;
|
||||
use NetBoxStore\Support;
|
||||
use NetBoxStore\Sync\SyncService;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class Application
|
||||
{
|
||||
private View $view;
|
||||
|
||||
public function __construct(
|
||||
private readonly Config $config,
|
||||
private readonly StoreRepository $repository,
|
||||
private readonly Auth $auth,
|
||||
private readonly SyncService $sync,
|
||||
private readonly SsrfGuard $guard,
|
||||
) {
|
||||
$this->view = new View($config);
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$response = $this->dispatch($request);
|
||||
} catch (Throwable $exception) {
|
||||
error_log(sprintf('store request failed method=%s path=%s error=%s', $request->method, $request->path, $exception->getMessage()));
|
||||
$response = str_starts_with($request->path, '/api/')
|
||||
? Response::json(['error' => ['status' => 500, 'message' => 'Interner Serverfehler.']], 500)
|
||||
: $this->viewResponse('error', ['title' => 'Interner Serverfehler', 'status' => 500, 'message' => 'Die Anfrage konnte nicht verarbeitet werden.'], 500, $request);
|
||||
}
|
||||
if (($request->headers['if-none-match'] ?? '') !== '' && ($response->headers['ETag'] ?? '') === $request->headers['if-none-match'] && $response->status === 200) {
|
||||
$response = new Response('', 304, ['ETag' => $response->headers['ETag'], 'Cache-Control' => $response->headers['Cache-Control'] ?? 'public, max-age=60']);
|
||||
}
|
||||
return $this->secure($response);
|
||||
}
|
||||
|
||||
private function dispatch(Request $request): Response
|
||||
{
|
||||
if ($request->method === 'GET' && $request->path === '/healthz') {
|
||||
$this->repository->read();
|
||||
return Response::json(['status' => 'ok'], 200, ['Cache-Control' => 'no-store']);
|
||||
}
|
||||
if (str_starts_with($request->path, '/api/v1/')) {
|
||||
return $this->api($request);
|
||||
}
|
||||
if ($request->path === '/admin' || str_starts_with($request->path, '/admin/')) {
|
||||
return $this->admin($request);
|
||||
}
|
||||
if ($request->method === 'GET' && $request->path === '/') {
|
||||
return $this->home($request);
|
||||
}
|
||||
if ($request->method === 'GET' && preg_match('#^/plugins/([^/]+)$#', $request->path, $match)) {
|
||||
return $this->plugin($request, $match[1]);
|
||||
}
|
||||
if ($request->method === 'GET' && $request->path === '/robots.txt') {
|
||||
return new Response("User-agent: *\nAllow: /\nDisallow: /admin/\n", 200, ['Content-Type' => 'text/plain; charset=utf-8']);
|
||||
}
|
||||
return $this->viewResponse('error', ['title' => 'Seite nicht gefunden', 'status' => 404, 'message' => 'Die angeforderte Seite existiert nicht.'], 404, $request);
|
||||
}
|
||||
|
||||
private function api(Request $request): Response
|
||||
{
|
||||
if ($request->method !== 'GET') {
|
||||
return $this->apiError(405, 'Methode nicht erlaubt.');
|
||||
}
|
||||
if ($request->path === '/api/v1/plugins') {
|
||||
$page = filter_var($request->query['page'] ?? 1, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
$requestedSize = filter_var($request->query['page_size'] ?? $this->config->pagination['apiPageSize'], FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
if ($page === false || $requestedSize === false) {
|
||||
return $this->apiError(400, 'page und page_size müssen positive Ganzzahlen sein.');
|
||||
}
|
||||
$pageSize = min($requestedSize, $this->config->pagination['apiMaxPageSize']);
|
||||
$netbox = ($request->query['netbox_version'] ?? '') !== '' ? Support::safeVersion($request->query['netbox_version']) : '';
|
||||
if (($request->query['netbox_version'] ?? '') !== '' && $netbox === '') {
|
||||
return $this->apiError(400, 'netbox_version ist ungültig.');
|
||||
}
|
||||
$state = $this->repository->read();
|
||||
$plugins = Catalog::approvedPlugins($state, [
|
||||
'query' => (string) ($request->query['q'] ?? ''),
|
||||
'source' => (string) ($request->query['source'] ?? ''),
|
||||
'netboxVersion' => $netbox,
|
||||
]);
|
||||
$count = count($plugins);
|
||||
$pages = max(1, (int) ceil($count / $pageSize));
|
||||
if ($page > $pages) {
|
||||
return $this->apiError(404, 'Diese Ergebnisseite existiert nicht.');
|
||||
}
|
||||
$slice = array_slice($plugins, ($page - 1) * $pageSize, $pageSize);
|
||||
return Response::json([
|
||||
'api_version' => 'v1', 'count' => $count, 'page' => $page, 'page_size' => $pageSize,
|
||||
'next' => $page < $pages ? $this->apiPageUrl($request, $page + 1) : null,
|
||||
'previous' => $page > 1 ? $this->apiPageUrl($request, $page - 1) : null,
|
||||
'results' => array_map(static fn (array $plugin): array => Catalog::serializePlugin($state, $plugin), $slice),
|
||||
]);
|
||||
}
|
||||
if (preg_match('#^/api/v1/plugins/([^/]+)/releases/([^/]+)$#', $request->path, $match)) {
|
||||
$state = $this->repository->read();
|
||||
$plugin = Catalog::findPlugin($state, $match[1]);
|
||||
if ($plugin === null) {
|
||||
return $this->apiError(404, 'Plugin wurde nicht gefunden.');
|
||||
}
|
||||
foreach (Catalog::releases($state, $plugin) as $release) {
|
||||
if ($release['version'] === $match[2]) {
|
||||
return Response::json(Catalog::serializeRelease($plugin, $release));
|
||||
}
|
||||
}
|
||||
return $this->apiError(404, 'Release wurde nicht gefunden.');
|
||||
}
|
||||
if (preg_match('#^/api/v1/plugins/([^/]+)$#', $request->path, $match)) {
|
||||
$state = $this->repository->read();
|
||||
$plugin = Catalog::findPlugin($state, $match[1]);
|
||||
return $plugin === null
|
||||
? $this->apiError(404, 'Plugin wurde nicht gefunden.')
|
||||
: Response::json(Catalog::serializePlugin($state, $plugin));
|
||||
}
|
||||
return $this->apiError(404, 'Nicht gefunden.');
|
||||
}
|
||||
|
||||
private function home(Request $request): Response
|
||||
{
|
||||
$state = $this->repository->read();
|
||||
$netbox = ($request->query['netbox_version'] ?? '') !== '' ? Support::safeVersion($request->query['netbox_version']) : '';
|
||||
$all = Catalog::approvedPlugins($state);
|
||||
$filtered = Catalog::approvedPlugins($state, [
|
||||
'query' => (string) ($request->query['q'] ?? ''),
|
||||
'source' => (string) ($request->query['source'] ?? ''),
|
||||
'netboxVersion' => $netbox,
|
||||
]);
|
||||
$page = max(1, (int) ($request->query['page'] ?? 1));
|
||||
$pages = max(1, (int) ceil(count($filtered) / $this->config->pagination['pageSize']));
|
||||
$page = min($page, $pages);
|
||||
$sourcesById = array_column($state['sources'], null, 'id');
|
||||
$cards = [];
|
||||
foreach (array_slice($filtered, ($page - 1) * $this->config->pagination['pageSize'], $this->config->pagination['pageSize']) as $plugin) {
|
||||
$releases = Catalog::releases($state, $plugin);
|
||||
$plugin['source'] = $sourcesById[$plugin['sourceId']] ?? [];
|
||||
$plugin['latestRelease'] = $releases[0] ?? null;
|
||||
$cards[] = $plugin;
|
||||
}
|
||||
$sourceIds = array_unique(array_column($all, 'sourceId'));
|
||||
$sources = array_values(array_filter($state['sources'], static fn (array $source): bool => in_array($source['id'], $sourceIds, true)));
|
||||
return $this->viewResponse('home', [
|
||||
'title' => 'NetBox Plugin Store', 'plugins' => $cards, 'sources' => $sources,
|
||||
'query' => (string) ($request->query['q'] ?? ''), 'selectedSource' => (string) ($request->query['source'] ?? ''),
|
||||
'selectedNetboxVersion' => (string) ($request->query['netbox_version'] ?? ''),
|
||||
'invalidVersion' => ($request->query['netbox_version'] ?? '') !== '' && $netbox === '',
|
||||
'count' => count($filtered), 'totalCount' => count($all), 'page' => $page, 'pages' => $pages,
|
||||
], 200, $request);
|
||||
}
|
||||
|
||||
private function plugin(Request $request, string $slug): Response
|
||||
{
|
||||
$state = $this->repository->read();
|
||||
$plugin = Catalog::findPlugin($state, $slug);
|
||||
if ($plugin === null) {
|
||||
return $this->viewResponse('error', ['title' => 'Plugin nicht gefunden', 'status' => 404, 'message' => 'Dieses Plugin ist nicht vorhanden oder noch nicht freigegeben.'], 404, $request);
|
||||
}
|
||||
$sources = array_column($state['sources'], null, 'id');
|
||||
return $this->viewResponse('plugin', [
|
||||
'title' => $plugin['name'] . ' – NetBox Plugin Store', 'plugin' => $plugin,
|
||||
'source' => $sources[$plugin['sourceId']], 'releases' => Catalog::releases($state, $plugin),
|
||||
], 200, $request);
|
||||
}
|
||||
|
||||
private function admin(Request $request): Response
|
||||
{
|
||||
if (!$this->auth->enabled()) {
|
||||
return $this->viewResponse('error', ['title' => 'Administration nicht konfiguriert', 'status' => 503, 'message' => 'Setze Admin-Benutzer, Argon2id-Hash und Session-Secret vollständig.'], 503, $request);
|
||||
}
|
||||
if ($request->method === 'GET' && $request->path === '/admin/login') {
|
||||
return $this->viewResponse('admin/login', ['title' => 'Admin-Anmeldung', 'error' => '', 'csrf' => $this->auth->csrfToken()], 200, $request);
|
||||
}
|
||||
if ($request->method === 'POST' && !$this->auth->verifyCsrf($request->body['_csrf'] ?? null)) {
|
||||
return $this->viewResponse('error', ['title' => 'Ungültige Anfrage', 'status' => 403, 'message' => 'Das CSRF-Token fehlt oder ist abgelaufen.'], 403, $request);
|
||||
}
|
||||
if ($request->method === 'POST' && $request->path === '/admin/login') {
|
||||
try {
|
||||
if ($this->auth->attempt($request->ip, (string) ($request->body['username'] ?? ''), (string) ($request->body['password'] ?? ''))) {
|
||||
return Response::redirect('/admin');
|
||||
}
|
||||
return $this->viewResponse('admin/login', ['title' => 'Admin-Anmeldung', 'error' => 'Benutzername oder Passwort ist falsch.', 'csrf' => $this->auth->csrfToken()], 401, $request);
|
||||
} catch (RuntimeException $exception) {
|
||||
return $this->viewResponse('admin/login', ['title' => 'Admin-Anmeldung', 'error' => $exception->getMessage(), 'csrf' => $this->auth->csrfToken()], 429, $request);
|
||||
}
|
||||
}
|
||||
if (!$this->auth->loggedIn()) {
|
||||
return Response::redirect('/admin/login');
|
||||
}
|
||||
if ($request->method === 'POST' && $request->path === '/admin/logout') {
|
||||
$this->auth->logout();
|
||||
return Response::redirect('/admin/login');
|
||||
}
|
||||
if ($request->method === 'GET' && $request->path === '/admin') {
|
||||
return $this->dashboard($request);
|
||||
}
|
||||
if ($request->method === 'POST' && $request->path === '/admin/sources') {
|
||||
return $this->createSource($request);
|
||||
}
|
||||
if ($request->method === 'POST' && preg_match('#^/admin/plugins/([^/]+)/edit$#', $request->path, $match)) {
|
||||
return $this->editPlugin($request, $match[1]);
|
||||
}
|
||||
if ($request->method === 'POST' && preg_match('#^/admin/(sources|plugins|releases)/([^/]+)/(approve|reject|resync)$#', $request->path, $match)) {
|
||||
return $this->adminAction($request, $match[1], $match[2], $match[3]);
|
||||
}
|
||||
return $this->viewResponse('error', ['title' => 'Admin-Seite nicht gefunden', 'status' => 404, 'message' => 'Diese Admin-Aktion existiert nicht.'], 404, $request);
|
||||
}
|
||||
|
||||
private function dashboard(Request $request): Response
|
||||
{
|
||||
$state = $this->repository->read();
|
||||
$sources = array_column($state['sources'], null, 'id');
|
||||
$pluginsById = array_column($state['plugins'], null, 'id');
|
||||
$plugins = array_map(static function (array $plugin) use ($sources): array {
|
||||
$plugin['source'] = $sources[$plugin['sourceId']] ?? [];
|
||||
return $plugin;
|
||||
}, $state['plugins']);
|
||||
$releases = array_map(static function (array $release) use ($pluginsById): array {
|
||||
$release['plugin'] = $pluginsById[$release['pluginId']] ?? [];
|
||||
return $release;
|
||||
}, $state['releases']);
|
||||
usort($plugins, static fn (array $a, array $b): int => (($a['status'] === 'pending' ? 0 : 1) <=> ($b['status'] === 'pending' ? 0 : 1)) ?: strnatcasecmp($a['name'], $b['name']));
|
||||
usort($releases, static fn (array $a, array $b): int => (($a['status'] === 'pending' ? 0 : 1) <=> ($b['status'] === 'pending' ? 0 : 1)) ?: strcmp((string) ($b['publishedAt'] ?? ''), (string) ($a['publishedAt'] ?? '')));
|
||||
return $this->viewResponse('admin/dashboard', [
|
||||
'title' => 'Store-Administration', 'csrf' => $this->auth->csrfToken(),
|
||||
'ok' => (string) ($request->query['ok'] ?? ''), 'error' => (string) ($request->query['error'] ?? ''),
|
||||
'sources' => array_values($state['sources']), 'plugins' => $plugins, 'releases' => $releases,
|
||||
'runs' => array_reverse(array_slice($state['syncRuns'], -25)), 'audits' => array_reverse(array_slice($state['auditLog'], -25)),
|
||||
], 200, $request);
|
||||
}
|
||||
|
||||
private function createSource(Request $request): Response
|
||||
{
|
||||
try {
|
||||
$provider = strtolower((string) ($request->body['provider'] ?? ''));
|
||||
$ownerKind = strtolower((string) ($request->body['owner_kind'] ?? 'user'));
|
||||
$name = Support::clip(trim((string) ($request->body['name'] ?? '')), 120);
|
||||
$slug = Support::slug((string) ($request->body['slug'] ?? $name));
|
||||
$owner = Support::clip(trim((string) ($request->body['owner'] ?? '')), 120);
|
||||
$baseUrl = rtrim((string) ($request->body['base_url'] ?? ''), '/');
|
||||
$apiUrl = rtrim((string) ($request->body['api_url'] ?? ($provider === 'github' ? 'https://api.github.com' : $baseUrl . '/api/v1')), '/');
|
||||
$tokenEnv = Support::clip(trim((string) ($request->body['token_env'] ?? '')), 100);
|
||||
if (!in_array($provider, ['forgejo', 'github'], true) || !in_array($ownerKind, ['user', 'organization', 'auto'], true)
|
||||
|| $name === '' || $slug === '' || !preg_match('/^[A-Za-z0-9_.-]+$/', $owner)
|
||||
|| ($tokenEnv !== '' && !preg_match('/^[A-Z][A-Z0-9_]*$/', $tokenEnv))) {
|
||||
throw new RuntimeException('Quellenangaben sind unvollständig oder ungültig.');
|
||||
}
|
||||
$this->guard->assertConfiguredUrl($baseUrl, 'Basis-URL');
|
||||
$this->guard->assertConfiguredUrl($apiUrl, 'API-URL');
|
||||
$this->repository->transaction(function (array &$state) use ($request, $provider, $ownerKind, $name, $slug, $owner, $baseUrl, $apiUrl, $tokenEnv): void {
|
||||
if (array_filter($state['sources'], static fn (array $source): bool => $source['slug'] === $slug)) {
|
||||
throw new RuntimeException('Dieser Source-Slug existiert bereits.');
|
||||
}
|
||||
$source = [
|
||||
'id' => Support::uuid(), 'name' => $name, 'slug' => $slug, 'provider' => $provider,
|
||||
'baseUrl' => $baseUrl, 'apiUrl' => $apiUrl, 'owner' => $owner, 'ownerKind' => $ownerKind,
|
||||
'tokenEnv' => $tokenEnv, 'topic' => Support::clip($request->body['topic'] ?? 'netbox-plugin', 80),
|
||||
'status' => 'pending', 'active' => true, 'includeForks' => false, 'includeArchived' => false,
|
||||
'autoApprovePlugins' => false,
|
||||
'approvedAt' => null, 'approvedBy' => null, 'moderationNote' => '', 'lastSyncedAt' => null,
|
||||
'createdAt' => Support::now(), 'updatedAt' => Support::now(),
|
||||
];
|
||||
$state['sources'][] = $source;
|
||||
Approval::audit($state, $this->auth->username(), 'create', 'source', $source['id'], $request->ip, ['slug' => $slug, 'provider' => $provider]);
|
||||
});
|
||||
return $this->adminRedirect('ok', 'Quelle wurde als ausstehend angelegt.');
|
||||
} catch (Throwable $exception) {
|
||||
return $this->adminRedirect('error', $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function editPlugin(Request $request, string $id): Response
|
||||
{
|
||||
try {
|
||||
$this->repository->transaction(function (array &$state) use ($request, $id): void {
|
||||
$index = $this->findIndex($state['plugins'], $id);
|
||||
if ($index === null) {
|
||||
throw new RuntimeException('Plugin wurde nicht gefunden.');
|
||||
}
|
||||
$candidate = $state['plugins'][$index];
|
||||
$candidate['name'] = Support::clip(trim((string) ($request->body['name'] ?? '')), 180);
|
||||
$candidate['slug'] = Support::slug((string) ($request->body['slug'] ?? ''));
|
||||
$candidate['summary'] = Support::clip($request->body['summary'] ?? '', 320);
|
||||
$candidate['description'] = Support::clip($request->body['description'] ?? '', 65_535);
|
||||
$candidate['packageName'] = Support::clip(trim((string) ($request->body['package_name'] ?? '')), 128);
|
||||
$candidate['importName'] = Support::clip(trim((string) ($request->body['import_name'] ?? '')), 128);
|
||||
$candidate['minNetboxVersion'] = Support::safeVersion($request->body['min_netbox_version'] ?? '');
|
||||
$candidate['maxNetboxVersion'] = Support::safeVersion($request->body['max_netbox_version'] ?? '');
|
||||
$errors = Approval::pluginErrors($candidate);
|
||||
if ($errors !== []) {
|
||||
throw new RuntimeException(implode(' ', $errors));
|
||||
}
|
||||
foreach ($state['plugins'] as $other) {
|
||||
if ($other['id'] !== $id && $other['slug'] === $candidate['slug']) {
|
||||
throw new RuntimeException('Dieser Plugin-Slug existiert bereits.');
|
||||
}
|
||||
}
|
||||
$securityChanged = serialize(array_intersect_key($state['plugins'][$index], array_flip(['packageName', 'importName', 'minNetboxVersion', 'maxNetboxVersion'])))
|
||||
!== serialize(array_intersect_key($candidate, array_flip(['packageName', 'importName', 'minNetboxVersion', 'maxNetboxVersion'])));
|
||||
$candidate['metadataOverrides'] = array_intersect_key($candidate, array_flip(['name', 'summary', 'description', 'packageName', 'importName', 'minNetboxVersion', 'maxNetboxVersion']));
|
||||
$candidate['updatedAt'] = Support::now();
|
||||
$state['plugins'][$index] = $candidate;
|
||||
if ($securityChanged) {
|
||||
foreach ($state['releases'] as &$release) {
|
||||
if ($release['pluginId'] === $id) {
|
||||
Approval::resetRelease($release, 'Admin hat sicherheitsrelevante Plugin-Metadaten geändert.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Approval::audit($state, $this->auth->username(), 'edit', 'plugin', $id, $request->ip, ['securityFieldsChanged' => $securityChanged]);
|
||||
});
|
||||
return $this->adminRedirect('ok', 'Plugin-Metadaten wurden gespeichert.');
|
||||
} catch (Throwable $exception) {
|
||||
return $this->adminRedirect('error', $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function adminAction(Request $request, string $collection, string $id, string $action): Response
|
||||
{
|
||||
try {
|
||||
if ($action === 'resync') {
|
||||
if (function_exists('set_time_limit')) {
|
||||
@set_time_limit(600);
|
||||
}
|
||||
$state = $this->repository->read();
|
||||
$sourceId = $id;
|
||||
if ($collection === 'plugins') {
|
||||
$sourceId = $this->find($state['plugins'], $id)['sourceId'] ?? '';
|
||||
} elseif ($collection === 'releases') {
|
||||
$release = $this->find($state['releases'], $id);
|
||||
$sourceId = $this->find($state['plugins'], (string) ($release['pluginId'] ?? ''))['sourceId'] ?? '';
|
||||
}
|
||||
if ($sourceId === '') {
|
||||
throw new RuntimeException('Zugehörige Quelle wurde nicht gefunden.');
|
||||
}
|
||||
$run = $this->sync->syncSource($sourceId, trigger: 'admin');
|
||||
$this->repository->transaction(function (array &$draft) use ($request, $collection, $id, $run): void {
|
||||
Approval::audit($draft, $this->auth->username(), 'resync', rtrim($collection, 's'), $id, $request->ip, ['runId' => $run['id'], 'status' => $run['status']]);
|
||||
});
|
||||
return $this->adminRedirect($run['status'] === 'failed' ? 'error' : 'ok', 'Synchronisierung beendet: ' . $run['status']);
|
||||
}
|
||||
$this->repository->transaction(function (array &$state) use ($request, $collection, $id, $action): void {
|
||||
$action === 'approve'
|
||||
? Approval::approve($state, $collection, $id, $this->auth->username())
|
||||
: Approval::reject($state, $collection, $id, $this->auth->username());
|
||||
$details = [];
|
||||
if ($collection === 'releases' && $action === 'approve') {
|
||||
$approvedRelease = $this->find($state['releases'], $id);
|
||||
$details['approvedPayloadSha256'] = (string) ($approvedRelease['approvedPayloadSha256'] ?? '');
|
||||
}
|
||||
Approval::audit($state, $this->auth->username(), $action, rtrim($collection, 's'), $id, $request->ip, $details);
|
||||
});
|
||||
return $this->adminRedirect('ok', $action === 'approve' ? 'Freigabe gespeichert.' : 'Ablehnung gespeichert.');
|
||||
} catch (Throwable $exception) {
|
||||
return $this->adminRedirect('error', $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function viewResponse(string $template, array $data, int $status, Request $request): Response
|
||||
{
|
||||
$data += ['currentPath' => $request->path, 'adminEnabled' => $this->auth->enabled(), 'adminUser' => $this->auth->username()];
|
||||
return new Response($this->view->render($template, $data), $status, [
|
||||
'Content-Type' => 'text/html; charset=utf-8', 'Cache-Control' => str_starts_with($request->path, '/admin') ? 'no-store' : 'public, max-age=60',
|
||||
]);
|
||||
}
|
||||
|
||||
private function secure(Response $response): Response
|
||||
{
|
||||
return new Response($response->body, $response->status, array_merge([
|
||||
'Content-Security-Policy' => "default-src 'self'; img-src 'self' https: data:; style-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'",
|
||||
'X-Content-Type-Options' => 'nosniff', 'Referrer-Policy' => 'strict-origin-when-cross-origin',
|
||||
'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()',
|
||||
], $response->headers));
|
||||
}
|
||||
|
||||
private function apiError(int $status, string $message): Response
|
||||
{
|
||||
return Response::json(['error' => ['status' => $status, 'message' => $message]], $status);
|
||||
}
|
||||
|
||||
private function apiPageUrl(Request $request, int $page): string
|
||||
{
|
||||
$query = $request->query;
|
||||
$query['page'] = $page;
|
||||
return '/api/v1/plugins/?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
|
||||
private function adminRedirect(string $kind, string $message): Response
|
||||
{
|
||||
return Response::redirect('/admin?' . http_build_query([$kind => $message], '', '&', PHP_QUERY_RFC3986));
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $items */
|
||||
private function find(array $items, string $id): ?array
|
||||
{
|
||||
$index = $this->findIndex($items, $id);
|
||||
return $index === null ? null : $items[$index];
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $items */
|
||||
private function findIndex(array $items, string $id): ?int
|
||||
{
|
||||
foreach ($items as $index => $item) {
|
||||
if (($item['id'] ?? '') === $id) {
|
||||
return $index;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Http;
|
||||
|
||||
use NetBoxStore\Config;
|
||||
|
||||
final class Request
|
||||
{
|
||||
/** @param array<string,mixed> $query @param array<string,mixed> $body @param array<string,string> $headers */
|
||||
public function __construct(
|
||||
public readonly string $method,
|
||||
public readonly string $path,
|
||||
public readonly array $query,
|
||||
public readonly array $body,
|
||||
public readonly array $headers,
|
||||
public readonly string $ip,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromGlobals(Config $config): self
|
||||
{
|
||||
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
|
||||
$path = rawurldecode((string) parse_url($uri, PHP_URL_PATH));
|
||||
$path = '/' . trim($path, '/');
|
||||
if ($path !== '/') {
|
||||
$path = rtrim($path, '/');
|
||||
}
|
||||
$headers = [];
|
||||
foreach ($_SERVER as $key => $value) {
|
||||
if (str_starts_with($key, 'HTTP_')) {
|
||||
$headers[strtolower(str_replace('_', '-', substr($key, 5)))] = (string) $value;
|
||||
}
|
||||
}
|
||||
$ip = (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
if ($config->trustProxy && in_array($ip, $config->network['trustedProxyIps'], true) && isset($headers['x-forwarded-for'])) {
|
||||
$candidate = trim(explode(',', $headers['x-forwarded-for'])[0]);
|
||||
if (filter_var($candidate, FILTER_VALIDATE_IP)) {
|
||||
$ip = $candidate;
|
||||
}
|
||||
}
|
||||
return new self(
|
||||
strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')),
|
||||
$path,
|
||||
$_GET,
|
||||
$_POST,
|
||||
$headers,
|
||||
$ip,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Http;
|
||||
|
||||
final class Response
|
||||
{
|
||||
/** @param array<string,string> $headers */
|
||||
public function __construct(
|
||||
public readonly string $body = '',
|
||||
public readonly int $status = 200,
|
||||
public readonly array $headers = [],
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $payload */
|
||||
public static function json(array $payload, int $status = 200, array $headers = []): self
|
||||
{
|
||||
$body = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
$etag = '"' . hash('sha256', $body) . '"';
|
||||
return new self($body, $status, array_merge([
|
||||
'Content-Type' => 'application/json; charset=utf-8',
|
||||
// Approval and revocation decisions are security state. A shared
|
||||
// cache must never serve a previously-approved payload after reject.
|
||||
'Cache-Control' => 'no-store',
|
||||
'ETag' => $etag,
|
||||
], $headers));
|
||||
}
|
||||
|
||||
public static function redirect(string $url, int $status = 303): self
|
||||
{
|
||||
return new self('', $status, ['Location' => $url, 'Cache-Control' => 'no-store']);
|
||||
}
|
||||
|
||||
public function send(): never
|
||||
{
|
||||
http_response_code($this->status);
|
||||
foreach ($this->headers as $name => $value) {
|
||||
header($name . ': ' . $value);
|
||||
}
|
||||
echo $this->body;
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Http;
|
||||
|
||||
use NetBoxStore\Config;
|
||||
use NetBoxStore\Support;
|
||||
use RuntimeException;
|
||||
|
||||
final class View
|
||||
{
|
||||
public function __construct(private readonly Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
public function render(string $template, array $data = []): string
|
||||
{
|
||||
$path = $this->config->root . '/templates/' . $template . '.php';
|
||||
if (!is_file($path)) {
|
||||
throw new RuntimeException('View not found: ' . $template);
|
||||
}
|
||||
$e = [Support::class, 'e'];
|
||||
$formatDate = static function (mixed $value): string {
|
||||
$timestamp = is_string($value) ? strtotime($value) : false;
|
||||
return $timestamp === false ? '–' : date('d.m.Y H:i', $timestamp);
|
||||
};
|
||||
$formatBytes = static function (mixed $value): string {
|
||||
$bytes = (int) $value;
|
||||
if ($bytes <= 0) {
|
||||
return '–';
|
||||
}
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$index = min((int) floor(log($bytes, 1024)), count($units) - 1);
|
||||
return number_format($bytes / (1024 ** $index), $index > 0 ? 1 : 0, ',', '.') . ' ' . $units[$index];
|
||||
};
|
||||
extract($data, EXTR_SKIP);
|
||||
ob_start();
|
||||
include $path;
|
||||
return (string) ob_get_clean();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Security;
|
||||
|
||||
use NetBoxStore\Config;
|
||||
use NetBoxStore\Database\StoreRepository;
|
||||
use NetBoxStore\Support;
|
||||
use RuntimeException;
|
||||
|
||||
final class Auth
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Config $config,
|
||||
private readonly StoreRepository $repository,
|
||||
) {
|
||||
}
|
||||
|
||||
public function startSession(): void
|
||||
{
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
return;
|
||||
}
|
||||
session_name($this->config->sessionName);
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 0,
|
||||
'path' => '/admin',
|
||||
'secure' => $this->config->secureCookies,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
]);
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
session_start();
|
||||
if (isset($_SESSION['adminExpiresAt']) && (int) $_SESSION['adminExpiresAt'] < time()) {
|
||||
$this->logout();
|
||||
}
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) $this->config->admin['enabled'];
|
||||
}
|
||||
|
||||
public function loggedIn(): bool
|
||||
{
|
||||
return isset($_SESSION['adminUser'], $_SESSION['adminExpiresAt'])
|
||||
&& hash_equals($this->config->admin['username'], (string) $_SESSION['adminUser'])
|
||||
&& (int) $_SESSION['adminExpiresAt'] >= time();
|
||||
}
|
||||
|
||||
public function username(): string
|
||||
{
|
||||
return $this->loggedIn() ? (string) $_SESSION['adminUser'] : '';
|
||||
}
|
||||
|
||||
public function csrfToken(): string
|
||||
{
|
||||
if (!isset($_SESSION['csrf'])) {
|
||||
$random = bin2hex(random_bytes(32));
|
||||
$_SESSION['csrf'] = $random . '.' . hash_hmac('sha256', $random, $this->config->admin['sessionSecret']);
|
||||
}
|
||||
return (string) $_SESSION['csrf'];
|
||||
}
|
||||
|
||||
public function verifyCsrf(?string $token): bool
|
||||
{
|
||||
return is_string($token) && isset($_SESSION['csrf']) && hash_equals((string) $_SESSION['csrf'], $token);
|
||||
}
|
||||
|
||||
public function attempt(string $ip, string $username, string $password): bool
|
||||
{
|
||||
if (!$this->enabled()) {
|
||||
return false;
|
||||
}
|
||||
$key = hash_hmac('sha256', $ip, $this->config->admin['sessionSecret']);
|
||||
$cutoff = time() - $this->config->admin['attemptWindow'];
|
||||
$blocked = $this->repository->transaction(function (array &$state) use ($key, $cutoff): bool {
|
||||
$state['authAttempts'] = array_values(array_filter(
|
||||
$state['authAttempts'],
|
||||
static fn (array $attempt): bool => ($attempt['timestamp'] ?? 0) >= $cutoff,
|
||||
));
|
||||
if (count($state['authAttempts']) > 10_000) {
|
||||
$state['authAttempts'] = array_slice($state['authAttempts'], -10_000);
|
||||
}
|
||||
return count(array_filter(
|
||||
$state['authAttempts'],
|
||||
static fn (array $attempt): bool => ($attempt['key'] ?? '') === $key,
|
||||
)) >= $this->config->admin['maxAttempts'];
|
||||
});
|
||||
if ($blocked) {
|
||||
throw new RuntimeException('Zu viele Anmeldeversuche. Bitte später erneut versuchen.');
|
||||
}
|
||||
$validPassword = password_verify($password, $this->config->admin['passwordHash']);
|
||||
$validUser = strlen($username) === strlen($this->config->admin['username'])
|
||||
&& hash_equals($this->config->admin['username'], $username);
|
||||
if (!$validPassword || !$validUser) {
|
||||
$this->repository->transaction(function (array &$state) use ($key): void {
|
||||
$state['authAttempts'][] = ['key' => $key, 'timestamp' => time()];
|
||||
if (count($state['authAttempts']) > 10_000) {
|
||||
$state['authAttempts'] = array_slice($state['authAttempts'], -10_000);
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
$this->repository->transaction(function (array &$state) use ($key): void {
|
||||
$state['authAttempts'] = array_values(array_filter(
|
||||
$state['authAttempts'],
|
||||
static fn (array $attempt): bool => ($attempt['key'] ?? '') !== $key,
|
||||
));
|
||||
});
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['adminUser'] = $this->config->admin['username'];
|
||||
$_SESSION['adminExpiresAt'] = time() + $this->config->admin['sessionTtl'];
|
||||
unset($_SESSION['csrf']);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function logout(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
if (ini_get('session.use_cookies')) {
|
||||
$params = session_get_cookie_params();
|
||||
setcookie(session_name(), '', time() - 42000, $params['path'], $params['domain'] ?? '', (bool) $params['secure'], (bool) $params['httponly']);
|
||||
}
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
session_destroy();
|
||||
}
|
||||
}
|
||||
|
||||
public static function passwordHash(string $password): string
|
||||
{
|
||||
if (strlen($password) < 12) {
|
||||
throw new RuntimeException('Password must contain at least 12 characters.');
|
||||
}
|
||||
return password_hash($password, PASSWORD_ARGON2ID, [
|
||||
'memory_cost' => 65_536,
|
||||
'time_cost' => 4,
|
||||
'threads' => 2,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Security;
|
||||
|
||||
use CurlHandle;
|
||||
use NetBoxStore\Config;
|
||||
use NetBoxStore\Sync\SyncBudget;
|
||||
use RuntimeException;
|
||||
|
||||
final class HttpClient
|
||||
{
|
||||
private ?SyncBudget $budget = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly Config $config,
|
||||
private readonly SsrfGuard $guard,
|
||||
) {
|
||||
}
|
||||
|
||||
public function beginBudget(SyncBudget $budget): void
|
||||
{
|
||||
if ($this->budget !== null) {
|
||||
throw new RuntimeException('An outbound sync budget is already active.');
|
||||
}
|
||||
$this->budget = $budget;
|
||||
}
|
||||
|
||||
public function endBudget(SyncBudget $budget): void
|
||||
{
|
||||
if ($this->budget === $budget) {
|
||||
$this->budget = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function consumeRepositories(int $count): void
|
||||
{
|
||||
$this->budget?->consumeRepositories($count);
|
||||
}
|
||||
|
||||
public function consumeReleases(int $count): void
|
||||
{
|
||||
$this->budget?->consumeReleases($count);
|
||||
}
|
||||
|
||||
/** Defense-in-depth for adapters which do not account per page. */
|
||||
public function ensureRepositoriesCounted(int $total): void
|
||||
{
|
||||
if ($this->budget !== null && $total > $this->budget->repositoriesCounted()) {
|
||||
$this->budget->consumeRepositories($total - $this->budget->repositoriesCounted());
|
||||
}
|
||||
}
|
||||
|
||||
/** Defense-in-depth for adapters which do not account per page. */
|
||||
public function ensureReleasesCounted(int $total): void
|
||||
{
|
||||
if ($this->budget !== null && $total > $this->budget->releasesCounted()) {
|
||||
$this->budget->consumeReleases($total - $this->budget->releasesCounted());
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $headers @return array<string,mixed>|null */
|
||||
public function getJson(string $url, array $headers = [], ?string $sensitiveOrigin = null, bool $allowNotFound = false): ?array
|
||||
{
|
||||
$response = $this->request($url, $headers, $sensitiveOrigin, $allowNotFound);
|
||||
if ($response === null) {
|
||||
return null;
|
||||
}
|
||||
$decoded = json_decode($response['body'], true, 512, JSON_THROW_ON_ERROR);
|
||||
if (!is_array($decoded)) {
|
||||
throw new RuntimeException('Remote source returned invalid JSON.');
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/** @param list<string> $headers */
|
||||
public function getText(string $url, array $headers = [], ?string $sensitiveOrigin = null, bool $allowNotFound = false): ?string
|
||||
{
|
||||
$response = $this->request($url, $headers, $sensitiveOrigin, $allowNotFound);
|
||||
return $response['body'] ?? null;
|
||||
}
|
||||
|
||||
/** @return array{sha256:string,artifactSize:int} */
|
||||
public function downloadAndHash(string $url, string $apiOrigin, ?string $authorization, string $expectedSha256 = ''): array
|
||||
{
|
||||
$current = $url;
|
||||
for ($redirects = 0; $redirects <= 5; $redirects++) {
|
||||
$headers = ['Accept: application/octet-stream'];
|
||||
if ($authorization !== null && $this->origin($current) === $apiOrigin) {
|
||||
$headers[] = 'Authorization: ' . $authorization;
|
||||
}
|
||||
$response = $this->performWithRetries($current, $headers, true);
|
||||
if (in_array($response['status'], [301, 302, 303, 307, 308], true)) {
|
||||
$location = $response['headers']['location'] ?? '';
|
||||
if ($location === '') {
|
||||
throw new RuntimeException('Artifact redirect has no Location header.');
|
||||
}
|
||||
$current = $this->resolveRedirect($current, $location);
|
||||
continue;
|
||||
}
|
||||
if ($response['status'] < 200 || $response['status'] >= 300) {
|
||||
throw new RuntimeException('Artifact host returned HTTP ' . $response['status'] . '.');
|
||||
}
|
||||
if ($response['tooLarge']) {
|
||||
throw new RuntimeException('Artifact exceeds configured size limit.');
|
||||
}
|
||||
if ($expectedSha256 !== '' && preg_match('/^[a-f0-9]{64}$/i', $expectedSha256) && !hash_equals(strtolower($expectedSha256), $response['sha256'])) {
|
||||
throw new RuntimeException('Downloaded artifact differs from advertised SHA-256.');
|
||||
}
|
||||
return ['sha256' => $response['sha256'], 'artifactSize' => $response['size']];
|
||||
}
|
||||
throw new RuntimeException('Artifact has too many redirects.');
|
||||
}
|
||||
|
||||
/** @param list<string> $headers @return array{status:int,headers:array<string,string>,body:string}|null */
|
||||
private function request(string $url, array $headers, ?string $sensitiveOrigin, bool $allowNotFound): ?array
|
||||
{
|
||||
$current = $url;
|
||||
for ($redirects = 0; $redirects <= 5; $redirects++) {
|
||||
$filtered = $this->filterSensitiveHeaders($headers, $current, $sensitiveOrigin);
|
||||
$response = $this->performWithRetries($current, $filtered, false);
|
||||
if ($allowNotFound && $response['status'] === 404) {
|
||||
return null;
|
||||
}
|
||||
if (in_array($response['status'], [301, 302, 303, 307, 308], true)) {
|
||||
$location = $response['headers']['location'] ?? '';
|
||||
if ($location === '') {
|
||||
throw new RuntimeException('Redirect has no Location header.');
|
||||
}
|
||||
$current = $this->resolveRedirect($current, $location);
|
||||
continue;
|
||||
}
|
||||
if ($response['status'] < 200 || $response['status'] >= 300) {
|
||||
throw new RuntimeException('Remote source returned HTTP ' . $response['status'] . '.');
|
||||
}
|
||||
if ($response['tooLarge']) {
|
||||
throw new RuntimeException('Metadata response exceeds configured size limit.');
|
||||
}
|
||||
return ['status' => $response['status'], 'headers' => $response['headers'], 'body' => $response['body']];
|
||||
}
|
||||
throw new RuntimeException('Remote source has too many redirects.');
|
||||
}
|
||||
|
||||
/** @param list<string> $headers @return array{status:int,headers:array<string,string>,body:string,sha256:string,size:int,tooLarge:bool} */
|
||||
private function performWithRetries(string $url, array $headers, bool $artifact): array
|
||||
{
|
||||
$last = null;
|
||||
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
||||
$last = $this->perform($url, $headers, $artifact);
|
||||
if ($last['status'] !== 429 && $last['status'] < 500) {
|
||||
return $last;
|
||||
}
|
||||
if ($attempt < 3) {
|
||||
usleep($attempt * 350_000);
|
||||
}
|
||||
}
|
||||
return $last;
|
||||
}
|
||||
|
||||
/** @param list<string> $headers @return array{status:int,headers:array<string,string>,body:string,sha256:string,size:int,tooLarge:bool} */
|
||||
private function perform(string $url, array $headers, bool $artifact): array
|
||||
{
|
||||
$budget = $this->budget;
|
||||
$budget?->consumeRequest();
|
||||
$target = $this->guard->resolve($url, $artifact ? 'Artifact URL' : 'Source URL');
|
||||
$curl = curl_init($url);
|
||||
if (!$curl instanceof CurlHandle) {
|
||||
throw new RuntimeException('Could not initialize cURL.');
|
||||
}
|
||||
$responseHeaders = [];
|
||||
$body = '';
|
||||
$size = 0;
|
||||
$tooLarge = false;
|
||||
$budgetExceeded = false;
|
||||
$hash = hash_init('sha256');
|
||||
$limit = $artifact ? $this->config->network['maxArtifactBytes'] : $this->config->network['maxMetadataBytes'];
|
||||
$timeout = $budget === null
|
||||
? $this->config->network['timeout']
|
||||
: min($this->config->network['timeout'], $budget->remainingSeconds());
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_CONNECTTIMEOUT => min(10, $timeout),
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_HTTPHEADER => array_merge(['User-Agent: ' . $this->config->network['userAgent']], $headers),
|
||||
CURLOPT_RESOLVE => [$target['resolve']],
|
||||
CURLOPT_HEADERFUNCTION => static function (CurlHandle $handle, string $line) use (&$responseHeaders): int {
|
||||
$trimmed = trim($line);
|
||||
if (str_starts_with($trimmed, 'HTTP/')) {
|
||||
$responseHeaders = [];
|
||||
} elseif (str_contains($trimmed, ':')) {
|
||||
[$name, $value] = explode(':', $trimmed, 2);
|
||||
$responseHeaders[strtolower(trim($name))] = trim($value);
|
||||
}
|
||||
return strlen($line);
|
||||
},
|
||||
CURLOPT_WRITEFUNCTION => static function (CurlHandle $handle, string $chunk) use (&$body, &$size, &$tooLarge, &$budgetExceeded, $hash, $limit, $artifact, $budget): int {
|
||||
$chunkSize = strlen($chunk);
|
||||
$size += $chunkSize;
|
||||
if ($budget !== null && !$budget->tryConsumeBytes($chunkSize)) {
|
||||
$budgetExceeded = true;
|
||||
return 0;
|
||||
}
|
||||
if ($size > $limit) {
|
||||
$tooLarge = true;
|
||||
return 0;
|
||||
}
|
||||
if ($artifact) {
|
||||
hash_update($hash, $chunk);
|
||||
} else {
|
||||
$body .= $chunk;
|
||||
}
|
||||
return strlen($chunk);
|
||||
},
|
||||
]);
|
||||
$ok = curl_exec($curl);
|
||||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
if ($budgetExceeded) {
|
||||
$budget?->assertWithinLimits();
|
||||
}
|
||||
$budget?->checkpoint();
|
||||
if ($ok === false && !$tooLarge) {
|
||||
throw new RuntimeException('Outbound request failed: ' . $error);
|
||||
}
|
||||
return [
|
||||
'status' => $status,
|
||||
'headers' => $responseHeaders,
|
||||
'body' => $body,
|
||||
'sha256' => hash_final($hash),
|
||||
'size' => $size,
|
||||
'tooLarge' => $tooLarge,
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<string> $headers @return list<string> */
|
||||
private function filterSensitiveHeaders(array $headers, string $url, ?string $sensitiveOrigin): array
|
||||
{
|
||||
if ($sensitiveOrigin !== null && $this->origin($url) === $sensitiveOrigin) {
|
||||
return $headers;
|
||||
}
|
||||
return array_values(array_filter($headers, static fn (string $header): bool => !str_starts_with(strtolower($header), 'authorization:')));
|
||||
}
|
||||
|
||||
public function origin(string $url): string
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
$port = (int) ($parts['port'] ?? 443);
|
||||
return strtolower((string) ($parts['scheme'] ?? '')) . '://' . strtolower((string) ($parts['host'] ?? '')) . ($port === 443 ? '' : ':' . $port);
|
||||
}
|
||||
|
||||
private function resolveRedirect(string $base, string $location): string
|
||||
{
|
||||
if (preg_match('#^https://#i', $location)) {
|
||||
return $location;
|
||||
}
|
||||
$parts = parse_url($base);
|
||||
$origin = $this->origin($base);
|
||||
if (str_starts_with($location, '/')) {
|
||||
return $origin . $location;
|
||||
}
|
||||
$directory = rtrim(dirname((string) ($parts['path'] ?? '/')), '/\\');
|
||||
return $origin . ($directory === '' ? '' : $directory) . '/' . $location;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Security;
|
||||
|
||||
use NetBoxStore\Config;
|
||||
use RuntimeException;
|
||||
|
||||
final class SsrfGuard
|
||||
{
|
||||
public function __construct(private readonly Config $config)
|
||||
{
|
||||
}
|
||||
|
||||
/** @return array{url:string,host:string,port:int,resolve:string} */
|
||||
public function resolve(string $url, string $label = 'URL'): array
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) {
|
||||
throw new RuntimeException($label . ' must be HTTPS.');
|
||||
}
|
||||
if (isset($parts['user']) || isset($parts['pass'])) {
|
||||
throw new RuntimeException($label . ' must not contain credentials.');
|
||||
}
|
||||
$host = strtolower(rtrim((string) $parts['host'], '.'));
|
||||
$asciiHost = idn_to_ascii($host, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46);
|
||||
if ($asciiHost === false || !$this->hostAllowed($asciiHost)) {
|
||||
throw new RuntimeException($label . ' host is not in STORE_ALLOWED_SOURCE_HOSTS.');
|
||||
}
|
||||
$addresses = [];
|
||||
if (filter_var($asciiHost, FILTER_VALIDATE_IP)) {
|
||||
$addresses[] = $asciiHost;
|
||||
} else {
|
||||
foreach (dns_get_record($asciiHost, DNS_A | DNS_AAAA) ?: [] as $record) {
|
||||
$address = $record['ip'] ?? $record['ipv6'] ?? null;
|
||||
if (is_string($address)) {
|
||||
$addresses[] = $address;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($addresses === []) {
|
||||
throw new RuntimeException($label . ' host could not be resolved.');
|
||||
}
|
||||
if (!$this->config->network['allowPrivate']) {
|
||||
foreach ($addresses as $address) {
|
||||
if (!$this->publicIp($address)) {
|
||||
throw new RuntimeException($label . ' resolves to a private or reserved address.');
|
||||
}
|
||||
}
|
||||
}
|
||||
$ip = $addresses[0];
|
||||
$port = (int) ($parts['port'] ?? 443);
|
||||
$resolveIp = str_contains($ip, ':') ? '[' . $ip . ']' : $ip;
|
||||
return [
|
||||
'url' => $url,
|
||||
'host' => $asciiHost,
|
||||
'port' => $port,
|
||||
'resolve' => sprintf('%s:%d:%s', $asciiHost, $port, $resolveIp),
|
||||
];
|
||||
}
|
||||
|
||||
public function assertConfiguredUrl(string $url, string $label = 'URL'): void
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if (!is_array($parts) || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])) {
|
||||
throw new RuntimeException($label . ' must be credential-free HTTPS.');
|
||||
}
|
||||
$host = strtolower(rtrim((string) $parts['host'], '.'));
|
||||
if (!$this->hostAllowed($host)) {
|
||||
throw new RuntimeException($label . ' host is not allowlisted.');
|
||||
}
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) && !$this->config->network['allowPrivate'] && !$this->publicIp($host)) {
|
||||
throw new RuntimeException($label . ' uses a private or reserved address.');
|
||||
}
|
||||
}
|
||||
|
||||
private function hostAllowed(string $host): bool
|
||||
{
|
||||
foreach ($this->config->network['allowedHosts'] as $pattern) {
|
||||
if (str_starts_with($pattern, '*.')) {
|
||||
$suffix = substr($pattern, 1);
|
||||
if (str_ends_with($host, $suffix) && $host !== ltrim($suffix, '.')) {
|
||||
return true;
|
||||
}
|
||||
} elseif ($host === $pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private function publicIp(string $address): bool
|
||||
{
|
||||
return filter_var(
|
||||
$address,
|
||||
FILTER_VALIDATE_IP,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE,
|
||||
) !== false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore;
|
||||
|
||||
final class Support
|
||||
{
|
||||
public static function now(): string
|
||||
{
|
||||
return gmdate('Y-m-d\TH:i:s\Z');
|
||||
}
|
||||
|
||||
public static function uuid(): string
|
||||
{
|
||||
$bytes = random_bytes(16);
|
||||
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
|
||||
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
|
||||
$hex = bin2hex($bytes);
|
||||
return sprintf('%s-%s-%s-%s-%s', substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4), substr($hex, 16, 4), substr($hex, 20));
|
||||
}
|
||||
|
||||
public static function clip(mixed $value, int $length): string
|
||||
{
|
||||
return mb_substr((string) ($value ?? ''), 0, $length);
|
||||
}
|
||||
|
||||
public static function slug(string $value): string
|
||||
{
|
||||
$ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $value) ?: $value;
|
||||
return trim(substr((string) preg_replace('/[^a-z0-9]+/', '-', strtolower($ascii)), 0, 64), '-');
|
||||
}
|
||||
|
||||
public static function safeVersion(mixed $value): string
|
||||
{
|
||||
$value = strtolower(preg_replace('/^v(?=\d)/i', '', trim((string) ($value ?? ''))) ?? '');
|
||||
// Deliberately accept a conservative, already-normalized PEP 440 subset.
|
||||
// Keeping the stored representation canonical prevents one malformed
|
||||
// upstream tag from making strict clients reject the complete catalog.
|
||||
return preg_match(
|
||||
'/^(?:0|[1-9]\d*)(?:\.(?:0|[1-9]\d*)){0,3}(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?(?:\+[a-z0-9]+(?:\.[a-z0-9]+)*)?$/',
|
||||
$value,
|
||||
) ? $value : '';
|
||||
}
|
||||
|
||||
public static function compareVersions(string $left, string $right): int
|
||||
{
|
||||
return version_compare($left, $right);
|
||||
}
|
||||
|
||||
public static function e(mixed $value): string
|
||||
{
|
||||
return htmlspecialchars((string) ($value ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $value */
|
||||
public static function canonicalJson(array $value): string
|
||||
{
|
||||
self::sortRecursive($value);
|
||||
return json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/** @param array<string|int,mixed> $value */
|
||||
private static function sortRecursive(array &$value): void
|
||||
{
|
||||
if (!array_is_list($value)) {
|
||||
ksort($value, SORT_STRING);
|
||||
}
|
||||
foreach ($value as &$item) {
|
||||
if (is_array($item)) {
|
||||
self::sortRecursive($item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Sync\Adapter;
|
||||
|
||||
use NetBoxStore\Config;
|
||||
use NetBoxStore\Security\HttpClient;
|
||||
use RuntimeException;
|
||||
|
||||
abstract class AbstractAdapter implements SourceAdapter
|
||||
{
|
||||
protected string $baseUrl;
|
||||
protected string $apiUrl;
|
||||
protected string $apiOrigin;
|
||||
protected ?string $authorization = null;
|
||||
|
||||
/** @param array<string,mixed> $source */
|
||||
public function __construct(
|
||||
protected readonly array $source,
|
||||
protected readonly Config $config,
|
||||
protected readonly HttpClient $http,
|
||||
) {
|
||||
$this->baseUrl = rtrim((string) $source['baseUrl'], '/');
|
||||
$this->apiUrl = rtrim((string) $source['apiUrl'], '/');
|
||||
$this->apiOrigin = $http->origin($this->apiUrl);
|
||||
$tokenName = (string) ($source['tokenEnv'] ?? '');
|
||||
$token = $tokenName === '' ? false : getenv($tokenName);
|
||||
if (is_string($token) && $token !== '') {
|
||||
$this->authorization = $this->authorizationValue($token);
|
||||
}
|
||||
}
|
||||
|
||||
abstract protected function authorizationValue(string $token): string;
|
||||
|
||||
/** @return list<string> */
|
||||
protected function apiHeaders(string $accept = 'application/json'): array
|
||||
{
|
||||
$headers = ['Accept: ' . $accept];
|
||||
if ($this->authorization !== null) {
|
||||
$headers[] = 'Authorization: ' . $this->authorization;
|
||||
}
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/** @param array<string,string|int> $query @return array<string,mixed>|null */
|
||||
protected function apiJson(string $path, array $query = [], bool $allowNotFound = false): ?array
|
||||
{
|
||||
$url = $this->apiUrl . '/' . ltrim($path, '/');
|
||||
if ($query !== []) {
|
||||
$url .= '?' . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
|
||||
}
|
||||
return $this->http->getJson($url, $this->apiHeaders(), $this->apiOrigin, $allowNotFound);
|
||||
}
|
||||
|
||||
/** @param list<mixed> $page */
|
||||
protected function accountRepositoryPage(array $page): void
|
||||
{
|
||||
$this->http->consumeRepositories(count($page));
|
||||
}
|
||||
|
||||
/** @param list<mixed> $page */
|
||||
protected function accountReleasePage(array $page): void
|
||||
{
|
||||
$this->http->consumeReleases(count($page));
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $repository */
|
||||
protected function validateRepository(array $repository): void
|
||||
{
|
||||
if (!preg_match('#^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$#', (string) ($repository['fullName'] ?? ''))) {
|
||||
throw new RuntimeException('Source returned an invalid repository name.');
|
||||
}
|
||||
$repositoryHost = strtolower((string) parse_url((string) ($repository['htmlUrl'] ?? ''), PHP_URL_HOST));
|
||||
$baseHost = strtolower((string) parse_url($this->baseUrl, PHP_URL_HOST));
|
||||
if ($repositoryHost === '' || $repositoryHost !== $baseHost || !str_starts_with((string) $repository['htmlUrl'], 'https://')) {
|
||||
throw new RuntimeException('Repository URL does not belong to source host.');
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $repository */
|
||||
public function getCommitSha(array $repository, ?string $ref = null): string
|
||||
{
|
||||
$this->validateRepository($repository);
|
||||
$ref ??= (string) $repository['defaultBranch'];
|
||||
$data = $this->apiJson('repos/' . $repository['fullName'] . '/git/commits/' . rawurlencode($ref), [], true);
|
||||
$sha = strtolower((string) ($data['sha'] ?? $data['id'] ?? ''));
|
||||
return preg_match('/^[a-f0-9]{40,64}$/', $sha) ? $sha : '';
|
||||
}
|
||||
|
||||
public function listTree(array $repository, string $commitSha): array
|
||||
{
|
||||
$this->validateRepository($repository);
|
||||
$this->assertCommit($commitSha);
|
||||
$data = $this->apiJson('repos/' . $repository['fullName'] . '/git/trees/' . rawurlencode($commitSha), ['recursive' => 1], true);
|
||||
if (!is_array($data['tree'] ?? null)) {
|
||||
return [];
|
||||
}
|
||||
$paths = [];
|
||||
foreach ($data['tree'] as $item) {
|
||||
if (is_array($item) && in_array($item['type'] ?? '', ['blob', 'file'], true) && is_string($item['path'] ?? null) && strlen($item['path']) <= 500) {
|
||||
$paths[] = $item['path'];
|
||||
}
|
||||
}
|
||||
return $paths;
|
||||
}
|
||||
|
||||
public function hashArtifact(string $url, string $expectedSha256 = ''): array
|
||||
{
|
||||
return $this->http->downloadAndHash($url, $this->apiOrigin, $this->authorization, $expectedSha256);
|
||||
}
|
||||
|
||||
protected function assertCommit(string $commitSha): void
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{40,64}$/', $commitSha)) {
|
||||
throw new RuntimeException('Repository read requires a pinned commit SHA.');
|
||||
}
|
||||
}
|
||||
|
||||
protected function cleanPath(string $path): string
|
||||
{
|
||||
$path = str_replace('\\', '/', rawurldecode($path));
|
||||
$parts = [];
|
||||
foreach (explode('/', $path) as $part) {
|
||||
if ($part === '' || $part === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($part === '..') {
|
||||
if ($parts === []) {
|
||||
throw new RuntimeException('Unsafe repository path.');
|
||||
}
|
||||
array_pop($parts);
|
||||
} elseif (str_contains($part, "\0")) {
|
||||
throw new RuntimeException('Unsafe repository path.');
|
||||
} else {
|
||||
$parts[] = $part;
|
||||
}
|
||||
}
|
||||
if ($parts === []) {
|
||||
throw new RuntimeException('Empty repository path.');
|
||||
}
|
||||
return implode('/', $parts);
|
||||
}
|
||||
|
||||
protected function encodedPath(string $path): string
|
||||
{
|
||||
return implode('/', array_map('rawurlencode', explode('/', $this->cleanPath($path))));
|
||||
}
|
||||
|
||||
protected function normalizeVersion(string $tag): string
|
||||
{
|
||||
$trimmed = trim($tag);
|
||||
return preg_match('/^v\d/i', $trimmed) ? substr($trimmed, 1) : $trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Sync\Adapter;
|
||||
|
||||
use NetBoxStore\Config;
|
||||
use NetBoxStore\Security\HttpClient;
|
||||
use RuntimeException;
|
||||
|
||||
final class AdapterFactory
|
||||
{
|
||||
/** @param array<string,mixed> $source */
|
||||
public static function create(array $source, Config $config, HttpClient $http): SourceAdapter
|
||||
{
|
||||
return match ($source['provider'] ?? '') {
|
||||
'forgejo' => new ForgejoAdapter($source, $config, $http),
|
||||
'github' => new GitHubAdapter($source, $config, $http),
|
||||
default => throw new RuntimeException('Unsupported source provider.'),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Sync\Adapter;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class ForgejoAdapter extends AbstractAdapter
|
||||
{
|
||||
private const MAX_REPOSITORY_PAGES = 200;
|
||||
private const MAX_RELEASE_PAGES = 100;
|
||||
|
||||
protected function authorizationValue(string $token): string
|
||||
{
|
||||
return 'token ' . $token;
|
||||
}
|
||||
|
||||
public function listRepositories(): array
|
||||
{
|
||||
$owner = rawurlencode((string) $this->source['owner']);
|
||||
$kind = $this->source['ownerKind'] ?? 'user';
|
||||
$endpoints = $kind === 'auto'
|
||||
? ['orgs/' . $owner . '/repos', 'users/' . $owner . '/repos']
|
||||
: [($kind === 'organization' ? 'orgs/' : 'users/') . $owner . '/repos'];
|
||||
$payload = null;
|
||||
foreach ($endpoints as $endpoint) {
|
||||
$payload = $this->pagedRepositories($endpoint);
|
||||
if ($payload !== null && ($payload !== [] || $kind !== 'auto')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($payload === null) {
|
||||
throw new RuntimeException('Forgejo owner was not found.');
|
||||
}
|
||||
return array_map(function (array $item): array {
|
||||
$repository = [
|
||||
'externalId' => (string) ($item['id'] ?? $item['full_name'] ?? ''),
|
||||
'owner' => (string) ($item['owner']['login'] ?? $this->source['owner']),
|
||||
'name' => (string) ($item['name'] ?? ''),
|
||||
'fullName' => (string) ($item['full_name'] ?? (($item['owner']['login'] ?? '') . '/' . ($item['name'] ?? ''))),
|
||||
'htmlUrl' => (string) ($item['html_url'] ?? ''),
|
||||
'defaultBranch' => (string) ($item['default_branch'] ?? 'main'),
|
||||
'description' => (string) ($item['description'] ?? ''),
|
||||
'homepageUrl' => (string) ($item['website'] ?? $item['homepage'] ?? ''),
|
||||
'topics' => array_values(array_map('strval', is_array($item['topics'] ?? null) ? $item['topics'] : [])),
|
||||
'archived' => (bool) ($item['archived'] ?? false),
|
||||
'fork' => (bool) ($item['fork'] ?? false),
|
||||
'empty' => (bool) ($item['empty'] ?? false),
|
||||
];
|
||||
$this->validateRepository($repository);
|
||||
return $repository;
|
||||
}, $payload);
|
||||
}
|
||||
|
||||
public function getCommitSha(array $repository, ?string $ref = null): string
|
||||
{
|
||||
$sha = parent::getCommitSha($repository, $ref);
|
||||
if ($sha !== '') {
|
||||
return $sha;
|
||||
}
|
||||
$ref ??= (string) $repository['defaultBranch'];
|
||||
$data = $this->apiJson('repos/' . $repository['fullName'] . '/branches/' . rawurlencode($ref), [], true);
|
||||
$sha = strtolower((string) ($data['commit']['id'] ?? $data['commit']['sha'] ?? ''));
|
||||
return preg_match('/^[a-f0-9]{40,64}$/', $sha) ? $sha : '';
|
||||
}
|
||||
|
||||
public function fetchText(array $repository, string $path, string $commitSha): ?string
|
||||
{
|
||||
$this->validateRepository($repository);
|
||||
$this->assertCommit($commitSha);
|
||||
$url = $this->apiUrl . '/repos/' . $repository['fullName'] . '/raw/' . $this->encodedPath($path)
|
||||
. '?ref=' . rawurlencode($commitSha);
|
||||
return $this->http->getText($url, $this->apiHeaders('text/plain'), $this->apiOrigin, true);
|
||||
}
|
||||
|
||||
public function rawFileUrl(array $repository, string $path, string $commitSha): string
|
||||
{
|
||||
$this->validateRepository($repository);
|
||||
$this->assertCommit($commitSha);
|
||||
return rtrim((string) $repository['htmlUrl'], '/') . '/raw/commit/' . $commitSha . '/' . $this->encodedPath($path);
|
||||
}
|
||||
|
||||
public function listReleases(array $repository): array
|
||||
{
|
||||
$payload = [];
|
||||
for ($page = 1; $page <= self::MAX_RELEASE_PAGES; $page++) {
|
||||
$chunk = $this->apiJson('repos/' . $repository['fullName'] . '/releases', ['limit' => 50, 'page' => $page]);
|
||||
if (!is_array($chunk) || !array_is_list($chunk)) {
|
||||
throw new RuntimeException('Forgejo release response is invalid.');
|
||||
}
|
||||
$this->accountReleasePage($chunk);
|
||||
foreach ($chunk as $item) {
|
||||
$payload[] = $item;
|
||||
}
|
||||
if (count($chunk) < 50) {
|
||||
break;
|
||||
}
|
||||
if ($page === self::MAX_RELEASE_PAGES) {
|
||||
throw new RuntimeException('Forgejo release pagination exceeded the safety limit.');
|
||||
}
|
||||
}
|
||||
$releases = [];
|
||||
foreach ($payload as $item) {
|
||||
if (!is_array($item) || trim((string) ($item['tag_name'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
$tag = trim((string) $item['tag_name']);
|
||||
$commit = $this->getCommitSha($repository, $tag);
|
||||
$assets = is_array($item['assets'] ?? null) ? $item['assets'] : [];
|
||||
$assets = array_values(array_filter($assets, static fn (mixed $asset): bool => is_array($asset) && self::assetRank($asset) < 99));
|
||||
usort($assets, static fn (array $a, array $b): int => (self::assetRank($a) <=> self::assetRank($b))
|
||||
?: strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')));
|
||||
$asset = $assets[0] ?? [];
|
||||
if ($asset === []) {
|
||||
continue;
|
||||
}
|
||||
$digest = preg_replace('/^sha256:/i', '', (string) ($asset['sha256'] ?? $asset['digest'] ?? '')) ?? '';
|
||||
$releases[] = [
|
||||
'externalId' => (string) ($item['id'] ?? 'tag:' . $tag),
|
||||
'version' => $this->normalizeVersion($tag),
|
||||
'title' => (string) ($item['name'] ?? $tag),
|
||||
'releaseUrl' => (string) ($item['html_url'] ?? ''),
|
||||
'downloadUrl' => (string) ($asset['browser_download_url'] ?? $asset['download_url'] ?? ''),
|
||||
'expectedSha256' => preg_match('/^[a-f0-9]{64}$/i', $digest) ? strtolower($digest) : '',
|
||||
'commitSha' => $commit,
|
||||
'prerelease' => (bool) ($item['prerelease'] ?? false),
|
||||
'draft' => (bool) ($item['draft'] ?? false),
|
||||
'changelog' => (string) ($item['body'] ?? ''),
|
||||
'publishedAt' => self::date((string) ($item['published_at'] ?? $item['created_at'] ?? '')),
|
||||
];
|
||||
}
|
||||
return $releases;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>>|null */
|
||||
private function pagedRepositories(string $endpoint): ?array
|
||||
{
|
||||
$repositories = [];
|
||||
for ($page = 1; $page <= self::MAX_REPOSITORY_PAGES; $page++) {
|
||||
$data = $this->apiJson($endpoint, ['page' => $page, 'limit' => 50], true);
|
||||
if ($data === null) {
|
||||
return null;
|
||||
}
|
||||
if (!array_is_list($data)) {
|
||||
throw new RuntimeException('Forgejo repository response is invalid.');
|
||||
}
|
||||
$this->accountRepositoryPage($data);
|
||||
foreach ($data as $item) {
|
||||
$repositories[] = $item;
|
||||
}
|
||||
if (count($data) < 50) {
|
||||
return $repositories;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('Forgejo repository pagination exceeded the 10,000 repository safety limit.');
|
||||
}
|
||||
|
||||
private static function assetRank(array $asset): int
|
||||
{
|
||||
$name = strtolower((string) ($asset['name'] ?? ''));
|
||||
if (!str_ends_with($name, '.whl')) {
|
||||
return 99;
|
||||
}
|
||||
return str_ends_with($name, 'py3-none-any.whl') ? 0 : 1;
|
||||
}
|
||||
|
||||
private static function date(string $value): ?string
|
||||
{
|
||||
try {
|
||||
return $value === '' ? null : (new DateTimeImmutable($value))->format(DATE_ATOM);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Sync\Adapter;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class GitHubAdapter extends AbstractAdapter
|
||||
{
|
||||
private const MAX_REPOSITORY_PAGES = 200;
|
||||
private const MAX_RELEASE_PAGES = 100;
|
||||
|
||||
protected function authorizationValue(string $token): string
|
||||
{
|
||||
return 'Bearer ' . $token;
|
||||
}
|
||||
|
||||
protected function apiHeaders(string $accept = 'application/vnd.github+json'): array
|
||||
{
|
||||
return array_merge(parent::apiHeaders($accept), ['X-GitHub-Api-Version: 2022-11-28']);
|
||||
}
|
||||
|
||||
public function listRepositories(): array
|
||||
{
|
||||
$owner = rawurlencode((string) $this->source['owner']);
|
||||
$kind = $this->source['ownerKind'] ?? 'user';
|
||||
$endpoints = $kind === 'auto'
|
||||
? ['orgs/' . $owner . '/repos', 'users/' . $owner . '/repos']
|
||||
: [($kind === 'organization' ? 'orgs/' : 'users/') . $owner . '/repos'];
|
||||
$payload = null;
|
||||
foreach ($endpoints as $endpoint) {
|
||||
$payload = $this->paged($endpoint);
|
||||
if ($payload !== null && ($payload !== [] || $kind !== 'auto')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($payload === null) {
|
||||
throw new RuntimeException('GitHub owner was not found.');
|
||||
}
|
||||
return array_map(function (array $item): array {
|
||||
$repository = [
|
||||
'externalId' => (string) ($item['id'] ?? $item['full_name'] ?? ''),
|
||||
'owner' => (string) ($item['owner']['login'] ?? $this->source['owner']),
|
||||
'name' => (string) ($item['name'] ?? ''),
|
||||
'fullName' => (string) ($item['full_name'] ?? ''),
|
||||
'htmlUrl' => (string) ($item['html_url'] ?? ''),
|
||||
'defaultBranch' => (string) ($item['default_branch'] ?? 'main'),
|
||||
'description' => (string) ($item['description'] ?? ''),
|
||||
'homepageUrl' => (string) ($item['homepage'] ?? ''),
|
||||
'topics' => array_values(array_map('strval', is_array($item['topics'] ?? null) ? $item['topics'] : [])),
|
||||
'archived' => (bool) ($item['archived'] ?? false),
|
||||
'fork' => (bool) ($item['fork'] ?? false),
|
||||
'empty' => isset($item['size']) ? (int) $item['size'] === 0 : false,
|
||||
'private' => (bool) ($item['private'] ?? false),
|
||||
];
|
||||
$this->validateRepository($repository);
|
||||
return $repository;
|
||||
}, $payload);
|
||||
}
|
||||
|
||||
public function getCommitSha(array $repository, ?string $ref = null): string
|
||||
{
|
||||
$ref ??= (string) $repository['defaultBranch'];
|
||||
$data = $this->apiJson('repos/' . $repository['fullName'] . '/commits/' . rawurlencode($ref), [], true);
|
||||
$sha = strtolower((string) ($data['sha'] ?? ''));
|
||||
return preg_match('/^[a-f0-9]{40,64}$/', $sha) ? $sha : '';
|
||||
}
|
||||
|
||||
public function fetchText(array $repository, string $path, string $commitSha): ?string
|
||||
{
|
||||
$this->validateRepository($repository);
|
||||
$this->assertCommit($commitSha);
|
||||
$url = $this->apiUrl . '/repos/' . $repository['fullName'] . '/contents/' . $this->encodedPath($path)
|
||||
. '?ref=' . rawurlencode($commitSha);
|
||||
return $this->http->getText($url, $this->apiHeaders('application/vnd.github.raw+json'), $this->apiOrigin, true);
|
||||
}
|
||||
|
||||
public function rawFileUrl(array $repository, string $path, string $commitSha): string
|
||||
{
|
||||
$this->validateRepository($repository);
|
||||
$this->assertCommit($commitSha);
|
||||
return 'https://raw.githubusercontent.com/' . $repository['fullName'] . '/' . $commitSha . '/' . $this->encodedPath($path);
|
||||
}
|
||||
|
||||
public function listReleases(array $repository): array
|
||||
{
|
||||
if (!empty($repository['private'])) {
|
||||
return [];
|
||||
}
|
||||
$payload = [];
|
||||
for ($page = 1; $page <= self::MAX_RELEASE_PAGES; $page++) {
|
||||
$chunk = $this->apiJson('repos/' . $repository['fullName'] . '/releases', ['per_page' => 100, 'page' => $page]);
|
||||
if (!is_array($chunk) || !array_is_list($chunk)) {
|
||||
throw new RuntimeException('GitHub release response is invalid.');
|
||||
}
|
||||
$this->accountReleasePage($chunk);
|
||||
foreach ($chunk as $item) {
|
||||
$payload[] = $item;
|
||||
}
|
||||
if (count($chunk) < 100) {
|
||||
break;
|
||||
}
|
||||
if ($page === self::MAX_RELEASE_PAGES) {
|
||||
throw new RuntimeException('GitHub release pagination exceeded the safety limit.');
|
||||
}
|
||||
}
|
||||
$releases = [];
|
||||
foreach ($payload as $item) {
|
||||
if (!is_array($item) || trim((string) ($item['tag_name'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
$tag = trim((string) $item['tag_name']);
|
||||
$commit = $this->getCommitSha($repository, $tag);
|
||||
$assets = is_array($item['assets'] ?? null) ? $item['assets'] : [];
|
||||
$assets = array_values(array_filter($assets, static fn (mixed $asset): bool => is_array($asset) && self::assetRank($asset) < 99));
|
||||
usort($assets, static fn (array $a, array $b): int => (self::assetRank($a) <=> self::assetRank($b))
|
||||
?: strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? '')));
|
||||
$asset = $assets[0] ?? [];
|
||||
if ($asset === []) {
|
||||
continue;
|
||||
}
|
||||
$digest = preg_replace('/^sha256:/i', '', (string) ($asset['digest'] ?? '')) ?? '';
|
||||
$releases[] = [
|
||||
'externalId' => (string) ($item['id'] ?? 'tag:' . $tag),
|
||||
'version' => $this->normalizeVersion($tag),
|
||||
'title' => (string) ($item['name'] ?? $tag),
|
||||
'releaseUrl' => (string) ($item['html_url'] ?? ''),
|
||||
'downloadUrl' => (string) ($asset['browser_download_url'] ?? ''),
|
||||
'expectedSha256' => preg_match('/^[a-f0-9]{64}$/i', $digest) ? strtolower($digest) : '',
|
||||
'commitSha' => $commit,
|
||||
'prerelease' => (bool) ($item['prerelease'] ?? false),
|
||||
'draft' => (bool) ($item['draft'] ?? false),
|
||||
'changelog' => (string) ($item['body'] ?? ''),
|
||||
'publishedAt' => self::date((string) ($item['published_at'] ?? $item['created_at'] ?? '')),
|
||||
];
|
||||
}
|
||||
return $releases;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>>|null */
|
||||
private function paged(string $endpoint): ?array
|
||||
{
|
||||
$repositories = [];
|
||||
for ($page = 1; $page <= self::MAX_REPOSITORY_PAGES; $page++) {
|
||||
$data = $this->apiJson($endpoint, ['page' => $page, 'per_page' => 50, 'type' => 'owner'], true);
|
||||
if ($data === null) {
|
||||
return null;
|
||||
}
|
||||
if (!array_is_list($data)) {
|
||||
throw new RuntimeException('GitHub repository response is invalid.');
|
||||
}
|
||||
$this->accountRepositoryPage($data);
|
||||
foreach ($data as $item) {
|
||||
$repositories[] = $item;
|
||||
}
|
||||
if (count($data) < 50) {
|
||||
return $repositories;
|
||||
}
|
||||
}
|
||||
throw new RuntimeException('GitHub repository pagination exceeded the 10,000 repository safety limit.');
|
||||
}
|
||||
|
||||
private static function assetRank(array $asset): int
|
||||
{
|
||||
$name = strtolower((string) ($asset['name'] ?? ''));
|
||||
if (!str_ends_with($name, '.whl')) {
|
||||
return 99;
|
||||
}
|
||||
return str_ends_with($name, 'py3-none-any.whl') ? 0 : 1;
|
||||
}
|
||||
|
||||
private static function date(string $value): ?string
|
||||
{
|
||||
try {
|
||||
return $value === '' ? null : (new \DateTimeImmutable($value))->format(DATE_ATOM);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Sync\Adapter;
|
||||
|
||||
interface SourceAdapter
|
||||
{
|
||||
/** @return list<array<string,mixed>> */
|
||||
public function listRepositories(): array;
|
||||
|
||||
/** @param array<string,mixed> $repository */
|
||||
public function getCommitSha(array $repository, ?string $ref = null): string;
|
||||
|
||||
/** @param array<string,mixed> $repository */
|
||||
public function fetchText(array $repository, string $path, string $commitSha): ?string;
|
||||
|
||||
/** @param array<string,mixed> $repository @return list<string> */
|
||||
public function listTree(array $repository, string $commitSha): array;
|
||||
|
||||
/** @param array<string,mixed> $repository */
|
||||
public function rawFileUrl(array $repository, string $path, string $commitSha): string;
|
||||
|
||||
/** @param array<string,mixed> $repository @return list<array<string,mixed>> */
|
||||
public function listReleases(array $repository): array;
|
||||
|
||||
/** @return array{sha256:string,artifactSize:int} */
|
||||
public function hashArtifact(string $url, string $expectedSha256 = ''): array;
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Sync;
|
||||
|
||||
use NetBoxStore\Support;
|
||||
use NetBoxStore\Sync\Adapter\SourceAdapter;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use Devium\Toml\Toml;
|
||||
|
||||
final class Discovery
|
||||
{
|
||||
private const MANIFESTS = ['netbox-plugin.json', '.netbox-plugin.json', 'netbox_plugin.json', 'netbox-plugin.yaml', 'netbox-plugin.yml'];
|
||||
private const READMES = ['README.md', 'readme.md', 'Readme.md', 'docs/README.md'];
|
||||
private const CONFIG_FILES = ['configuration.py', 'config.py', 'plugin.py', '__init__.py'];
|
||||
|
||||
/** @param array<string,mixed> $repository @return array<string,mixed> */
|
||||
public function discover(SourceAdapter $adapter, array $repository, string $topic): array
|
||||
{
|
||||
// Resolve first. Every following byte is fetched by this immutable ref.
|
||||
$commitSha = $adapter->getCommitSha($repository, (string) $repository['defaultBranch']);
|
||||
if ($commitSha === '') {
|
||||
throw new RuntimeException('Default branch could not be pinned to a commit SHA.');
|
||||
}
|
||||
$repository['commitSha'] = $commitSha;
|
||||
$reasons = [];
|
||||
$normalizedTopic = str_replace('_', '-', strtolower($topic));
|
||||
$topics = array_map(static fn (string $value): string => str_replace('_', '-', strtolower($value)), $repository['topics']);
|
||||
if ($normalizedTopic !== '' && in_array($normalizedTopic, $topics, true)) {
|
||||
$reasons[] = 'topic';
|
||||
}
|
||||
|
||||
$manifest = [];
|
||||
foreach (self::MANIFESTS as $path) {
|
||||
$source = $adapter->fetchText($repository, $path, $commitSha);
|
||||
if ($source !== null) {
|
||||
$manifest = $this->parseManifest($path, $source);
|
||||
if ($manifest !== []) {
|
||||
$manifest['_path'] = $path;
|
||||
$reasons[] = 'manifest';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$pyprojectSource = $adapter->fetchText($repository, 'pyproject.toml', $commitSha);
|
||||
$pyproject = $this->parseToml($pyprojectSource);
|
||||
$project = $this->map($pyproject['project'] ?? []);
|
||||
$tool = $this->map($pyproject['tool'] ?? []);
|
||||
$poetry = $this->map($tool['poetry'] ?? []);
|
||||
$setuptools = $this->map($tool['setuptools'] ?? []);
|
||||
$pluginTool = $this->map($tool['netbox-plugin'] ?? $tool['netbox_plugin'] ?? []);
|
||||
$entryImport = $this->entryImport($project);
|
||||
$dependencyText = strtolower(json_encode($project['dependencies'] ?? $poetry['dependencies'] ?? '') ?: '');
|
||||
if ($pyproject !== [] && ($pluginTool !== [] || $entryImport !== '' || str_contains(strtolower($repository['name']), 'netbox') || str_contains($dependencyText, 'netbox'))) {
|
||||
$reasons[] = 'pyproject';
|
||||
}
|
||||
|
||||
$setupSource = $adapter->fetchText($repository, 'setup.py', $commitSha);
|
||||
if ($setupSource !== null && (str_contains(strtolower($setupSource), 'netbox') || str_contains(strtolower($repository['name']), 'netbox'))) {
|
||||
$reasons[] = 'setup.py';
|
||||
}
|
||||
$tree = $adapter->listTree($repository, $commitSha);
|
||||
$pluginConfig = $this->findPluginConfig($adapter, $repository, $tree, $commitSha);
|
||||
$dynamicVersion = $this->dynamicVersion(
|
||||
$adapter,
|
||||
$repository,
|
||||
$tree,
|
||||
$commitSha,
|
||||
$this->map($setuptools['dynamic'] ?? []),
|
||||
);
|
||||
if ($pluginConfig !== null) {
|
||||
$reasons[] = 'PluginConfig';
|
||||
}
|
||||
if ($reasons === []) {
|
||||
return ['candidate' => false, 'repository' => $repository, 'reasons' => []];
|
||||
}
|
||||
|
||||
$readmePath = '';
|
||||
$readmeSource = '';
|
||||
foreach (self::READMES as $path) {
|
||||
if (!in_array($path, $tree, true) && $tree !== []) {
|
||||
continue;
|
||||
}
|
||||
$content = $adapter->fetchText($repository, $path, $commitSha);
|
||||
if ($content !== null) {
|
||||
$readmePath = $path;
|
||||
$readmeSource = $content;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
[$manifestMin, $manifestMax] = $this->boundsFromMap($manifest);
|
||||
[$toolMin, $toolMax] = $this->boundsFromMap($pluginTool);
|
||||
[$dependencyMin, $dependencyMax] = $this->dependencyBounds($project['dependencies'] ?? $poetry['dependencies'] ?? []);
|
||||
$configSource = $pluginConfig['source'] ?? '';
|
||||
$setupName = $this->pythonString((string) $setupSource, 'name');
|
||||
$setupVersion = $this->pythonString((string) $setupSource, 'version');
|
||||
// Some repository manifests describe their own schema at `version`
|
||||
// while a compatibility release matrix is stored as a list. Treat that
|
||||
// value as a manifest schema version, not as the plugin version.
|
||||
$manifestVersion = is_array($manifest['compatibility'] ?? null) && array_is_list($manifest['compatibility'])
|
||||
? ''
|
||||
: ($manifest['version'] ?? '');
|
||||
$packageName = $this->first($manifest['package_name'] ?? '', $manifest['distribution_name'] ?? '', $pluginTool['package_name'] ?? '', $project['name'] ?? '', $poetry['name'] ?? '', $setupName);
|
||||
$importName = $this->first($manifest['import_name'] ?? '', $manifest['module'] ?? '', $pluginTool['import_name'] ?? '', $entryImport, $pluginConfig['importName'] ?? '');
|
||||
$description = $this->first($manifest['description'] ?? '', $project['description'] ?? '', $poetry['description'] ?? '', $repository['description'] ?? '');
|
||||
$license = $manifest['license'] ?? $project['license'] ?? $poetry['license'] ?? '';
|
||||
if (is_array($license)) {
|
||||
$license = $license['text'] ?? $license['file'] ?? '';
|
||||
}
|
||||
return [
|
||||
'candidate' => true,
|
||||
'repository' => $repository,
|
||||
'reasons' => array_values(array_unique($reasons)),
|
||||
'manifest' => $manifest,
|
||||
'name' => Support::clip($this->first($manifest['name'] ?? '', $manifest['display_name'] ?? '', $pluginTool['name'] ?? '', $this->pythonString($configSource, 'verbose_name'), $project['name'] ?? '', $poetry['name'] ?? '', $setupName, $repository['name']), 180),
|
||||
'summary' => Support::clip($this->first($manifest['summary'] ?? '', $description, $repository['description'] ?? ''), 320),
|
||||
'description' => Support::clip($description, 65_535),
|
||||
'packageName' => Support::clip($packageName, 128),
|
||||
'importName' => $this->normalizeImportName($importName),
|
||||
'author' => Support::clip($this->first($manifest['author'] ?? '', $this->authors($project, $poetry)), 180),
|
||||
'license' => Support::clip((string) $license, 100),
|
||||
'version' => $this->firstVersion(
|
||||
$manifestVersion,
|
||||
$project['version'] ?? '',
|
||||
$poetry['version'] ?? '',
|
||||
$dynamicVersion,
|
||||
$setupVersion,
|
||||
$this->pythonString($configSource, 'version'),
|
||||
$this->pythonString($configSource, '__version__'),
|
||||
),
|
||||
'minNetboxVersion' => $manifestMin ?: ($toolMin ?: (Support::safeVersion($this->pythonString($configSource, 'min_version')) ?: $dependencyMin)),
|
||||
'maxNetboxVersion' => $manifestMax ?: ($toolMax ?: (Support::safeVersion($this->pythonString($configSource, 'max_version')) ?: $dependencyMax)),
|
||||
'readmePath' => $readmePath,
|
||||
'readmeSource' => $readmeSource,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function parseManifest(string $path, string $source): array
|
||||
{
|
||||
try {
|
||||
$value = str_ends_with($path, '.json') ? json_decode($source, true, 128, JSON_THROW_ON_ERROR) : Yaml::parse($source, Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE);
|
||||
return is_array($value) ? $value : [];
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function parseToml(?string $source): array
|
||||
{
|
||||
if ($source === null) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
$value = Toml::decode($source, asArray: true);
|
||||
return is_array($value) ? $value : [];
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<string> $tree @param array<string,mixed> $repository @return array{path:string,source:string,importName:string}|null */
|
||||
private function findPluginConfig(SourceAdapter $adapter, array $repository, array $tree, string $commitSha): ?array
|
||||
{
|
||||
$checked = 0;
|
||||
foreach ($tree as $path) {
|
||||
$parts = explode('/', $path);
|
||||
if (count($parts) > 6 || !str_ends_with($path, '.py') || !in_array(end($parts), self::CONFIG_FILES, true)) {
|
||||
continue;
|
||||
}
|
||||
if (++$checked > 40) {
|
||||
break;
|
||||
}
|
||||
$source = $adapter->fetchText($repository, $path, $commitSha);
|
||||
if ($source !== null && preg_match('/(?:class\s+\w+\s*\([^)]*PluginConfig|\bPluginConfig\b)/', $source)) {
|
||||
return ['path' => $path, 'source' => $source, 'importName' => preg_replace('/\.py$/', '', $parts[0]) ?? ''];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param list<string> $tree @param array<string,mixed> $repository @param array<string,mixed> $dynamic */
|
||||
private function dynamicVersion(SourceAdapter $adapter, array $repository, array $tree, string $commitSha, array $dynamic): string
|
||||
{
|
||||
$version = $this->map($dynamic['version'] ?? []);
|
||||
$attribute = trim((string) ($version['attr'] ?? ''));
|
||||
if (preg_match('/^[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+$/', $attribute)) {
|
||||
$parts = explode('.', $attribute);
|
||||
$property = (string) array_pop($parts);
|
||||
$module = implode('/', $parts);
|
||||
foreach ([$module . '.py', $module . '/__init__.py'] as $path) {
|
||||
if ($tree !== [] && !in_array($path, $tree, true)) {
|
||||
continue;
|
||||
}
|
||||
$source = $adapter->fetchText($repository, $path, $commitSha);
|
||||
$candidate = $source === null ? '' : $this->pythonString($source, $property);
|
||||
if (Support::safeVersion($candidate) !== '') {
|
||||
return Support::safeVersion($candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
$file = trim((string) ($version['file'] ?? ''));
|
||||
if ($file !== '' && preg_match('#^[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*$#', $file)
|
||||
&& ($tree === [] || in_array($file, $tree, true))) {
|
||||
$source = $adapter->fetchText($repository, $file, $commitSha);
|
||||
if ($source !== null && preg_match('/\b(\d+(?:\.\d+){0,3}(?:(?:a|b|rc)\d+)?(?:\.post\d+)?(?:\.dev\d+)?(?:\+[a-z0-9]+(?:[.-][a-z0-9]+)*)?)\b/i', $source, $match)) {
|
||||
return Support::safeVersion($match[1]);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @return array{string,string} */
|
||||
private function boundsFromMap(array $map): array
|
||||
{
|
||||
$netbox = $this->map($map['netbox'] ?? []);
|
||||
$compatibility = $this->map($map['compatibility'] ?? []);
|
||||
return [
|
||||
Support::safeVersion($this->first($map['min_netbox_version'] ?? '', $netbox['min_version'] ?? '', $netbox['minimum'] ?? '', $compatibility['minimum'] ?? '')),
|
||||
Support::safeVersion($this->first($map['max_netbox_version'] ?? '', $netbox['max_version'] ?? '', $netbox['maximum'] ?? '', $compatibility['maximum'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{string,string} */
|
||||
private function dependencyBounds(mixed $dependencies): array
|
||||
{
|
||||
$text = is_array($dependencies) ? json_encode($dependencies) : (string) $dependencies;
|
||||
if (stripos((string) $text, 'netbox') === false) {
|
||||
return ['', ''];
|
||||
}
|
||||
// Only inclusive constraints map safely to the catalog's inclusive
|
||||
// min/max fields. Unknown and exclusive bounds stay empty for admin
|
||||
// review instead of inventing compatibility.
|
||||
preg_match('/netbox.{0,160}?>=\s*(\d+(?:\.\d+){0,3})/is', (string) $text, $minimum);
|
||||
preg_match('/netbox.{0,160}?<=\s*(\d+(?:\.\d+){0,3})/is', (string) $text, $maximum);
|
||||
return [Support::safeVersion($minimum[1] ?? ''), Support::safeVersion($maximum[1] ?? '')];
|
||||
}
|
||||
|
||||
private function pythonString(string $source, string $property): string
|
||||
{
|
||||
return preg_match('/(?:^|\n)\s*' . preg_quote($property, '/') . '\s*=\s*["\']([^"\']+)["\']/', $source, $match) ? trim($match[1]) : '';
|
||||
}
|
||||
|
||||
private function entryImport(array $project): string
|
||||
{
|
||||
$groups = $this->map($project['entry-points'] ?? []);
|
||||
foreach (['netbox.plugins', 'netbox_plugins'] as $name) {
|
||||
$values = array_values($this->map($groups[$name] ?? []));
|
||||
if (isset($values[0])) {
|
||||
return explode(':', (string) $values[0], 2)[0];
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function authors(array $project, array $poetry): string
|
||||
{
|
||||
$authors = $project['authors'] ?? $poetry['authors'] ?? [];
|
||||
if (!is_array($authors)) {
|
||||
return '';
|
||||
}
|
||||
return implode(', ', array_filter(array_map(static fn (mixed $author): string => is_array($author) ? (string) ($author['name'] ?? '') : (string) $author, $authors)));
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function map(mixed $value): array
|
||||
{
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
|
||||
private function first(mixed ...$values): string
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
if (is_scalar($value) && trim((string) $value) !== '') {
|
||||
return trim((string) $value);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function firstVersion(mixed ...$values): string
|
||||
{
|
||||
foreach ($values as $value) {
|
||||
$version = Support::safeVersion($value);
|
||||
if ($version !== '') {
|
||||
return $version;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private function normalizeImportName(string $value): string
|
||||
{
|
||||
$topLevel = explode('.', explode(':', trim($value), 2)[0], 2)[0];
|
||||
return preg_match('/^[A-Za-z_][A-Za-z0-9_]{0,127}$/', $topLevel) ? $topLevel : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Sync;
|
||||
|
||||
use DOMDocument;
|
||||
use DOMElement;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use NetBoxStore\Sync\Adapter\SourceAdapter;
|
||||
|
||||
final class ReadmeRenderer
|
||||
{
|
||||
private CommonMarkConverter $converter;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->converter = new CommonMarkConverter([
|
||||
'html_input' => 'strip',
|
||||
'allow_unsafe_links' => false,
|
||||
'max_nesting_level' => 50,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $repository */
|
||||
public function render(string $markdown, SourceAdapter $adapter, array $repository, string $readmePath, string $commitSha): string
|
||||
{
|
||||
$html = (string) $this->converter->convert($markdown);
|
||||
$document = new DOMDocument('1.0', 'UTF-8');
|
||||
$previous = libxml_use_internal_errors(true);
|
||||
$document->loadHTML(
|
||||
'<?xml encoding="UTF-8"><div id="readme-root">' . $html . '</div>',
|
||||
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD,
|
||||
);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($previous);
|
||||
foreach (['a' => 'href', 'img' => 'src'] as $tag => $attribute) {
|
||||
/** @var DOMElement $element */
|
||||
foreach (iterator_to_array($document->getElementsByTagName($tag)) as $element) {
|
||||
$value = $element->getAttribute($attribute);
|
||||
$rewritten = $this->rewriteUrl($value, $adapter, $repository, $readmePath, $commitSha, $tag === 'img');
|
||||
if ($rewritten === '') {
|
||||
$element->removeAttribute($attribute);
|
||||
} else {
|
||||
$element->setAttribute($attribute, $rewritten);
|
||||
}
|
||||
if ($tag === 'a') {
|
||||
$element->setAttribute('rel', 'nofollow noreferrer noopener');
|
||||
} else {
|
||||
$element->setAttribute('loading', 'lazy');
|
||||
$element->setAttribute('referrerpolicy', 'no-referrer');
|
||||
}
|
||||
}
|
||||
}
|
||||
$root = $document->getElementById('readme-root');
|
||||
if (!$root instanceof DOMElement) {
|
||||
return '';
|
||||
}
|
||||
$result = '';
|
||||
foreach ($root->childNodes as $child) {
|
||||
$result .= $document->saveHTML($child);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $repository */
|
||||
private function rewriteUrl(string $value, SourceAdapter $adapter, array $repository, string $readmePath, string $commitSha, bool $image): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '' || str_starts_with($value, '#')) {
|
||||
return $value;
|
||||
}
|
||||
$scheme = parse_url($value, PHP_URL_SCHEME);
|
||||
if (is_string($scheme) && $scheme !== '') {
|
||||
$allowed = $image ? ['https'] : ['https', 'mailto'];
|
||||
return in_array(strtolower($scheme), $allowed, true) ? $value : '';
|
||||
}
|
||||
if (str_starts_with($value, '//')) {
|
||||
return '';
|
||||
}
|
||||
preg_match('/^([^?#]*)(\?[^#]*)?(#.*)?$/', $value, $matches);
|
||||
$path = rawurldecode($matches[1] ?? '');
|
||||
$query = $matches[2] ?? '';
|
||||
$fragment = $matches[3] ?? '';
|
||||
$combined = str_starts_with($path, '/') ? $path : dirname($readmePath) . '/' . $path;
|
||||
$parts = [];
|
||||
foreach (explode('/', str_replace('\\', '/', $combined)) as $part) {
|
||||
if ($part === '' || $part === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($part === '..') {
|
||||
if ($parts === []) {
|
||||
return '';
|
||||
}
|
||||
array_pop($parts);
|
||||
} else {
|
||||
$parts[] = $part;
|
||||
}
|
||||
}
|
||||
if ($parts === []) {
|
||||
return '';
|
||||
}
|
||||
return $adapter->rawFileUrl($repository, implode('/', $parts), $commitSha) . $query . $fragment;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Sync;
|
||||
|
||||
final class SyncBudget
|
||||
{
|
||||
private readonly float $startedAt;
|
||||
private readonly float $deadlineAt;
|
||||
private int $requests = 0;
|
||||
private int $bytes = 0;
|
||||
private int $repositories = 0;
|
||||
private int $releases = 0;
|
||||
private ?string $exceeded = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly int $maxSeconds,
|
||||
private readonly int $maxRequests,
|
||||
private readonly int $maxBytes,
|
||||
private readonly int $maxRepositories,
|
||||
private readonly int $maxReleases,
|
||||
) {
|
||||
$this->startedAt = microtime(true);
|
||||
$this->deadlineAt = $this->startedAt + $maxSeconds;
|
||||
}
|
||||
|
||||
public function checkpoint(): void
|
||||
{
|
||||
if (microtime(true) >= $this->deadlineAt) {
|
||||
$this->exceeded ??= 'Sync deadline exceeded.';
|
||||
}
|
||||
$this->assertWithinLimits();
|
||||
}
|
||||
|
||||
public function consumeRequest(): void
|
||||
{
|
||||
$this->checkpoint();
|
||||
$this->requests++;
|
||||
if ($this->requests > $this->maxRequests) {
|
||||
$this->exceeded ??= 'Aggregated outbound request limit exceeded.';
|
||||
}
|
||||
$this->assertWithinLimits();
|
||||
}
|
||||
|
||||
/** Used inside the cURL write callback, where throwing is unsafe. */
|
||||
public function tryConsumeBytes(int $bytes): bool
|
||||
{
|
||||
if ($bytes < 0 || $this->exceeded !== null || microtime(true) >= $this->deadlineAt
|
||||
|| $this->bytes + $bytes > $this->maxBytes) {
|
||||
$this->exceeded ??= microtime(true) >= $this->deadlineAt
|
||||
? 'Sync deadline exceeded while downloading.'
|
||||
: 'Aggregated download byte limit exceeded.';
|
||||
return false;
|
||||
}
|
||||
$this->bytes += $bytes;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function consumeRepositories(int $count): void
|
||||
{
|
||||
$this->checkpoint();
|
||||
$this->repositories += max(0, $count);
|
||||
if ($this->repositories > $this->maxRepositories) {
|
||||
$this->exceeded ??= 'Aggregated repository limit exceeded.';
|
||||
}
|
||||
$this->assertWithinLimits();
|
||||
}
|
||||
|
||||
public function consumeReleases(int $count): void
|
||||
{
|
||||
$this->checkpoint();
|
||||
$this->releases += max(0, $count);
|
||||
if ($this->releases > $this->maxReleases) {
|
||||
$this->exceeded ??= 'Aggregated release limit exceeded.';
|
||||
}
|
||||
$this->assertWithinLimits();
|
||||
}
|
||||
|
||||
public function repositoriesCounted(): int
|
||||
{
|
||||
return $this->repositories;
|
||||
}
|
||||
|
||||
public function releasesCounted(): int
|
||||
{
|
||||
return $this->releases;
|
||||
}
|
||||
|
||||
public function remainingSeconds(): int
|
||||
{
|
||||
$this->checkpoint();
|
||||
return max(1, (int) ceil($this->deadlineAt - microtime(true)));
|
||||
}
|
||||
|
||||
public function assertWithinLimits(): void
|
||||
{
|
||||
if ($this->exceeded !== null) {
|
||||
throw new SyncBudgetExceeded($this->exceeded);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,int> */
|
||||
public function usage(): array
|
||||
{
|
||||
return [
|
||||
'deadlineSeconds' => $this->maxSeconds,
|
||||
'requestsUsed' => $this->requests,
|
||||
'bytesDownloaded' => $this->bytes,
|
||||
'repositoriesCounted' => $this->repositories,
|
||||
'releasesCounted' => $this->releases,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Sync;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class SyncBudgetExceeded extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NetBoxStore\Sync;
|
||||
|
||||
use NetBoxStore\Config;
|
||||
use NetBoxStore\Database\StoreRepository;
|
||||
use NetBoxStore\Domain\Approval;
|
||||
use NetBoxStore\Security\HttpClient;
|
||||
use NetBoxStore\Security\SsrfGuard;
|
||||
use NetBoxStore\Support;
|
||||
use NetBoxStore\Sync\Adapter\AdapterFactory;
|
||||
use NetBoxStore\Sync\Adapter\SourceAdapter;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
use Closure;
|
||||
|
||||
final class SyncService
|
||||
{
|
||||
private const PLUGIN_SECURITY = ['packageName', 'importName', 'minNetboxVersion', 'maxNetboxVersion'];
|
||||
private const RELEASE_SECURITY = ['version', 'downloadUrl', 'sha256', 'artifactSize', 'artifactKind', 'commitSha', 'minNetboxVersion', 'maxNetboxVersion', 'withdrawn'];
|
||||
|
||||
public function __construct(
|
||||
private readonly StoreRepository $repository,
|
||||
private readonly Config $config,
|
||||
private readonly HttpClient $http,
|
||||
private readonly SsrfGuard $guard,
|
||||
private readonly Discovery $discovery = new Discovery(),
|
||||
private readonly ReadmeRenderer $readmeRenderer = new ReadmeRenderer(),
|
||||
private readonly ?Closure $adapterFactory = null,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array{source:array<string,mixed>,created:bool} */
|
||||
public function ensureDefaultSource(): array
|
||||
{
|
||||
// Web requests call this bootstrap helper too. Avoid taking an exclusive
|
||||
// lock (and, for JSON, rewriting the complete database) after bootstrap.
|
||||
$currentState = $this->repository->read();
|
||||
if ($currentState['sources'] !== []) {
|
||||
return ['source' => $currentState['sources'][0], 'created' => false];
|
||||
}
|
||||
return $this->repository->transaction(function (array &$state): array {
|
||||
if ($state['sources'] !== []) {
|
||||
return ['source' => $state['sources'][0], 'created' => false];
|
||||
}
|
||||
$this->guard->assertConfiguredUrl($this->config->defaults['baseUrl'], 'Default source URL');
|
||||
$this->guard->assertConfiguredUrl($this->config->defaults['apiUrl'], 'Default API URL');
|
||||
$timestamp = Support::now();
|
||||
$source = [
|
||||
'id' => Support::uuid(),
|
||||
'name' => $this->config->defaults['name'],
|
||||
'slug' => $this->config->defaults['slug'],
|
||||
'provider' => $this->config->defaults['provider'],
|
||||
'baseUrl' => $this->config->defaults['baseUrl'],
|
||||
'apiUrl' => $this->config->defaults['apiUrl'],
|
||||
'owner' => $this->config->defaults['owner'],
|
||||
'ownerKind' => $this->config->defaults['ownerKind'],
|
||||
'tokenEnv' => $this->config->defaults['tokenEnv'],
|
||||
'topic' => $this->config->defaults['topic'],
|
||||
'status' => 'approved',
|
||||
'active' => true,
|
||||
'includeForks' => false,
|
||||
'includeArchived' => false,
|
||||
'autoApprovePlugins' => false,
|
||||
'approvedAt' => $timestamp,
|
||||
'approvedBy' => 'system:bootstrap',
|
||||
'moderationNote' => '',
|
||||
'lastSyncedAt' => null,
|
||||
'createdAt' => $timestamp,
|
||||
'updatedAt' => $timestamp,
|
||||
];
|
||||
$state['sources'][] = $source;
|
||||
return ['source' => $source, 'created' => true];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param list<string>|null $sourceSlugs @return list<array<string,mixed>> */
|
||||
public function syncAll(?array $sourceSlugs = null, bool $failFast = false, string $trigger = 'command'): array
|
||||
{
|
||||
$state = $this->repository->read();
|
||||
$sources = array_values(array_filter($state['sources'], static fn (array $source): bool => !empty($source['active'])
|
||||
&& ($source['status'] ?? '') === 'approved'
|
||||
&& ($sourceSlugs === null || in_array($source['slug'], $sourceSlugs, true))));
|
||||
if ($sourceSlugs !== null) {
|
||||
foreach ($sourceSlugs as $slug) {
|
||||
if (!array_filter($sources, static fn (array $source): bool => $source['slug'] === $slug)) {
|
||||
throw new RuntimeException('Source not found or not approved: ' . $slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
$runs = [];
|
||||
foreach ($sources as $source) {
|
||||
$run = $this->syncSource((string) $source['id'], $failFast, $trigger);
|
||||
$runs[] = $run;
|
||||
if ($failFast && $run['status'] === 'failed') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $runs;
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
public function syncSource(string $sourceId, bool $failFast = false, string $trigger = 'command'): array
|
||||
{
|
||||
$lease = $this->repository->acquireLease('sync-source:' . $sourceId);
|
||||
if ($lease === null) {
|
||||
throw new RuntimeException('A synchronization for this source is already running.');
|
||||
}
|
||||
try {
|
||||
return $this->syncSourceLocked($sourceId, $failFast, $trigger);
|
||||
} finally {
|
||||
$lease->release();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private function syncSourceLocked(string $sourceId, bool $failFast, string $trigger): array
|
||||
{
|
||||
$source = null;
|
||||
$run = null;
|
||||
$this->repository->transaction(function (array &$state) use ($sourceId, $trigger, &$source, &$run): void {
|
||||
$source = $this->find($state['sources'], $sourceId);
|
||||
if ($source === null || empty($source['active']) || ($source['status'] ?? '') !== 'approved') {
|
||||
throw new RuntimeException('Only active, approved sources can be synchronized.');
|
||||
}
|
||||
// The process-wide lease is already held. Any persisted "running"
|
||||
// record therefore belongs to a process which ended without being
|
||||
// able to finalize its run; elapsed time is deliberately irrelevant.
|
||||
foreach ($state['syncRuns'] as &$existing) {
|
||||
if (($existing['sourceId'] ?? '') === $sourceId && ($existing['status'] ?? '') === 'running') {
|
||||
$existing['status'] = 'failed';
|
||||
$existing['finishedAt'] = Support::now();
|
||||
$existing['message'] = 'Aborted run closed after recovering the exclusive process lease.';
|
||||
}
|
||||
}
|
||||
$run = [
|
||||
'id' => Support::uuid(), 'sourceId' => $sourceId, 'trigger' => $trigger,
|
||||
'status' => 'running', 'startedAt' => Support::now(), 'finishedAt' => null,
|
||||
'repositoriesSeen' => 0, 'candidatesFound' => 0, 'pluginsCreated' => 0,
|
||||
'pluginsUpdated' => 0, 'releasesCreated' => 0, 'releasesUpdated' => 0,
|
||||
'errors' => [], 'message' => '', 'limits' => $this->config->syncLimits,
|
||||
];
|
||||
$state['syncRuns'][] = $run;
|
||||
});
|
||||
if (!is_array($source) || !is_array($run)) {
|
||||
throw new RuntimeException('Could not start synchronization.');
|
||||
}
|
||||
|
||||
$budget = new SyncBudget(
|
||||
(int) $this->config->syncLimits['seconds'],
|
||||
(int) $this->config->syncLimits['requests'],
|
||||
(int) $this->config->syncLimits['bytes'],
|
||||
(int) $this->config->syncLimits['repositories'],
|
||||
(int) $this->config->syncLimits['releases'],
|
||||
);
|
||||
$seen = [];
|
||||
$nonCandidates = [];
|
||||
$releaseInfosSeen = 0;
|
||||
$enumerated = false;
|
||||
$fatal = '';
|
||||
$this->http->beginBudget($budget);
|
||||
try {
|
||||
$adapter = $this->adapterFactory !== null
|
||||
? ($this->adapterFactory)($source)
|
||||
: AdapterFactory::create($source, $this->config, $this->http);
|
||||
if (!$adapter instanceof SourceAdapter) {
|
||||
throw new RuntimeException('Adapter factory returned an invalid adapter.');
|
||||
}
|
||||
$repositories = $adapter->listRepositories();
|
||||
$this->http->ensureRepositoriesCounted(count($repositories));
|
||||
$seen = array_values(array_map(
|
||||
static fn (array $repository): string => (string) ($repository['externalId'] ?? ''),
|
||||
$repositories,
|
||||
));
|
||||
$run['repositoriesSeen'] = count($repositories);
|
||||
$enumerated = true;
|
||||
foreach ($repositories as $repository) {
|
||||
$budget->checkpoint();
|
||||
if (!empty($repository['empty'])
|
||||
|| (!empty($repository['fork']) && empty($source['includeForks']))
|
||||
|| (!empty($repository['archived']) && empty($source['includeArchived']))) {
|
||||
// These conditions come from a successful repository listing,
|
||||
// so an older public candidate must no longer remain active.
|
||||
$nonCandidates[] = $repository['externalId'];
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
$result = $this->discovery->discover($adapter, $repository, (string) $source['topic']);
|
||||
if (empty($result['candidate'])) {
|
||||
$nonCandidates[] = $repository['externalId'];
|
||||
continue;
|
||||
}
|
||||
$run['candidatesFound']++;
|
||||
[$plugin, $created] = $this->upsertPlugin($source, $result, $adapter);
|
||||
$run[$created ? 'pluginsCreated' : 'pluginsUpdated']++;
|
||||
|
||||
// GitHub's browser_download_url is deliberately public-only.
|
||||
// Provider credentials stay on the API origin and the v1
|
||||
// Host-Agent has no credentials for private asset downloads.
|
||||
if (($source['provider'] ?? '') === 'github' && !empty($repository['private'])) {
|
||||
$this->withdrawMissingReleases((string) $plugin['id'], []);
|
||||
$run['errors'][] = [
|
||||
'repository' => $repository['fullName'],
|
||||
'error' => 'Private GitHub release assets are not imported by API v1; metadata and README were synchronized.',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$releaseInfos = $adapter->listReleases($result['repository']);
|
||||
$releaseInfosSeen += count($releaseInfos);
|
||||
$this->http->ensureReleasesCounted($releaseInfosSeen);
|
||||
foreach (array_filter($releaseInfos) as $releaseInfo) {
|
||||
$budget->checkpoint();
|
||||
[$releaseCreated, $hashError] = $this->upsertRelease((string) $plugin['id'], $releaseInfo, $adapter);
|
||||
$run[$releaseCreated ? 'releasesCreated' : 'releasesUpdated']++;
|
||||
if ($hashError !== '') {
|
||||
$run['errors'][] = ['repository' => $repository['fullName'], 'release' => $releaseInfo['version'], 'error' => $hashError];
|
||||
}
|
||||
}
|
||||
$this->withdrawMissingReleases(
|
||||
(string) $plugin['id'],
|
||||
array_values(array_map(static fn (array $release): string => (string) $release['externalId'], array_filter($releaseInfos))),
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$run['errors'][] = ['repository' => $repository['fullName'], 'error' => Support::clip($exception->getMessage(), 500)];
|
||||
if ($exception instanceof SyncBudgetExceeded || $failFast) {
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
$fatal = Support::clip($exception->getMessage(), 1_000);
|
||||
$run['errors'][] = ['source' => $source['slug'], 'error' => $fatal];
|
||||
} finally {
|
||||
$this->http->endBudget($budget);
|
||||
$run = array_merge($run, $budget->usage());
|
||||
}
|
||||
|
||||
$this->repository->transaction(function (array &$state) use ($sourceId, $seen, $nonCandidates, $enumerated, $fatal, &$run): void {
|
||||
if ($enumerated) {
|
||||
foreach ($state['plugins'] as &$plugin) {
|
||||
if ($plugin['sourceId'] === $sourceId && !in_array($plugin['externalId'], $seen, true)) {
|
||||
$plugin['archived'] = true;
|
||||
if (($plugin['status'] ?? '') === 'approved') {
|
||||
$plugin['status'] = 'pending';
|
||||
$plugin['approvedAt'] = null;
|
||||
$plugin['approvedBy'] = null;
|
||||
}
|
||||
$plugin['moderationNote'] = 'Repository ist in einer erfolgreichen Upstream-Auflistung nicht mehr vorhanden.';
|
||||
$plugin['updatedAt'] = Support::now();
|
||||
foreach ($state['releases'] as &$release) {
|
||||
if ($release['pluginId'] === $plugin['id']) {
|
||||
$release['withdrawn'] = true;
|
||||
Approval::resetRelease($release, 'Zugehöriges Repository ist upstream nicht mehr vorhanden.');
|
||||
}
|
||||
}
|
||||
} elseif ($plugin['sourceId'] === $sourceId && in_array($plugin['externalId'], $nonCandidates, true)) {
|
||||
$plugin['archived'] = true;
|
||||
if (($plugin['status'] ?? '') === 'approved') {
|
||||
$plugin['status'] = 'pending';
|
||||
$plugin['approvedAt'] = null;
|
||||
$plugin['approvedBy'] = null;
|
||||
}
|
||||
$plugin['moderationNote'] = 'Repository wurde erfolgreich geprüft, ist aber kein NetBox-Plugin-Kandidat mehr.';
|
||||
$plugin['updatedAt'] = Support::now();
|
||||
foreach ($state['releases'] as &$release) {
|
||||
if ($release['pluginId'] === $plugin['id']) {
|
||||
$release['withdrawn'] = true;
|
||||
Approval::resetRelease($release, 'Repository ist kein Plugin-Kandidat mehr.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($state['sources'] as &$storedSource) {
|
||||
if ($storedSource['id'] === $sourceId && $fatal === '') {
|
||||
$storedSource['lastSyncedAt'] = Support::now();
|
||||
$storedSource['updatedAt'] = Support::now();
|
||||
}
|
||||
}
|
||||
foreach ($state['syncRuns'] as &$storedRun) {
|
||||
if ($storedRun['id'] === $run['id']) {
|
||||
$run['errors'] = array_slice($run['errors'], 0, 200);
|
||||
$run['finishedAt'] = Support::now();
|
||||
$run['status'] = $fatal !== '' ? 'failed' : ($run['errors'] !== [] ? 'partial' : 'success');
|
||||
$run['message'] = $fatal ?: ($run['errors'] !== [] ? count($run['errors']) . ' error(s); see details.' : 'Synchronization completed successfully.');
|
||||
$storedRun = $run;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
return $run;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $source @param array<string,mixed> $result @return array{array<string,mixed>,bool} */
|
||||
private function upsertPlugin(array $source, array $result, SourceAdapter $adapter): array
|
||||
{
|
||||
$repository = $result['repository'];
|
||||
$readmeHtml = $result['readmeSource'] !== ''
|
||||
? $this->readmeRenderer->render($result['readmeSource'], $adapter, $repository, $result['readmePath'], $repository['commitSha']) : '';
|
||||
$readmeUrl = $result['readmePath'] !== '' ? $adapter->rawFileUrl($repository, $result['readmePath'], $repository['commitSha']) : '';
|
||||
return $this->repository->transaction(function (array &$state) use ($source, $result, $repository, $readmeHtml, $readmeUrl): array {
|
||||
$index = null;
|
||||
foreach ($state['plugins'] as $candidateIndex => $candidate) {
|
||||
if ($candidate['sourceId'] === $source['id'] && ($candidate['externalId'] === $repository['externalId']
|
||||
|| (strcasecmp($candidate['repositoryOwner'], $repository['owner']) === 0 && strcasecmp($candidate['repositoryName'], $repository['name']) === 0))) {
|
||||
$index = $candidateIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$created = $index === null;
|
||||
if ($created) {
|
||||
$slug = $this->uniqueSlug($state, $result['name'] ?: $repository['name'], $source['slug']);
|
||||
$state['plugins'][] = [
|
||||
'id' => Support::uuid(), 'sourceId' => $source['id'], 'externalId' => $repository['externalId'],
|
||||
'slug' => $slug, 'status' => !empty($source['autoApprovePlugins']) ? 'approved' : 'pending',
|
||||
'approvedAt' => !empty($source['autoApprovePlugins']) ? Support::now() : null,
|
||||
'approvedBy' => !empty($source['autoApprovePlugins']) ? 'system:auto-policy' : null,
|
||||
'moderationNote' => '', 'active' => true, 'firstSeenAt' => Support::now(), 'createdAt' => Support::now(),
|
||||
];
|
||||
$index = array_key_last($state['plugins']);
|
||||
}
|
||||
$before = $state['plugins'][$index];
|
||||
$plugin = array_merge($state['plugins'][$index], [
|
||||
'externalId' => $repository['externalId'], 'repositoryOwner' => $repository['owner'],
|
||||
'repositoryName' => $repository['name'], 'repositoryUrl' => $repository['htmlUrl'],
|
||||
'defaultBranch' => $repository['defaultBranch'], 'commitSha' => $repository['commitSha'],
|
||||
'name' => Support::clip($result['name'], 180), 'summary' => Support::clip($result['summary'], 320),
|
||||
'description' => Support::clip($result['description'], 65_535), 'homepageUrl' => $this->publicUrl($repository['homepageUrl']),
|
||||
'packageName' => Support::clip($result['packageName'], 128), 'importName' => Support::clip($result['importName'], 128),
|
||||
'author' => Support::clip($result['author'], 180), 'license' => Support::clip($result['license'], 100),
|
||||
'latestVersion' => Support::safeVersion($result['version']),
|
||||
'minNetboxVersion' => Support::safeVersion($result['minNetboxVersion']),
|
||||
'maxNetboxVersion' => Support::safeVersion($result['maxNetboxVersion']),
|
||||
'topics' => $repository['topics'], 'manifest' => $result['manifest'], 'readmePath' => $result['readmePath'],
|
||||
'readmeSourceUrl' => $readmeUrl, 'readmeHtml' => $readmeHtml, 'archived' => (bool) $repository['archived'],
|
||||
'lastSeenAt' => Support::now(), 'updatedAt' => Support::now(),
|
||||
]);
|
||||
// Admin corrections intentionally win over imported metadata until
|
||||
// explicitly edited/cleared. This is essential for dynamic setup.py
|
||||
// projects whose package/import/compat values cannot be discovered.
|
||||
$overrides = is_array($before['metadataOverrides'] ?? null) ? $before['metadataOverrides'] : [];
|
||||
foreach (['name', 'summary', 'description', 'packageName', 'importName', 'minNetboxVersion', 'maxNetboxVersion'] as $field) {
|
||||
if (array_key_exists($field, $overrides)) {
|
||||
$plugin[$field] = $overrides[$field];
|
||||
}
|
||||
}
|
||||
$plugin['metadataOverrides'] = $overrides;
|
||||
if (!$created && $this->changed($before, $plugin, self::PLUGIN_SECURITY)) {
|
||||
if (($plugin['status'] ?? '') === 'approved') {
|
||||
$plugin['status'] = 'pending';
|
||||
$plugin['approvedAt'] = null;
|
||||
$plugin['approvedBy'] = null;
|
||||
$plugin['moderationNote'] = 'Installationsrelevante Upstream-Metadaten wurden geändert und müssen erneut geprüft werden.';
|
||||
}
|
||||
foreach ($state['releases'] as &$release) {
|
||||
if ($release['pluginId'] === $plugin['id']) {
|
||||
Approval::resetRelease($release, 'Sicherheitsrelevante Plugin-Metadaten wurden geändert.');
|
||||
}
|
||||
}
|
||||
}
|
||||
$state['plugins'][$index] = $plugin;
|
||||
return [$plugin, $created];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $releaseInfo @return array{bool,string} */
|
||||
private function upsertRelease(string $pluginId, array $releaseInfo, SourceAdapter $adapter): array
|
||||
{
|
||||
$state = $this->repository->read();
|
||||
$existing = null;
|
||||
foreach ($state['releases'] as $candidate) {
|
||||
if ($candidate['pluginId'] === $pluginId && $candidate['externalId'] === $releaseInfo['externalId']) {
|
||||
$existing = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$artifact = ['sha256' => '', 'artifactSize' => 0];
|
||||
$hashError = '';
|
||||
// Release URLs are not necessarily immutable: Forgejo/GitHub assets can
|
||||
// be replaced in place. Re-hash every successful listing so replacement
|
||||
// is detected and the prior approval is reset even when the URL stayed
|
||||
// exactly the same.
|
||||
if (($releaseInfo['downloadUrl'] ?? '') !== '' && empty($releaseInfo['draft'])) {
|
||||
try {
|
||||
$artifact = $adapter->hashArtifact($releaseInfo['downloadUrl'], $releaseInfo['expectedSha256'] ?? '');
|
||||
} catch (Throwable $exception) {
|
||||
$hashError = Support::clip($exception->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
return $this->repository->transaction(function (array &$draft) use ($pluginId, $releaseInfo, $artifact, $hashError): array {
|
||||
$plugin = $this->find($draft['plugins'], $pluginId);
|
||||
if ($plugin === null) {
|
||||
throw new RuntimeException('Plugin disappeared during release sync.');
|
||||
}
|
||||
$index = null;
|
||||
foreach ($draft['releases'] as $candidateIndex => $candidate) {
|
||||
if ($candidate['pluginId'] === $pluginId && $candidate['externalId'] === $releaseInfo['externalId']) {
|
||||
$index = $candidateIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$created = $index === null;
|
||||
if ($created) {
|
||||
$draft['releases'][] = [
|
||||
'id' => Support::uuid(), 'pluginId' => $pluginId, 'externalId' => $releaseInfo['externalId'],
|
||||
'status' => 'pending', 'approvedAt' => null, 'approvedBy' => null,
|
||||
'approvedPayloadSha256' => '', 'moderationNote' => '', 'createdAt' => Support::now(),
|
||||
];
|
||||
$index = array_key_last($draft['releases']);
|
||||
}
|
||||
$before = $draft['releases'][$index];
|
||||
$downloadUrl = $this->publicUrl($releaseInfo['downloadUrl'] ?? '');
|
||||
$release = array_merge($draft['releases'][$index], [
|
||||
'version' => Support::clip($releaseInfo['version'], 100), 'title' => Support::clip($releaseInfo['title'], 220),
|
||||
'releaseUrl' => $this->publicUrl($releaseInfo['releaseUrl'] ?? ''), 'downloadUrl' => $downloadUrl,
|
||||
'sha256' => $artifact['sha256'], 'artifactSize' => (int) $artifact['artifactSize'],
|
||||
'commitSha' => preg_match('/^[a-f0-9]{40}$/', $releaseInfo['commitSha'] ?? '') ? $releaseInfo['commitSha'] : '',
|
||||
'artifactKind' => str_ends_with(strtolower(parse_url($downloadUrl, PHP_URL_PATH) ?: ''), '.whl') ? 'wheel' : 'invalid',
|
||||
'prerelease' => (bool) ($releaseInfo['prerelease'] ?? false), 'draft' => (bool) ($releaseInfo['draft'] ?? false),
|
||||
'withdrawn' => false,
|
||||
'changelog' => (string) ($releaseInfo['changelog'] ?? ''), 'publishedAt' => $releaseInfo['publishedAt'] ?? null,
|
||||
'minNetboxVersion' => $plugin['minNetboxVersion'], 'maxNetboxVersion' => $plugin['maxNetboxVersion'],
|
||||
'updatedAt' => Support::now(),
|
||||
]);
|
||||
if ($hashError !== '') {
|
||||
$release['moderationNote'] = 'Artefakt konnte nicht gehasht werden: ' . $hashError;
|
||||
}
|
||||
if ((!$created && $this->changed($before, $release, self::RELEASE_SECURITY)) || (($release['status'] ?? '') === 'approved' && !Approval::current($plugin, $release))) {
|
||||
Approval::resetRelease($release, $hashError ?: 'Artefakt- oder Installationsdaten wurden geändert.');
|
||||
}
|
||||
$draft['releases'][$index] = $release;
|
||||
return [$created, $hashError];
|
||||
});
|
||||
}
|
||||
|
||||
/** @param list<array<string,mixed>> $items */
|
||||
private function find(array $items, string $id): ?array
|
||||
{
|
||||
foreach ($items as $item) {
|
||||
if (($item['id'] ?? '') === $id) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $before @param array<string,mixed> $after @param list<string> $fields */
|
||||
private function changed(array $before, array $after, array $fields): bool
|
||||
{
|
||||
foreach ($fields as $field) {
|
||||
if (($before[$field] ?? null) !== ($after[$field] ?? null)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
private function uniqueSlug(array $state, string $desired, string $sourceSlug): string
|
||||
{
|
||||
$used = array_column($state['plugins'], 'slug');
|
||||
$base = Support::slug($desired) ?: 'plugin';
|
||||
if (!in_array($base, $used, true)) {
|
||||
return $base;
|
||||
}
|
||||
$candidate = rtrim(substr($sourceSlug . '-' . $base, 0, 64), '-');
|
||||
for ($suffix = 2; in_array($candidate, $used, true); $suffix++) {
|
||||
$candidate = rtrim(substr($sourceSlug . '-' . $base, 0, max(1, 63 - strlen((string) $suffix))), '-') . '-' . $suffix;
|
||||
}
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function publicUrl(string $url): string
|
||||
{
|
||||
return filter_var($url, FILTER_VALIDATE_URL) !== false && str_starts_with($url, 'https://') && parse_url($url, PHP_URL_USER) === null ? $url : '';
|
||||
}
|
||||
|
||||
/** @param list<string> $externalIds */
|
||||
private function withdrawMissingReleases(string $pluginId, array $externalIds): void
|
||||
{
|
||||
$this->repository->transaction(function (array &$state) use ($pluginId, $externalIds): void {
|
||||
foreach ($state['releases'] as &$release) {
|
||||
if ($release['pluginId'] === $pluginId && !in_array($release['externalId'], $externalIds, true)) {
|
||||
$release['withdrawn'] = true;
|
||||
$release['updatedAt'] = Support::now();
|
||||
Approval::resetRelease($release, 'Upstream-Release wurde zurückgezogen oder entfernt.');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use NetBoxStore\Domain\Approval;
|
||||
|
||||
$statusLabel = static fn (string $status): string => match ($status) {
|
||||
'approved' => 'Freigegeben',
|
||||
'rejected' => 'Abgelehnt',
|
||||
'pending' => 'Ausstehend',
|
||||
default => ucfirst($status),
|
||||
};
|
||||
$statusClass = static fn (string $status): string => match ($status) {
|
||||
'approved' => 'success',
|
||||
'rejected' => 'danger',
|
||||
default => 'warning',
|
||||
};
|
||||
include dirname(__DIR__) . '/partials/head.php';
|
||||
?>
|
||||
<section class="admin-hero">
|
||||
<div class="shell admin-title-row">
|
||||
<div>
|
||||
<span class="eyebrow">Moderation & Supply Chain</span>
|
||||
<h1>Store-Administration</h1>
|
||||
<p>Angemeldet als <strong><?= $e($adminUser) ?></strong>. Jede Freigabe wird protokolliert.</p>
|
||||
</div>
|
||||
<form method="post" action="/admin/logout">
|
||||
<input type="hidden" name="_csrf" value="<?= $e($csrf) ?>">
|
||||
<button class="button secondary" type="submit">Abmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="shell admin-layout">
|
||||
<?php if (($ok ?? '') !== ''): ?><div class="notice success"><?= $e($ok) ?></div><?php endif; ?>
|
||||
<?php if (($error ?? '') !== ''): ?><div class="notice error"><?= $e($error) ?></div><?php endif; ?>
|
||||
|
||||
<div class="admin-stats">
|
||||
<div><strong><?= $e(count($sources ?? [])) ?></strong><span>Quellen</span></div>
|
||||
<div><strong><?= $e(count($plugins ?? [])) ?></strong><span>Plugins</span></div>
|
||||
<div><strong><?= $e(count($releases ?? [])) ?></strong><span>Artefakte</span></div>
|
||||
<div><strong><?= $e(count(array_filter($plugins ?? [], static fn (array $item): bool => ($item['status'] ?? '') === 'pending')) + count(array_filter($releases ?? [], static fn (array $item): bool => ($item['status'] ?? '') === 'pending'))) ?></strong><span>Offen</span></div>
|
||||
</div>
|
||||
|
||||
<section class="admin-section">
|
||||
<div class="admin-section-heading">
|
||||
<div><span class="eyebrow">01</span><h2>Quellen</h2></div>
|
||||
<details class="create-source">
|
||||
<summary class="button primary">Quelle hinzufügen</summary>
|
||||
<form method="post" action="/admin/sources" class="form-grid popover-form">
|
||||
<input type="hidden" name="_csrf" value="<?= $e($csrf) ?>">
|
||||
<label><span>Name</span><input name="name" required maxlength="120" placeholder="Mein Forgejo"></label>
|
||||
<label><span>Slug</span><input name="slug" maxlength="64" placeholder="mein-forgejo"></label>
|
||||
<label><span>Provider</span><select name="provider"><option value="forgejo">Forgejo / Gitea</option><option value="github">GitHub</option></select></label>
|
||||
<label><span>Owner-Typ</span><select name="owner_kind"><option value="user">Benutzer</option><option value="organization">Organisation</option><option value="auto">Automatisch</option></select></label>
|
||||
<label><span>Owner</span><input name="owner" required maxlength="120" placeholder="MrBlake"></label>
|
||||
<label><span>Topic</span><input name="topic" maxlength="80" value="netbox-plugin"></label>
|
||||
<label class="span-2"><span>Basis-URL</span><input type="url" name="base_url" required placeholder="https://git.example.com"></label>
|
||||
<label class="span-2"><span>API-URL</span><input type="url" name="api_url" placeholder="https://git.example.com/api/v1"></label>
|
||||
<label><span>Token-ENV</span><input name="token_env" pattern="[A-Z][A-Z0-9_]*" placeholder="GITEA_TOKEN"></label>
|
||||
<div class="span-2 form-actions"><button class="button primary" type="submit">Ausstehend anlegen</button></div>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead><tr><th>Quelle</th><th>Provider / Owner</th><th>Status</th><th>Letzter Sync</th><th>Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach (($sources ?? []) as $source): ?>
|
||||
<tr>
|
||||
<td><strong><?= $e($source['name']) ?></strong><small><?= $e($source['baseUrl']) ?></small></td>
|
||||
<td><?= $e($source['provider']) ?> · <?= $e($source['owner']) ?><small>Öffentliche Wheel-Releases</small></td>
|
||||
<td><span class="status-pill <?= $e($statusClass($source['status'])) ?>"><?= $e($statusLabel($source['status'])) ?></span></td>
|
||||
<td><?= $e($formatDate($source['lastSyncedAt'] ?? null)) ?></td>
|
||||
<td><div class="action-row">
|
||||
<?php if ($source['status'] !== 'approved'): ?><form method="post" action="/admin/sources/<?= $e($source['id']) ?>/approve"><input type="hidden" name="_csrf" value="<?= $e($csrf) ?>"><button class="mini-button approve">Freigeben</button></form><?php endif; ?>
|
||||
<?php if ($source['status'] !== 'rejected'): ?><form method="post" action="/admin/sources/<?= $e($source['id']) ?>/reject"><input type="hidden" name="_csrf" value="<?= $e($csrf) ?>"><button class="mini-button reject">Ablehnen</button></form><?php endif; ?>
|
||||
<?php if ($source['status'] === 'approved'): ?><form method="post" action="/admin/sources/<?= $e($source['id']) ?>/resync"><input type="hidden" name="_csrf" value="<?= $e($csrf) ?>"><button class="mini-button">Synchronisieren</button></form><?php endif; ?>
|
||||
</div></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<div class="admin-section-heading"><div><span class="eyebrow">02</span><h2>Plugin-Kandidaten</h2></div><p>Remote-Metadaten können vor der Freigabe dauerhaft korrigiert werden.</p></div>
|
||||
<div class="moderation-list">
|
||||
<?php if (($plugins ?? []) === []): ?><p class="empty-row">Noch keine Kandidaten eingelesen.</p><?php endif; ?>
|
||||
<?php foreach (($plugins ?? []) as $plugin): ?>
|
||||
<?php $pluginErrors = Approval::pluginErrors($plugin); ?>
|
||||
<details class="moderation-item" <?= $plugin['status'] === 'pending' ? 'open' : '' ?>>
|
||||
<summary>
|
||||
<span><strong><?= $e($plugin['name'] ?: $plugin['repositoryName']) ?></strong><small><?= $e(($plugin['source']['name'] ?? '') . ' · ' . $plugin['repositoryOwner'] . '/' . $plugin['repositoryName']) ?></small></span>
|
||||
<span class="summary-status"><span class="status-pill <?= $e($statusClass($plugin['status'])) ?>"><?= $e($statusLabel($plugin['status'])) ?></span><?php if (!empty($plugin['archived'])): ?><span class="status-pill danger">Archiviert</span><?php endif; ?></span>
|
||||
</summary>
|
||||
<div class="moderation-body">
|
||||
<?php if ($pluginErrors !== []): ?><div class="validation-box"><strong>Freigabe blockiert</strong><ul><?php foreach ($pluginErrors as $validationError): ?><li><?= $e($validationError) ?></li><?php endforeach; ?></ul></div><?php endif; ?>
|
||||
<form method="post" action="/admin/plugins/<?= $e($plugin['id']) ?>/edit" class="form-grid">
|
||||
<input type="hidden" name="_csrf" value="<?= $e($csrf) ?>">
|
||||
<label><span>Name</span><input name="name" required maxlength="180" value="<?= $e($plugin['name']) ?>"></label>
|
||||
<label><span>Slug</span><input name="slug" required maxlength="64" pattern="[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?" value="<?= $e($plugin['slug']) ?>"></label>
|
||||
<label class="span-2"><span>Kurzbeschreibung</span><input name="summary" maxlength="320" value="<?= $e($plugin['summary']) ?>"></label>
|
||||
<label class="span-2"><span>Beschreibung</span><textarea name="description" maxlength="65535" rows="3"><?= $e($plugin['description']) ?></textarea></label>
|
||||
<label><span>Python-Paket</span><input name="package_name" required maxlength="128" value="<?= $e($plugin['packageName']) ?>"></label>
|
||||
<label><span>Top-Level-Import</span><input name="import_name" required maxlength="128" value="<?= $e($plugin['importName']) ?>"></label>
|
||||
<label><span>Min. NetBox</span><input name="min_netbox_version" required value="<?= $e($plugin['minNetboxVersion']) ?>"></label>
|
||||
<label><span>Max. NetBox</span><input name="max_netbox_version" required value="<?= $e($plugin['maxNetboxVersion']) ?>"></label>
|
||||
<div class="span-2 form-actions"><button class="button secondary" type="submit">Korrekturen speichern</button><small>Diese Overrides bleiben bei späteren Syncs erhalten.</small></div>
|
||||
</form>
|
||||
<div class="moderation-actions">
|
||||
<?php if ($plugin['status'] !== 'approved'): ?><form method="post" action="/admin/plugins/<?= $e($plugin['id']) ?>/approve"><input type="hidden" name="_csrf" value="<?= $e($csrf) ?>"><button class="button primary" <?= $pluginErrors !== [] ? 'disabled title="Metadaten zuerst korrigieren"' : '' ?>>Plugin freigeben</button></form><?php endif; ?>
|
||||
<?php if ($plugin['status'] !== 'rejected'): ?><form method="post" action="/admin/plugins/<?= $e($plugin['id']) ?>/reject"><input type="hidden" name="_csrf" value="<?= $e($csrf) ?>"><button class="button danger">Ablehnen</button></form><?php endif; ?>
|
||||
<form method="post" action="/admin/plugins/<?= $e($plugin['id']) ?>/resync"><input type="hidden" name="_csrf" value="<?= $e($csrf) ?>"><button class="button quiet">Quelle neu einlesen</button></form>
|
||||
<a class="button quiet" href="<?= $e($plugin['repositoryUrl']) ?>" rel="noreferrer noopener">Repository ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-section">
|
||||
<div class="admin-section-heading"><div><span class="eyebrow">03</span><h2>Release-Artefakte</h2></div><p>Neue oder veränderte Payloads sind immer ausstehend.</p></div>
|
||||
<div class="table-wrap">
|
||||
<table class="admin-table release-admin-table">
|
||||
<thead><tr><th>Plugin / Version</th><th>Artefakt</th><th>Integrität</th><th>Status</th><th>Aktionen</th></tr></thead>
|
||||
<tbody>
|
||||
<?php if (($releases ?? []) === []): ?><tr><td colspan="5" class="empty-row">Keine Release-Artefakte gefunden. Veröffentliche ein Wheel als Forgejo-Release-Asset.</td></tr><?php endif; ?>
|
||||
<?php foreach (($releases ?? []) as $release): ?>
|
||||
<?php $releasePlugin = $release['plugin'] ?? []; $releaseErrors = Approval::releaseErrors($releasePlugin, $release); ?>
|
||||
<tr>
|
||||
<td><strong><?= $e($releasePlugin['name'] ?? 'Unbekannt') ?> · <?= $e($release['version']) ?></strong><small><?= $e($release['title'] ?? '') ?></small></td>
|
||||
<td>
|
||||
<span class="status-pill <?= ($release['artifactKind'] ?? '') === 'wheel' ? 'success' : 'warning' ?>"><?= $e($release['artifactKind'] ?? 'source') ?></span>
|
||||
<small><?= $e($formatBytes($release['artifactSize'])) ?> · <?= $e($formatDate($release['publishedAt'] ?? null)) ?></small>
|
||||
<details class="artifact-details"><summary>URLs vollständig prüfen</summary>
|
||||
<strong>Download</strong><?php if (($release['downloadUrl'] ?? '') !== ''): ?><a href="<?= $e($release['downloadUrl']) ?>" rel="noreferrer noopener" class="break-value"><?= $e($release['downloadUrl']) ?></a><?php else: ?><span>–</span><?php endif; ?>
|
||||
<?php if (($release['releaseUrl'] ?? '') !== ''): ?><strong>Release-Seite</strong><a href="<?= $e($release['releaseUrl']) ?>" rel="noreferrer noopener" class="break-value"><?= $e($release['releaseUrl']) ?></a><?php endif; ?>
|
||||
</details>
|
||||
</td>
|
||||
<td>
|
||||
<code class="full-hash"><?= ($release['sha256'] ?? '') !== '' ? $e($release['sha256']) : 'kein Hash' ?></code>
|
||||
<small>Commit: <code class="break-value"><?= $e($release['commitSha'] ?? '–') ?></code></small>
|
||||
<small>NetBox: <?= $e(($release['minNetboxVersion'] ?? '') ?: ($releasePlugin['minNetboxVersion'] ?? '–')) ?> – <?= $e(($release['maxNetboxVersion'] ?? '') ?: ($releasePlugin['maxNetboxVersion'] ?? '–')) ?></small>
|
||||
<?php if ($releaseErrors !== []): ?><small class="text-danger" title="<?= $e(implode(' ', $releaseErrors)) ?>"><?= $e(count($releaseErrors)) ?> Prüfproblem(e)</small><?php endif; ?>
|
||||
</td>
|
||||
<td><span class="status-pill <?= $e($statusClass($release['status'])) ?>"><?= $e($statusLabel($release['status'])) ?></span><?php if (!empty($release['withdrawn'])): ?><small class="text-danger">Zurückgezogen</small><?php endif; ?></td>
|
||||
<td><div class="action-row">
|
||||
<?php if ($release['status'] !== 'approved'): ?><form method="post" action="/admin/releases/<?= $e($release['id']) ?>/approve"><input type="hidden" name="_csrf" value="<?= $e($csrf) ?>"><button class="mini-button approve" <?= $releaseErrors !== [] ? 'disabled title="Validierung fehlgeschlagen"' : '' ?>>Freigeben</button></form><?php endif; ?>
|
||||
<?php if ($release['status'] !== 'rejected'): ?><form method="post" action="/admin/releases/<?= $e($release['id']) ?>/reject"><input type="hidden" name="_csrf" value="<?= $e($csrf) ?>"><button class="mini-button reject">Ablehnen</button></form><?php endif; ?>
|
||||
<form method="post" action="/admin/releases/<?= $e($release['id']) ?>/resync"><input type="hidden" name="_csrf" value="<?= $e($csrf) ?>"><button class="mini-button">Neu laden</button></form>
|
||||
</div></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="admin-columns">
|
||||
<section class="admin-section compact-section">
|
||||
<div class="admin-section-heading"><div><span class="eyebrow">04</span><h2>Sync-Verlauf</h2></div></div>
|
||||
<ol class="timeline">
|
||||
<?php if (($runs ?? []) === []): ?><li class="empty-row">Noch kein Sync ausgeführt.</li><?php endif; ?>
|
||||
<?php foreach (($runs ?? []) as $run): ?>
|
||||
<li><span class="timeline-dot <?= $e($run['status']) ?>"></span><div>
|
||||
<strong><?= $e($run['status']) ?></strong><p><?= $e($run['message']) ?></p>
|
||||
<small><?= $e($formatDate($run['startedAt'])) ?> · <?= $e($run['candidatesFound']) ?> Kandidaten · <?= $e(count($run['errors'] ?? [])) ?> Hinweise/Fehler</small>
|
||||
<?php if (($run['errors'] ?? []) !== []): ?><details class="sync-errors"><summary>Details anzeigen</summary><ul>
|
||||
<?php foreach ($run['errors'] as $item): ?><li><strong><?= $e($item['repository'] ?? $item['source'] ?? 'Source') ?>:</strong> <?= $e($item['error'] ?? 'Unbekannter Fehler') ?></li><?php endforeach; ?>
|
||||
</ul></details><?php endif; ?>
|
||||
</div></li>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
</section>
|
||||
<section class="admin-section compact-section">
|
||||
<div class="admin-section-heading"><div><span class="eyebrow">05</span><h2>Audit-Log</h2></div></div>
|
||||
<ol class="timeline">
|
||||
<?php if (($audits ?? []) === []): ?><li class="empty-row">Noch keine Admin-Aktion protokolliert.</li><?php endif; ?>
|
||||
<?php foreach (($audits ?? []) as $audit): ?><li><span class="timeline-dot audit"></span><div><strong><?= $e($audit['actor']) ?> · <?= $e($audit['action']) ?></strong><p><?= $e($audit['targetType']) ?> · <code><?= $e(substr($audit['targetId'], 0, 12)) ?></code></p><small><?= $e($formatDate($audit['timestamp'])) ?></small></div></li><?php endforeach; ?>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<?php include dirname(__DIR__) . '/partials/footer.php'; ?>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php declare(strict_types=1); include dirname(__DIR__) . '/partials/head.php'; ?>
|
||||
<section class="login-page shell">
|
||||
<div class="login-card">
|
||||
<span class="eyebrow">Geschützter Bereich</span>
|
||||
<h1>Store-Administration</h1>
|
||||
<p>Plugins und Artefakte werden hier geprüft, bevor sie im öffentlichen Katalog erscheinen.</p>
|
||||
<?php if (($error ?? '') !== ''): ?><div class="notice error"><?= $e($error) ?></div><?php endif; ?>
|
||||
<form method="post" action="/admin/login" class="stack-form">
|
||||
<input type="hidden" name="_csrf" value="<?= $e($csrf) ?>">
|
||||
<label><span>Benutzername</span><input name="username" required autocomplete="username" autofocus></label>
|
||||
<label><span>Passwort</span><input type="password" name="password" required autocomplete="current-password"></label>
|
||||
<button class="button primary wide" type="submit">Sicher anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<?php include dirname(__DIR__) . '/partials/footer.php'; ?>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php declare(strict_types=1); include __DIR__ . '/partials/head.php'; ?>
|
||||
<section class="shell error-page">
|
||||
<span class="error-code"><?= $e($status ?? 500) ?></span>
|
||||
<h1><?= $e($title ?? 'Fehler') ?></h1>
|
||||
<p><?= $e($message ?? 'Die Anfrage konnte nicht verarbeitet werden.') ?></p>
|
||||
<a class="button primary" href="/">Zur Store-Startseite</a>
|
||||
</section>
|
||||
<?php include __DIR__ . '/partials/footer.php'; ?>
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php declare(strict_types=1); include __DIR__ . '/partials/head.php'; ?>
|
||||
<section class="hero">
|
||||
<div class="shell hero-grid">
|
||||
<div>
|
||||
<span class="eyebrow">Kuratierter Katalog</span>
|
||||
<h1>Plugins, auf die dein<br><span>NetBox vertrauen kann.</span></h1>
|
||||
<p class="hero-copy">Entdecke freigegebene Erweiterungen aus unseren Forgejo-Repositories. Jede Version wird separat geprüft, gehasht und erst nach einer Admin-Freigabe installierbar.</p>
|
||||
</div>
|
||||
<div class="hero-stat" aria-label="Katalogstatistik">
|
||||
<strong><?= $e($totalCount ?? 0) ?></strong>
|
||||
<span>freigegebene Plugins</span>
|
||||
<small>Metadaten commitgenau synchronisiert</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="shell catalog-section">
|
||||
<form class="filter-panel" method="get" action="/">
|
||||
<label class="search-field">
|
||||
<span>Plugins durchsuchen</span>
|
||||
<input type="search" name="q" value="<?= $e($query ?? '') ?>" placeholder="Name, Paket oder Beschreibung …">
|
||||
</label>
|
||||
<label>
|
||||
<span>Quelle</span>
|
||||
<select name="source">
|
||||
<option value="">Alle Quellen</option>
|
||||
<?php foreach (($sources ?? []) as $filterSource): ?>
|
||||
<option value="<?= $e($filterSource['slug']) ?>" <?= ($selectedSource ?? '') === $filterSource['slug'] ? 'selected' : '' ?>><?= $e($filterSource['name']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>NetBox-Version</span>
|
||||
<input name="netbox_version" value="<?= $e($selectedNetboxVersion ?? '') ?>" placeholder="z. B. 4.6.5" inputmode="decimal">
|
||||
</label>
|
||||
<button class="button primary" type="submit">Filtern</button>
|
||||
<?php if (($query ?? '') !== '' || ($selectedSource ?? '') !== '' || ($selectedNetboxVersion ?? '') !== ''): ?>
|
||||
<a class="button quiet" href="/">Zurücksetzen</a>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
<?php if (!empty($invalidVersion)): ?>
|
||||
<p class="notice error">Die angegebene NetBox-Version ist ungültig. Der Versionsfilter wurde ignoriert.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="catalog-heading">
|
||||
<div>
|
||||
<span class="eyebrow">Store</span>
|
||||
<h2><?= $e($count ?? 0) ?> <?= ($count ?? 0) === 1 ? 'Plugin' : 'Plugins' ?></h2>
|
||||
</div>
|
||||
<span class="muted">Nur freigegebene Katalogeinträge</span>
|
||||
</div>
|
||||
|
||||
<?php if (($plugins ?? []) === []): ?>
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon" aria-hidden="true">⌁</span>
|
||||
<h2>Keine Plugins gefunden</h2>
|
||||
<p>Ändere die Suche oder den Versionsfilter. Neu eingelesene Plugins erscheinen erst nach der Freigabe.</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="plugin-grid">
|
||||
<?php foreach ($plugins as $plugin): ?>
|
||||
<?php $release = $plugin['latestRelease'] ?? null; $isWheel = is_array($release) && ($release['artifactKind'] ?? '') === 'wheel'; ?>
|
||||
<article class="plugin-card">
|
||||
<div class="card-topline">
|
||||
<span class="provider-pill"><?= $e(strtoupper((string) ($plugin['source']['provider'] ?? 'git'))) ?></span>
|
||||
<?php if ($isWheel): ?>
|
||||
<span class="status-pill success">Wheel geprüft</span>
|
||||
<?php elseif ($release): ?>
|
||||
<span class="status-pill warning">Kein installierbares Release</span>
|
||||
<?php else: ?>
|
||||
<span class="status-pill neutral">Kein installierbares Release</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<h3><a href="/plugins/<?= $e(rawurlencode($plugin['slug'])) ?>"><?= $e($plugin['name']) ?></a></h3>
|
||||
<p><?= $e($plugin['summary'] ?: 'Für dieses Plugin ist noch keine Kurzbeschreibung hinterlegt.') ?></p>
|
||||
<dl class="card-meta">
|
||||
<div><dt>NetBox</dt><dd><?= $e($plugin['minNetboxVersion']) ?> – <?= $e($plugin['maxNetboxVersion']) ?></dd></div>
|
||||
<div><dt>Version</dt><dd><?= $release ? $e($release['version']) : '–' ?></dd></div>
|
||||
</dl>
|
||||
<div class="card-footer">
|
||||
<code><?= $e($plugin['packageName']) ?></code>
|
||||
<a class="arrow-link" href="/plugins/<?= $e(rawurlencode($plugin['slug'])) ?>" aria-label="Details zu <?= $e($plugin['name']) ?>">Details <span aria-hidden="true">→</span></a>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (($pages ?? 1) > 1): ?>
|
||||
<nav class="pagination" aria-label="Ergebnisseiten">
|
||||
<?php $baseQuery = ['q' => $query ?? '', 'source' => $selectedSource ?? '', 'netbox_version' => $selectedNetboxVersion ?? '']; ?>
|
||||
<?php if (($page ?? 1) > 1): ?><a href="/?<?= $e(http_build_query($baseQuery + ['page' => $page - 1])) ?>">← Zurück</a><?php endif; ?>
|
||||
<span>Seite <?= $e($page) ?> von <?= $e($pages) ?></span>
|
||||
<?php if (($page ?? 1) < $pages): ?><a href="/?<?= $e(http_build_query($baseQuery + ['page' => $page + 1])) ?>">Weiter →</a><?php endif; ?>
|
||||
</nav>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
<?php include __DIR__ . '/partials/footer.php'; ?>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php declare(strict_types=1); ?>
|
||||
</main>
|
||||
<footer class="site-footer">
|
||||
<div class="shell footer-inner">
|
||||
<div>
|
||||
<strong>NetBox Plugin Store</strong>
|
||||
<p>Freigegebene Metadaten, reproduzierbar geprüfte Artefakte.</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="/api/v1/plugins/">Catalog API v1</a>
|
||||
<a href="/healthz">Systemstatus</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php declare(strict_types=1); ?>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="theme-color" content="#102a43">
|
||||
<meta name="description" content="Kuratierter Plugin-Katalog für NetBox mit geprüften Artefakten.">
|
||||
<title><?= $e($title ?? 'NetBox Plugin Store') ?></title>
|
||||
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/assets/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#content">Zum Inhalt springen</a>
|
||||
<header class="site-header">
|
||||
<div class="shell header-inner">
|
||||
<a class="brand" href="/" aria-label="NetBox Plugin Store – Startseite">
|
||||
<span class="brand-mark" aria-hidden="true">N</span>
|
||||
<span><strong>NetBox</strong><small>Plugin Store</small></span>
|
||||
</a>
|
||||
<nav class="main-nav" aria-label="Hauptnavigation">
|
||||
<a class="<?= ($currentPath ?? '') === '/' ? 'active' : '' ?>" href="/">Store</a>
|
||||
<a href="/api/v1/plugins/">API</a>
|
||||
<?php if (!empty($adminEnabled)): ?>
|
||||
<a class="<?= str_starts_with((string) ($currentPath ?? ''), '/admin') ? 'active' : '' ?>" href="/admin">Admin</a>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main id="content">
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php declare(strict_types=1); include __DIR__ . '/partials/head.php'; ?>
|
||||
<?php $wheelReleases = array_values(array_filter($releases ?? [], static fn (array $release): bool => ($release['artifactKind'] ?? '') === 'wheel')); ?>
|
||||
<section class="detail-hero">
|
||||
<div class="shell">
|
||||
<a class="back-link" href="/">← Alle Plugins</a>
|
||||
<div class="detail-title-row">
|
||||
<div>
|
||||
<span class="eyebrow"><?= $e(strtoupper((string) ($source['provider'] ?? 'Git'))) ?> · <?= $e($source['name'] ?? '') ?></span>
|
||||
<h1><?= $e($plugin['name']) ?></h1>
|
||||
<p><?= $e($plugin['summary'] ?: $plugin['description']) ?></p>
|
||||
</div>
|
||||
<div class="detail-actions">
|
||||
<a class="button primary" href="<?= $e($plugin['repositoryUrl']) ?>" rel="noreferrer noopener">Repository öffnen ↗</a>
|
||||
<a class="button secondary" href="/api/v1/plugins/<?= $e(rawurlencode($plugin['slug'])) ?>/">API-Daten</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="shell detail-layout">
|
||||
<article class="readme-card">
|
||||
<div class="section-label">README · Commit <?= $e(substr((string) ($plugin['commitSha'] ?? ''), 0, 10)) ?></div>
|
||||
<div class="readme-content">
|
||||
<?php if (($plugin['readmeHtml'] ?? '') !== ''): ?>
|
||||
<?= $plugin['readmeHtml'] ?>
|
||||
<?php else: ?>
|
||||
<div class="empty-inline"><h2>Keine README gefunden</h2><p>Die synchronisierte Revision enthält keine unterstützte README-Datei.</p></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="detail-sidebar">
|
||||
<section class="side-card">
|
||||
<h2>Kompatibilität</h2>
|
||||
<dl class="side-list">
|
||||
<div><dt>NetBox</dt><dd><?= $e($plugin['minNetboxVersion']) ?> – <?= $e($plugin['maxNetboxVersion']) ?></dd></div>
|
||||
<div><dt>Python-Paket</dt><dd><code><?= $e($plugin['packageName']) ?></code></dd></div>
|
||||
<div><dt>Import</dt><dd><code><?= $e($plugin['importName']) ?></code></dd></div>
|
||||
<div><dt>Lizenz</dt><dd><?= $e($plugin['license'] ?: 'Nicht angegeben') ?></dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="side-card releases-card">
|
||||
<div class="side-heading"><h2>Releases</h2><span><?= $e(count($releases ?? [])) ?></span></div>
|
||||
<?php if ($wheelReleases === []): ?>
|
||||
<div class="release-warning">
|
||||
<strong>Kein installierbares Release</strong>
|
||||
<p>Für die automatische Installation durch den Host-Agenten fehlt ein freigegebenes <code>.whl</code>-Artefakt.</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (($releases ?? []) === []): ?>
|
||||
<p class="muted">Es wurde noch kein Artefakt separat freigegeben.</p>
|
||||
<?php else: ?>
|
||||
<ol class="release-list">
|
||||
<?php foreach ($releases as $release): ?>
|
||||
<li>
|
||||
<div><strong><?= $e($release['version']) ?></strong><span><?= $e($formatDate($release['publishedAt'] ?? null)) ?></span></div>
|
||||
<div class="release-tags">
|
||||
<span class="status-pill success">Wheel</span>
|
||||
<span><?= $e($formatBytes($release['artifactSize'])) ?></span>
|
||||
</div>
|
||||
<code class="hash" title="SHA-256"><?= $e(substr($release['sha256'], 0, 16)) ?>…</code>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ol>
|
||||
<?php endif; ?>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
<?php include __DIR__ . '/partials/footer.php'; ?>
|
||||
@@ -0,0 +1,852 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use NetBoxStore\Config;
|
||||
use NetBoxStore\Database\JsonStoreRepository;
|
||||
use NetBoxStore\Database\CallbackLease;
|
||||
use NetBoxStore\Database\ExclusiveLease;
|
||||
use NetBoxStore\Database\State;
|
||||
use NetBoxStore\Database\StoreRepository;
|
||||
use NetBoxStore\Domain\Approval;
|
||||
use NetBoxStore\Domain\Catalog;
|
||||
use NetBoxStore\Http\Application;
|
||||
use NetBoxStore\Http\Request;
|
||||
use NetBoxStore\Http\View;
|
||||
use NetBoxStore\Security\Auth;
|
||||
use NetBoxStore\Security\HttpClient;
|
||||
use NetBoxStore\Security\SsrfGuard;
|
||||
use NetBoxStore\Support;
|
||||
use NetBoxStore\Sync\Adapter\SourceAdapter;
|
||||
use NetBoxStore\Sync\Adapter\ForgejoAdapter;
|
||||
use NetBoxStore\Sync\Adapter\GitHubAdapter;
|
||||
use NetBoxStore\Sync\Discovery;
|
||||
use NetBoxStore\Sync\ReadmeRenderer;
|
||||
use NetBoxStore\Sync\SyncService;
|
||||
use NetBoxStore\Sync\SyncBudget;
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||
if ((error_reporting() & $severity) === 0) {
|
||||
return false;
|
||||
}
|
||||
throw new ErrorException($message, 0, $severity, $file, $line);
|
||||
});
|
||||
|
||||
final class MemoryRepository implements StoreRepository
|
||||
{
|
||||
public int $transactions = 0;
|
||||
/** @var array<string,true> */
|
||||
private array $leases = [];
|
||||
|
||||
/** @param array<string,mixed> $state */
|
||||
public function __construct(public array $state)
|
||||
{
|
||||
State::validate($this->state);
|
||||
}
|
||||
|
||||
public function initialize(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function read(): array
|
||||
{
|
||||
return $this->state;
|
||||
}
|
||||
|
||||
public function acquireLease(string $name): ?ExclusiveLease
|
||||
{
|
||||
if (isset($this->leases[$name])) {
|
||||
return null;
|
||||
}
|
||||
$this->leases[$name] = true;
|
||||
return new CallbackLease(function () use ($name): void {
|
||||
unset($this->leases[$name]);
|
||||
});
|
||||
}
|
||||
|
||||
public function transaction(callable $callback): mixed
|
||||
{
|
||||
$this->transactions++;
|
||||
$draft = $this->state;
|
||||
$result = $callback($draft);
|
||||
State::validate($draft);
|
||||
$this->state = $draft;
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeAdapter implements SourceAdapter
|
||||
{
|
||||
/** @var array<string,string> */
|
||||
public array $files = [];
|
||||
/** @var list<array<string,mixed>> */
|
||||
public array $releases = [];
|
||||
/** @var list<array<string,mixed>> */
|
||||
public array $repositories = [];
|
||||
/** @var list<string> */
|
||||
public array $readRefs = [];
|
||||
public string $artifactSha = '';
|
||||
public int $artifactSize = 128;
|
||||
public int $hashCalls = 0;
|
||||
public int $releaseListCalls = 0;
|
||||
public bool $throwOnCommit = false;
|
||||
|
||||
/** @param array<string,mixed> $repository */
|
||||
public function __construct(public array $repository)
|
||||
{
|
||||
}
|
||||
|
||||
public function listRepositories(): array
|
||||
{
|
||||
return $this->repositories !== [] ? $this->repositories : [$this->repository];
|
||||
}
|
||||
|
||||
public function getCommitSha(array $repository, ?string $ref = null): string
|
||||
{
|
||||
if ($this->throwOnCommit) {
|
||||
throw new RuntimeException('simulated inspection failure');
|
||||
}
|
||||
return (string) $this->repository['commitSha'];
|
||||
}
|
||||
|
||||
public function fetchText(array $repository, string $path, string $commitSha): ?string
|
||||
{
|
||||
$this->readRefs[] = $commitSha;
|
||||
if ($commitSha !== $this->repository['commitSha']) {
|
||||
throw new RuntimeException('unpinned read');
|
||||
}
|
||||
return $this->files[$path] ?? null;
|
||||
}
|
||||
|
||||
public function listTree(array $repository, string $commitSha): array
|
||||
{
|
||||
$this->readRefs[] = $commitSha;
|
||||
return array_keys($this->files);
|
||||
}
|
||||
|
||||
public function rawFileUrl(array $repository, string $path, string $commitSha): string
|
||||
{
|
||||
$this->readRefs[] = $commitSha;
|
||||
return 'https://git.mrblake.cc/' . $repository['fullName'] . '/raw/commit/' . $commitSha . '/' . $path;
|
||||
}
|
||||
|
||||
public function listReleases(array $repository): array
|
||||
{
|
||||
$this->releaseListCalls++;
|
||||
return $this->releases;
|
||||
}
|
||||
|
||||
public function hashArtifact(string $url, string $expectedSha256 = ''): array
|
||||
{
|
||||
$this->hashCalls++;
|
||||
return ['sha256' => $this->artifactSha, 'artifactSize' => $this->artifactSize];
|
||||
}
|
||||
}
|
||||
|
||||
/** @var array<string,Closure> $tests */
|
||||
$tests = [];
|
||||
function test(string $name, Closure $test): void
|
||||
{
|
||||
global $tests;
|
||||
$tests[$name] = $test;
|
||||
}
|
||||
|
||||
function assertTrue(bool $condition, string $message = 'assertTrue failed'): void
|
||||
{
|
||||
if (!$condition) {
|
||||
throw new RuntimeException($message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSame(mixed $expected, mixed $actual, string $message = ''): void
|
||||
{
|
||||
if ($expected !== $actual) {
|
||||
throw new RuntimeException(($message !== '' ? $message . ': ' : '') . 'expected ' . var_export($expected, true) . ', got ' . var_export($actual, true));
|
||||
}
|
||||
}
|
||||
|
||||
function assertThrows(Closure $callback, string $contains = ''): void
|
||||
{
|
||||
try {
|
||||
$callback();
|
||||
} catch (Throwable $exception) {
|
||||
if ($contains !== '' && !str_contains($exception->getMessage(), $contains)) {
|
||||
throw new RuntimeException('Exception did not contain expected text: ' . $exception->getMessage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new RuntimeException('Expected exception was not thrown.');
|
||||
}
|
||||
|
||||
function configureEnvironment(string $root, ?string $jsonPath = null): Config
|
||||
{
|
||||
$values = [
|
||||
'APP_ENV' => 'test',
|
||||
'STORE_PUBLIC_URL' => 'http://localhost:3000',
|
||||
'STORE_TRUST_PROXY' => 'false',
|
||||
'STORE_DB_DRIVER' => 'json',
|
||||
'STORE_JSON_PATH' => $jsonPath ?? ($root . '/data/test-store.json'),
|
||||
'STORE_ALLOWED_SOURCE_HOSTS' => 'git.mrblake.cc,api.github.com,github.com,raw.githubusercontent.com,127.0.0.1',
|
||||
'STORE_ALLOW_PRIVATE_NETWORKS' => 'false',
|
||||
'STORE_ADMIN_USERNAME' => '',
|
||||
'STORE_ADMIN_PASSWORD_HASH' => '',
|
||||
'STORE_SESSION_SECRET' => '',
|
||||
'STORE_DEFAULT_BASE_URL' => 'https://git.mrblake.cc',
|
||||
'STORE_DEFAULT_API_URL' => 'https://git.mrblake.cc/api/v1',
|
||||
'STORE_DEFAULT_OWNER' => 'MrBlake',
|
||||
'STORE_DEFAULT_PROVIDER' => 'forgejo',
|
||||
];
|
||||
foreach ($values as $key => $value) {
|
||||
putenv($key . '=' . $value);
|
||||
$_ENV[$key] = $value;
|
||||
}
|
||||
putenv('STORE_COOKIE_SECURE');
|
||||
unset($_ENV['STORE_COOKIE_SECURE']);
|
||||
return Config::load($root);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
function approvedFixture(): array
|
||||
{
|
||||
$state = State::empty();
|
||||
$source = [
|
||||
'id' => 'source-1', 'slug' => 'mrblake', 'name' => 'MrBlake', 'provider' => 'forgejo',
|
||||
'baseUrl' => 'https://git.mrblake.cc', 'apiUrl' => 'https://git.mrblake.cc/api/v1',
|
||||
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '', 'topic' => 'netbox-plugin',
|
||||
'status' => 'approved', 'active' => true, 'includeForks' => false, 'includeArchived' => false,
|
||||
'autoApprovePlugins' => false,
|
||||
];
|
||||
$plugin = [
|
||||
'id' => 'plugin-1', 'sourceId' => 'source-1', 'externalId' => '101', 'slug' => 'demo-plugin',
|
||||
'name' => 'Demo Plugin', 'summary' => 'Ein Testplugin', 'description' => 'Beschreibung',
|
||||
'repositoryOwner' => 'MrBlake', 'repositoryName' => 'netbox-demo',
|
||||
'repositoryUrl' => 'https://git.mrblake.cc/MrBlake/netbox-demo', 'packageName' => 'netbox-demo',
|
||||
'importName' => 'netbox_demo', 'minNetboxVersion' => '4.6.5', 'maxNetboxVersion' => '4.6.8',
|
||||
'status' => 'approved', 'active' => true, 'archived' => false, 'license' => 'MIT',
|
||||
'commitSha' => str_repeat('a', 40), 'readmeHtml' => '<p>README</p>',
|
||||
];
|
||||
$release = [
|
||||
'id' => 'release-1', 'pluginId' => 'plugin-1', 'externalId' => '501', 'version' => '1.2.3',
|
||||
'title' => '1.2.3', 'downloadUrl' => 'https://git.mrblake.cc/assets/netbox_demo-1.2.3-py3-none-any.whl',
|
||||
'releaseUrl' => 'https://git.mrblake.cc/releases/1', 'sha256' => str_repeat('b', 64),
|
||||
'artifactSize' => 12_345, 'commitSha' => str_repeat('a', 40), 'artifactKind' => 'wheel',
|
||||
'minNetboxVersion' => '4.6.5', 'maxNetboxVersion' => '4.6.8', 'publishedAt' => '2026-08-20T10:00:00Z',
|
||||
'draft' => false, 'withdrawn' => false, 'status' => 'pending',
|
||||
'approvedAt' => null, 'approvedBy' => null, 'approvedPayloadSha256' => '',
|
||||
];
|
||||
$state['sources'][] = $source;
|
||||
$state['plugins'][] = $plugin;
|
||||
$state['releases'][] = $release;
|
||||
Approval::approve($state, 'releases', 'release-1', 'test-admin');
|
||||
return $state;
|
||||
}
|
||||
|
||||
function fakeRepository(): array
|
||||
{
|
||||
return [
|
||||
'externalId' => '101', 'owner' => 'MrBlake', 'name' => 'netbox-demo', 'fullName' => 'MrBlake/netbox-demo',
|
||||
'htmlUrl' => 'https://git.mrblake.cc/MrBlake/netbox-demo', 'defaultBranch' => 'main',
|
||||
'description' => 'Remote description', 'homepageUrl' => '', 'topics' => [], 'archived' => false,
|
||||
'fork' => false, 'empty' => false, 'commitSha' => str_repeat('c', 40),
|
||||
];
|
||||
}
|
||||
|
||||
function candidatePyproject(): string
|
||||
{
|
||||
return <<<'TOML'
|
||||
[project]
|
||||
name = "netbox-demo"
|
||||
version = "1.2.3"
|
||||
description = "Remote summary"
|
||||
dependencies = ["netbox>=4.6.5,<=4.6.8"]
|
||||
|
||||
[project.entry-points."netbox.plugins"]
|
||||
demo = "netbox_demo"
|
||||
TOML;
|
||||
}
|
||||
|
||||
$storeRoot = dirname(__DIR__);
|
||||
$config = configureEnvironment($storeRoot);
|
||||
$guard = new SsrfGuard($config);
|
||||
$http = new HttpClient($config, $guard);
|
||||
|
||||
test('versions use a strict PEP 440 subset', static function (): void {
|
||||
assertSame('1.2.3rc1', Support::safeVersion('v1.2.3RC1'));
|
||||
assertSame('0.0.0+build.abcdef12', Support::safeVersion('0.0.0+build.abcdef12'));
|
||||
assertSame('', Support::safeVersion('1.0-foo'));
|
||||
assertSame('', Support::safeVersion('01.0'));
|
||||
assertSame('', Support::safeVersion('1.0+local-build'));
|
||||
assertSame('', Support::safeVersion('release-foo'));
|
||||
});
|
||||
|
||||
test('plugin validation matches strict client limits', static function (): void {
|
||||
$plugin = approvedFixture()['plugins'][0];
|
||||
assertSame([], Approval::pluginErrors($plugin));
|
||||
$plugin['slug'] = str_repeat('a', 65);
|
||||
assertTrue(Approval::pluginErrors($plugin) !== []);
|
||||
$plugin = approvedFixture()['plugins'][0];
|
||||
$plugin['packageName'] = 'bad-';
|
||||
assertTrue(Approval::pluginErrors($plugin) !== []);
|
||||
$plugin = approvedFixture()['plugins'][0];
|
||||
$plugin['importName'] = 'nested.module';
|
||||
assertTrue(Approval::pluginErrors($plugin) !== []);
|
||||
});
|
||||
|
||||
test('release approval is payload-bound and rejects invalid or duplicate versions', static function (): void {
|
||||
$state = approvedFixture();
|
||||
assertTrue(Approval::current($state['plugins'][0], $state['releases'][0]));
|
||||
$state['releases'][0]['artifactSize']++;
|
||||
assertTrue(!Approval::current($state['plugins'][0], $state['releases'][0]), 'changed size must invalidate payload');
|
||||
|
||||
$invalid = approvedFixture();
|
||||
$invalid['releases'][0]['status'] = 'pending';
|
||||
$invalid['releases'][0]['approvedPayloadSha256'] = '';
|
||||
$invalid['releases'][0]['version'] = 'release-foo';
|
||||
assertThrows(static function () use (&$invalid): void { Approval::approve($invalid, 'releases', 'release-1', 'admin'); }, 'Release-Version');
|
||||
|
||||
$sourceArchive = approvedFixture();
|
||||
$sourceArchive['releases'][0]['status'] = 'pending';
|
||||
$sourceArchive['releases'][0]['approvedPayloadSha256'] = '';
|
||||
$sourceArchive['releases'][0]['artifactKind'] = 'source';
|
||||
assertThrows(static function () use (&$sourceArchive): void { Approval::approve($sourceArchive, 'releases', 'release-1', 'admin'); }, 'Wheel');
|
||||
|
||||
$duplicate = approvedFixture();
|
||||
$second = $duplicate['releases'][0];
|
||||
$second['id'] = 'release-2';
|
||||
$second['externalId'] = '502';
|
||||
$second['status'] = 'pending';
|
||||
$second['approvedPayloadSha256'] = '';
|
||||
$duplicate['releases'][] = $second;
|
||||
assertThrows(static function () use (&$duplicate): void { Approval::approve($duplicate, 'releases', 'release-2', 'admin'); }, 'bereits');
|
||||
});
|
||||
|
||||
test('release approval matches Host-Agent commit, Wheel identity and catalog bounds', static function (): void {
|
||||
$invalidCommit = approvedFixture();
|
||||
$invalidCommit['releases'][0]['status'] = 'pending';
|
||||
$invalidCommit['releases'][0]['approvedPayloadSha256'] = '';
|
||||
$invalidCommit['releases'][0]['commitSha'] = str_repeat('c', 64);
|
||||
assertThrows(static function () use (&$invalidCommit): void {
|
||||
Approval::approve($invalidCommit, 'releases', 'release-1', 'admin');
|
||||
}, '40-stellig');
|
||||
|
||||
$unsafeFilename = approvedFixture();
|
||||
$unsafeFilename['releases'][0]['status'] = 'pending';
|
||||
$unsafeFilename['releases'][0]['approvedPayloadSha256'] = '';
|
||||
$unsafeFilename['releases'][0]['downloadUrl'] = 'https://git.mrblake.cc/assets/not-a-wheel.whl';
|
||||
assertThrows(static function () use (&$unsafeFilename): void {
|
||||
Approval::approve($unsafeFilename, 'releases', 'release-1', 'admin');
|
||||
}, 'Wheel-Dateinamen');
|
||||
|
||||
$wrongDistribution = approvedFixture();
|
||||
$wrongDistribution['releases'][0]['status'] = 'pending';
|
||||
$wrongDistribution['releases'][0]['approvedPayloadSha256'] = '';
|
||||
$wrongDistribution['releases'][0]['downloadUrl'] = 'https://git.mrblake.cc/assets/other_plugin-1.2.3-py3-none-any.whl';
|
||||
assertThrows(static function () use (&$wrongDistribution): void {
|
||||
Approval::approve($wrongDistribution, 'releases', 'release-1', 'admin');
|
||||
}, 'Distribution');
|
||||
|
||||
$wrongVersion = approvedFixture();
|
||||
$wrongVersion['releases'][0]['status'] = 'pending';
|
||||
$wrongVersion['releases'][0]['approvedPayloadSha256'] = '';
|
||||
$wrongVersion['releases'][0]['downloadUrl'] = 'https://git.mrblake.cc/assets/netbox_demo-2.0.0-py3-none-any.whl';
|
||||
assertThrows(static function () use (&$wrongVersion): void {
|
||||
Approval::approve($wrongVersion, 'releases', 'release-1', 'admin');
|
||||
}, 'Wheel-Version');
|
||||
|
||||
$bounded = approvedFixture();
|
||||
$base = $bounded['releases'][0];
|
||||
for ($number = 2; $number <= 1_000; $number++) {
|
||||
$release = $base;
|
||||
$release['id'] = 'release-' . $number;
|
||||
$release['externalId'] = 'external-' . $number;
|
||||
$release['version'] = '1.2.' . $number;
|
||||
$release['downloadUrl'] = 'https://git.mrblake.cc/assets/netbox_demo-1.2.' . $number . '-py3-none-any.whl';
|
||||
$release['approvedPayloadSha256'] = Approval::payloadHash($bounded['plugins'][0], $release);
|
||||
$bounded['releases'][] = $release;
|
||||
}
|
||||
$candidate = $base;
|
||||
$candidate['id'] = 'release-1001';
|
||||
$candidate['externalId'] = 'external-1001';
|
||||
$candidate['version'] = '2.0.0';
|
||||
$candidate['downloadUrl'] = 'https://git.mrblake.cc/assets/netbox_demo-2.0.0-py3-none-any.whl';
|
||||
$candidate['status'] = 'pending';
|
||||
$candidate['approvedPayloadSha256'] = '';
|
||||
$bounded['releases'][] = $candidate;
|
||||
assertThrows(static function () use (&$bounded): void {
|
||||
Approval::approve($bounded, 'releases', 'release-1001', 'admin');
|
||||
}, '1.000');
|
||||
|
||||
$last = array_key_last($bounded['releases']);
|
||||
$bounded['releases'][$last]['status'] = 'approved';
|
||||
$bounded['releases'][$last]['approvedPayloadSha256'] = Approval::payloadHash($bounded['plugins'][0], $bounded['releases'][$last]);
|
||||
assertSame(1_000, count(Catalog::releases($bounded, $bounded['plugins'][0])));
|
||||
});
|
||||
|
||||
test('release discovery considers Wheel assets only', static function (): void {
|
||||
$rank = new ReflectionMethod(ForgejoAdapter::class, 'assetRank');
|
||||
assertSame(99, $rank->invoke(null, ['name' => 'plugin.tar.gz']));
|
||||
assertSame(99, $rank->invoke(null, ['name' => 'plugin.whl.asc']));
|
||||
assertSame(0, $rank->invoke(null, ['name' => 'plugin-1.0-py3-none-any.whl']));
|
||||
assertSame(1, $rank->invoke(null, ['name' => 'plugin-1.0-cp312-linux_x86_64.whl']));
|
||||
});
|
||||
|
||||
test('Forgejo and GitHub account repository and release pages before accumulation', static function () use ($config, $guard): void {
|
||||
$cases = [
|
||||
[ForgejoAdapter::class, 'accountRepositoryPage', 'repository', [
|
||||
'baseUrl' => 'https://git.mrblake.cc', 'apiUrl' => 'https://git.mrblake.cc/api/v1',
|
||||
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '',
|
||||
]],
|
||||
[ForgejoAdapter::class, 'accountReleasePage', 'release', [
|
||||
'baseUrl' => 'https://git.mrblake.cc', 'apiUrl' => 'https://git.mrblake.cc/api/v1',
|
||||
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '',
|
||||
]],
|
||||
[GitHubAdapter::class, 'accountRepositoryPage', 'repository', [
|
||||
'baseUrl' => 'https://github.com', 'apiUrl' => 'https://api.github.com',
|
||||
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '',
|
||||
]],
|
||||
[GitHubAdapter::class, 'accountReleasePage', 'release', [
|
||||
'baseUrl' => 'https://github.com', 'apiUrl' => 'https://api.github.com',
|
||||
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '',
|
||||
]],
|
||||
];
|
||||
foreach ($cases as [$adapterClass, $methodName, $kind, $source]) {
|
||||
$caseHttp = new HttpClient($config, $guard);
|
||||
$budget = new SyncBudget(60, 100, 1_000_000, 1, 1);
|
||||
$caseHttp->beginBudget($budget);
|
||||
try {
|
||||
$adapter = new $adapterClass($source, $config, $caseHttp);
|
||||
$method = new ReflectionMethod($adapterClass, $methodName);
|
||||
assertThrows(static function () use ($method, $adapter): void {
|
||||
$method->invoke($adapter, [['id' => 1], ['id' => 2]]);
|
||||
}, $kind . ' limit');
|
||||
$usage = $budget->usage();
|
||||
assertSame(2, $usage[$kind === 'repository' ? 'repositoriesCounted' : 'releasesCounted']);
|
||||
} finally {
|
||||
$caseHttp->endBudget($budget);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('catalog and release-detail API keep the exact client contract', static function () use ($config, $guard, $http): void {
|
||||
$state = approvedFixture();
|
||||
$releaseKeys = [
|
||||
'version', 'download_url', 'sha256', 'artifact_size', 'commit_sha', 'min_netbox_version',
|
||||
'max_netbox_version', 'published_at', 'approved', 'status', 'immutable', 'approved_payload_sha256',
|
||||
];
|
||||
$pluginKeys = [
|
||||
'api_version', 'slug', 'name', 'summary', 'description', 'repository_url', 'latest_version',
|
||||
'package_name', 'import_name', 'min_netbox_version', 'max_netbox_version', 'approved', 'status', 'releases',
|
||||
];
|
||||
$serialized = Catalog::serializePlugin($state, $state['plugins'][0]);
|
||||
assertSame($pluginKeys, array_keys($serialized));
|
||||
assertSame($releaseKeys, array_keys($serialized['releases'][0]));
|
||||
assertSame('1.2.3', $serialized['latest_version']);
|
||||
|
||||
$repository = new MemoryRepository($state);
|
||||
$sync = new SyncService($repository, $config, $http, $guard);
|
||||
$app = new Application($config, $repository, new Auth($config, $repository), $sync, $guard);
|
||||
$response = $app->handle(new Request('GET', '/api/v1/plugins/demo-plugin/releases/1.2.3', [], [], [], '127.0.0.1'));
|
||||
assertSame(200, $response->status);
|
||||
assertSame('no-store', $response->headers['Cache-Control']);
|
||||
assertSame($releaseKeys, array_keys(json_decode($response->body, true, 512, JSON_THROW_ON_ERROR)));
|
||||
|
||||
$state['releases'] = [];
|
||||
assertSame(null, Catalog::serializePlugin($state, $state['plugins'][0])['latest_version']);
|
||||
});
|
||||
|
||||
test('catalog defensively filters stale, withdrawn and incomplete entries', static function (): void {
|
||||
$state = approvedFixture();
|
||||
$state['releases'][0]['withdrawn'] = true;
|
||||
assertSame([], Catalog::releases($state, $state['plugins'][0]));
|
||||
$state = approvedFixture();
|
||||
$state['plugins'][0]['maxNetboxVersion'] = '';
|
||||
assertSame([], Catalog::approvedPlugins($state));
|
||||
});
|
||||
|
||||
test('public and admin templates render safely with complete artifact evidence', static function () use ($config): void {
|
||||
$state = approvedFixture();
|
||||
$plugin = $state['plugins'][0];
|
||||
$plugin['source'] = $state['sources'][0];
|
||||
$plugin['latestRelease'] = $state['releases'][0];
|
||||
$release = $state['releases'][0];
|
||||
$release['plugin'] = $state['plugins'][0];
|
||||
$view = new View($config);
|
||||
$common = ['currentPath' => '/', 'adminEnabled' => false, 'adminUser' => ''];
|
||||
$home = $view->render('home', $common + [
|
||||
'title' => 'Store', 'plugins' => [$plugin], 'sources' => $state['sources'], 'query' => '',
|
||||
'selectedSource' => '', 'selectedNetboxVersion' => '', 'invalidVersion' => false,
|
||||
'count' => 1, 'totalCount' => 1, 'page' => 1, 'pages' => 1,
|
||||
]);
|
||||
assertTrue(str_contains($home, 'Demo Plugin'));
|
||||
$detail = $view->render('plugin', $common + [
|
||||
'title' => 'Demo', 'plugin' => $state['plugins'][0], 'source' => $state['sources'][0], 'releases' => $state['releases'],
|
||||
]);
|
||||
assertTrue(str_contains($detail, 'Wheel'));
|
||||
$admin = $view->render('admin/dashboard', [
|
||||
'title' => 'Admin', 'currentPath' => '/admin', 'adminEnabled' => true, 'adminUser' => 'admin',
|
||||
'csrf' => 'safe-token', 'ok' => '', 'error' => '', 'sources' => $state['sources'],
|
||||
'plugins' => [$plugin], 'releases' => [$release], 'runs' => [], 'audits' => [],
|
||||
]);
|
||||
assertTrue(str_contains($admin, $release['downloadUrl']));
|
||||
assertTrue(str_contains($admin, $release['sha256']));
|
||||
assertTrue(str_contains($admin, '4.6.5'));
|
||||
});
|
||||
|
||||
test('README rendering strips HTML and pins relative links to the commit', static function (): void {
|
||||
$repository = fakeRepository();
|
||||
$adapter = new FakeAdapter($repository);
|
||||
$html = (new ReadmeRenderer())->render(
|
||||
"# Demo\n\n<script>alert(1)</script>\n\n[Handbuch](../manual.md)  [Unsicher](javascript:alert(1))",
|
||||
$adapter,
|
||||
$repository,
|
||||
'docs/README.md',
|
||||
$repository['commitSha'],
|
||||
);
|
||||
assertTrue(!str_contains(strtolower($html), '<script'));
|
||||
assertTrue(!str_contains(strtolower($html), 'javascript:'));
|
||||
assertTrue(str_contains($html, '/raw/commit/' . $repository['commitSha'] . '/manual.md'));
|
||||
assertTrue(str_contains($html, '/raw/commit/' . $repository['commitSha'] . '/docs/images/logo.png'));
|
||||
assertTrue(str_contains($html, 'referrerpolicy="no-referrer"'));
|
||||
});
|
||||
|
||||
test('discovery resolves dynamic setuptools version and pins every read', static function (): void {
|
||||
$repository = fakeRepository();
|
||||
$adapter = new FakeAdapter($repository);
|
||||
$adapter->files = [
|
||||
'pyproject.toml' => <<<'TOML'
|
||||
[project]
|
||||
name = "netbox-slm"
|
||||
dynamic = ["version"]
|
||||
dependencies = ["netbox>=4.6.5,<=4.6.8"]
|
||||
[project.entry-points."netbox.plugins"]
|
||||
slm = "netbox_slm"
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "netbox_slm.__version__"}
|
||||
TOML,
|
||||
'netbox_slm/__init__.py' => "__version__ = '1.13.0'\n",
|
||||
'README.md' => '# SLM',
|
||||
];
|
||||
$result = (new Discovery())->discover($adapter, $repository, 'netbox-plugin');
|
||||
assertSame('1.13.0', $result['version']);
|
||||
assertSame('netbox_slm', $result['importName']);
|
||||
assertTrue($adapter->readRefs !== []);
|
||||
assertTrue(count(array_unique($adapter->readRefs)) === 1 && $adapter->readRefs[0] === $repository['commitSha']);
|
||||
});
|
||||
|
||||
test('manifest schema version is ignored for compatibility-list manifests', static function (): void {
|
||||
$repository = fakeRepository();
|
||||
$adapter = new FakeAdapter($repository);
|
||||
$adapter->files = [
|
||||
'netbox-plugin.json' => json_encode(['version' => '0.1', 'compatibility' => [['netbox' => '4.5']]], JSON_THROW_ON_ERROR),
|
||||
'setup.py' => "# netbox\nname = 'netbox-topology'\nversion = '4.5.1'\n",
|
||||
'README.md' => '# Topology',
|
||||
];
|
||||
$result = (new Discovery())->discover($adapter, $repository, 'netbox-plugin');
|
||||
assertSame('4.5.1', $result['version']);
|
||||
});
|
||||
|
||||
test('HTTP authorization stays on the exact API origin and private literals fail closed', static function () use ($http, $guard): void {
|
||||
$method = new ReflectionMethod(HttpClient::class, 'filterSensitiveHeaders');
|
||||
$headers = ['Accept: application/json', 'Authorization: token very-secret'];
|
||||
$same = $method->invoke($http, $headers, 'https://git.mrblake.cc/api/v1/repos', 'https://git.mrblake.cc');
|
||||
$redirected = $method->invoke($http, $headers, 'https://github.com/assets/file.whl', 'https://git.mrblake.cc');
|
||||
assertSame($headers, $same);
|
||||
assertTrue(!array_filter($redirected, static fn (string $header): bool => str_starts_with(strtolower($header), 'authorization:')));
|
||||
assertThrows(static function () use ($guard): void { $guard->assertConfiguredUrl('https://127.0.0.1/internal'); }, 'private');
|
||||
});
|
||||
|
||||
test('default-source bootstrap does not write on the second call', static function () use ($config, $guard, $http): void {
|
||||
$repository = new MemoryRepository(State::empty());
|
||||
$service = new SyncService($repository, $config, $http, $guard);
|
||||
assertTrue($service->ensureDefaultSource()['created']);
|
||||
assertSame(1, $repository->transactions);
|
||||
assertTrue(!$service->ensureDefaultSource()['created']);
|
||||
assertSame(1, $repository->transactions, 'second bootstrap should be read-only');
|
||||
});
|
||||
|
||||
test('JSON datastore transactions remain valid and atomic', static function () use ($storeRoot): void {
|
||||
$directory = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'netbox-store-test-' . bin2hex(random_bytes(5));
|
||||
$path = $directory . DIRECTORY_SEPARATOR . 'store.json';
|
||||
$repository = new JsonStoreRepository($path, 1_024);
|
||||
$repository->initialize();
|
||||
$repository->transaction(static function (array &$state): void {
|
||||
$state['auditLog'][] = ['id' => 'one'];
|
||||
});
|
||||
$decoded = json_decode((string) file_get_contents($path), true, 512, JSON_THROW_ON_ERROR);
|
||||
assertSame('one', $decoded['auditLog'][0]['id']);
|
||||
assertTrue(!glob($directory . DIRECTORY_SEPARATOR . '*.tmp-*'));
|
||||
$before = hash_file('sha256', $path);
|
||||
assertThrows(static function () use ($repository): void {
|
||||
$repository->transaction(static function (array &$state): void {
|
||||
$state['auditLog'][] = ['id' => 'oversized', 'details' => str_repeat('x', 2_000)];
|
||||
});
|
||||
}, 'safety limit');
|
||||
assertSame($before, hash_file('sha256', $path), 'oversized draft must leave old datastore intact');
|
||||
$otherRepository = new JsonStoreRepository($path, 1_024);
|
||||
$firstLease = $repository->acquireLease('sync-source:one');
|
||||
assertTrue($firstLease instanceof ExclusiveLease);
|
||||
assertSame(null, $otherRepository->acquireLease('sync-source:one'), 'second process lease must fail immediately');
|
||||
$firstLease->release();
|
||||
$recoveredLease = $otherRepository->acquireLease('sync-source:one');
|
||||
assertTrue($recoveredLease instanceof ExclusiveLease);
|
||||
$recoveredLease->release();
|
||||
@unlink($path);
|
||||
@unlink($path . '.lock');
|
||||
foreach (glob($path . '.lease-*.lock') ?: [] as $leasePath) {
|
||||
@unlink($leasePath);
|
||||
}
|
||||
@rmdir($directory);
|
||||
});
|
||||
|
||||
test('sync budgets enforce aggregate counters and deadline', static function (): void {
|
||||
$requests = new SyncBudget(60, 1, 1_000, 10, 10);
|
||||
$requests->consumeRequest();
|
||||
assertThrows(static function () use ($requests): void { $requests->consumeRequest(); }, 'request limit');
|
||||
|
||||
$bytes = new SyncBudget(60, 10, 10, 10, 10);
|
||||
assertTrue($bytes->tryConsumeBytes(6));
|
||||
assertTrue(!$bytes->tryConsumeBytes(5));
|
||||
assertThrows(static function () use ($bytes): void { $bytes->assertWithinLimits(); }, 'byte limit');
|
||||
|
||||
$repositories = new SyncBudget(60, 10, 1_000, 1, 10);
|
||||
assertThrows(static function () use ($repositories): void { $repositories->consumeRepositories(2); }, 'repository limit');
|
||||
assertSame(2, $repositories->usage()['repositoriesCounted']);
|
||||
|
||||
$releases = new SyncBudget(60, 10, 1_000, 10, 1);
|
||||
assertThrows(static function () use ($releases): void { $releases->consumeReleases(2); }, 'release limit');
|
||||
|
||||
$deadline = new SyncBudget(0, 10, 1_000, 10, 10);
|
||||
assertThrows(static function () use ($deadline): void { $deadline->checkpoint(); }, 'deadline');
|
||||
});
|
||||
|
||||
test('sync service holds an exclusive lease independent of stale run age', static function () use ($config, $guard, $http): void {
|
||||
$state = State::empty();
|
||||
$state['sources'][] = [
|
||||
'id' => 'source-sync', 'slug' => 'mrblake', 'name' => 'MrBlake', 'provider' => 'forgejo',
|
||||
'baseUrl' => 'https://git.mrblake.cc', 'apiUrl' => 'https://git.mrblake.cc/api/v1',
|
||||
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '', 'topic' => 'netbox-plugin',
|
||||
'status' => 'approved', 'active' => true, 'includeForks' => false, 'includeArchived' => false,
|
||||
'autoApprovePlugins' => false,
|
||||
];
|
||||
$state['syncRuns'][] = [
|
||||
'id' => 'old-run', 'sourceId' => 'source-sync', 'trigger' => 'command', 'status' => 'running',
|
||||
'startedAt' => gmdate('Y-m-d\TH:i:s\Z', time() - 7 * 3600), 'finishedAt' => null, 'errors' => [],
|
||||
];
|
||||
$repository = new MemoryRepository($state);
|
||||
$adapter = new FakeAdapter(fakeRepository() + ['empty' => true]);
|
||||
$service = new SyncService($repository, $config, $http, $guard, adapterFactory: static fn (array $source): SourceAdapter => $adapter);
|
||||
$held = $repository->acquireLease('sync-source:source-sync');
|
||||
assertTrue($held instanceof ExclusiveLease);
|
||||
assertThrows(static function () use ($service): void { $service->syncSource('source-sync'); }, 'already running');
|
||||
assertSame('running', $repository->read()['syncRuns'][0]['status'], 'age must never bypass a held lease');
|
||||
$held->release();
|
||||
assertSame('success', $service->syncSource('source-sync')['status']);
|
||||
assertSame('failed', $repository->read()['syncRuns'][0]['status'], 'orphaned run is recovered only after the lease is available');
|
||||
});
|
||||
|
||||
test('sync service enforces aggregate repository and release limits', static function () use ($storeRoot, $guard, $http): void {
|
||||
$source = [
|
||||
'id' => 'source-budget', 'slug' => 'budget', 'name' => 'Budget', 'provider' => 'forgejo',
|
||||
'baseUrl' => 'https://git.mrblake.cc', 'apiUrl' => 'https://git.mrblake.cc/api/v1',
|
||||
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '', 'topic' => 'netbox-plugin',
|
||||
'status' => 'approved', 'active' => true, 'includeForks' => false, 'includeArchived' => false,
|
||||
'autoApprovePlugins' => false,
|
||||
];
|
||||
try {
|
||||
putenv('STORE_SYNC_MAX_REPOSITORIES=1');
|
||||
putenv('STORE_SYNC_MAX_RELEASES=1');
|
||||
$limitedConfig = Config::load($storeRoot);
|
||||
|
||||
$repositoryState = State::empty();
|
||||
$repositoryState['sources'][] = $source;
|
||||
$repositoryStore = new MemoryRepository($repositoryState);
|
||||
$repositoryAdapter = new FakeAdapter(fakeRepository());
|
||||
$secondRepository = fakeRepository();
|
||||
$secondRepository['externalId'] = '102';
|
||||
$secondRepository['name'] = 'netbox-demo-two';
|
||||
$secondRepository['fullName'] = 'MrBlake/netbox-demo-two';
|
||||
$secondRepository['htmlUrl'] .= '-two';
|
||||
$repositoryAdapter->repositories = [fakeRepository(), $secondRepository];
|
||||
$service = new SyncService($repositoryStore, $limitedConfig, $http, $guard, adapterFactory: static fn (array $item): SourceAdapter => $repositoryAdapter);
|
||||
$run = $service->syncSource('source-budget');
|
||||
assertSame('failed', $run['status']);
|
||||
assertSame(2, $run['repositoriesCounted']);
|
||||
assertSame([], $repositoryStore->read()['plugins']);
|
||||
|
||||
$releaseState = State::empty();
|
||||
$releaseState['sources'][] = $source;
|
||||
$releaseStore = new MemoryRepository($releaseState);
|
||||
$releaseAdapter = new FakeAdapter(fakeRepository());
|
||||
$releaseAdapter->files = ['pyproject.toml' => candidatePyproject(), 'README.md' => '# Demo'];
|
||||
$releaseAdapter->releases = [
|
||||
['externalId' => 'one', 'version' => '1.0.0'],
|
||||
['externalId' => 'two', 'version' => '2.0.0'],
|
||||
];
|
||||
$service = new SyncService($releaseStore, $limitedConfig, $http, $guard, adapterFactory: static fn (array $item): SourceAdapter => $releaseAdapter);
|
||||
$run = $service->syncSource('source-budget');
|
||||
assertSame('failed', $run['status']);
|
||||
assertSame(2, $run['releasesCounted']);
|
||||
assertSame([], $releaseStore->read()['releases']);
|
||||
} finally {
|
||||
putenv('STORE_SYNC_MAX_REPOSITORIES=2000');
|
||||
putenv('STORE_SYNC_MAX_RELEASES=1000');
|
||||
}
|
||||
});
|
||||
|
||||
test('private GitHub repositories sync metadata but never release assets', static function () use ($config, $guard, $http): void {
|
||||
$state = State::empty();
|
||||
$state['sources'][] = [
|
||||
'id' => 'source-github', 'slug' => 'github', 'name' => 'GitHub', 'provider' => 'github',
|
||||
'baseUrl' => 'https://github.com', 'apiUrl' => 'https://api.github.com',
|
||||
'owner' => 'PrivateOrg', 'ownerKind' => 'organization', 'tokenEnv' => 'GITHUB_TOKEN', 'topic' => 'netbox-plugin',
|
||||
'status' => 'approved', 'active' => true, 'includeForks' => false, 'includeArchived' => false,
|
||||
'autoApprovePlugins' => false,
|
||||
];
|
||||
$repository = new MemoryRepository($state);
|
||||
$upstream = fakeRepository();
|
||||
$upstream['private'] = true;
|
||||
$upstream['htmlUrl'] = 'https://github.com/PrivateOrg/netbox-demo';
|
||||
$upstream['owner'] = 'PrivateOrg';
|
||||
$upstream['fullName'] = 'PrivateOrg/netbox-demo';
|
||||
$adapter = new FakeAdapter($upstream);
|
||||
$adapter->files = ['pyproject.toml' => candidatePyproject(), 'README.md' => '# Private demo'];
|
||||
$adapter->releases = [['externalId' => 'must-not-be-read', 'version' => '1.2.3']];
|
||||
$service = new SyncService($repository, $config, $http, $guard, adapterFactory: static fn (array $source): SourceAdapter => $adapter);
|
||||
$run = $service->syncSource('source-github');
|
||||
assertSame('partial', $run['status']);
|
||||
assertSame(0, $adapter->releaseListCalls);
|
||||
assertSame(1, count($repository->read()['plugins']));
|
||||
assertSame([], $repository->read()['releases']);
|
||||
assertTrue(str_contains(json_encode($run['errors'], JSON_THROW_ON_ERROR), 'Private GitHub'));
|
||||
$plugin = $repository->read()['plugins'][0];
|
||||
$plugin['source'] = $repository->read()['sources'][0];
|
||||
$dashboard = (new View($config))->render('admin/dashboard', [
|
||||
'title' => 'Admin', 'currentPath' => '/admin', 'adminEnabled' => true, 'adminUser' => 'admin',
|
||||
'csrf' => 'token', 'ok' => '', 'error' => '', 'sources' => $repository->read()['sources'],
|
||||
'plugins' => [$plugin], 'releases' => [], 'runs' => [$run], 'audits' => [],
|
||||
]);
|
||||
assertTrue(str_contains($dashboard, 'Private GitHub'));
|
||||
});
|
||||
|
||||
test('forwarded client IP is accepted only from an exact trusted proxy', static function () use ($storeRoot): void {
|
||||
$server = $_SERVER;
|
||||
$get = $_GET;
|
||||
$post = $_POST;
|
||||
try {
|
||||
putenv('STORE_TRUST_PROXY=true');
|
||||
putenv('STORE_TRUSTED_PROXY_IPS=127.0.0.1');
|
||||
$_SERVER = ['REQUEST_URI' => '/', 'REQUEST_METHOD' => 'GET', 'REMOTE_ADDR' => '203.0.113.10', 'HTTP_X_FORWARDED_FOR' => '198.51.100.20'];
|
||||
$_GET = $_POST = [];
|
||||
$untrusted = Request::fromGlobals(Config::load($storeRoot));
|
||||
assertSame('203.0.113.10', $untrusted->ip);
|
||||
|
||||
putenv('STORE_TRUSTED_PROXY_IPS=203.0.113.10');
|
||||
$trusted = Request::fromGlobals(Config::load($storeRoot));
|
||||
assertSame('198.51.100.20', $trusted->ip);
|
||||
} finally {
|
||||
$_SERVER = $server;
|
||||
$_GET = $get;
|
||||
$_POST = $post;
|
||||
putenv('STORE_TRUST_PROXY=false');
|
||||
putenv('STORE_TRUSTED_PROXY_IPS=127.0.0.1,::1');
|
||||
}
|
||||
});
|
||||
|
||||
test('sync preserves overrides, rehashes replacements, withdraws removals and archives non-candidates', static function () use ($config, $guard, $http): void {
|
||||
$state = State::empty();
|
||||
$state['sources'][] = [
|
||||
'id' => 'source-sync', 'slug' => 'mrblake', 'name' => 'MrBlake', 'provider' => 'forgejo',
|
||||
'baseUrl' => 'https://git.mrblake.cc', 'apiUrl' => 'https://git.mrblake.cc/api/v1',
|
||||
'owner' => 'MrBlake', 'ownerKind' => 'user', 'tokenEnv' => '', 'topic' => 'netbox-plugin',
|
||||
'status' => 'approved', 'active' => true, 'includeForks' => false, 'includeArchived' => false,
|
||||
'autoApprovePlugins' => false,
|
||||
];
|
||||
$repository = new MemoryRepository($state);
|
||||
$adapter = new FakeAdapter(fakeRepository());
|
||||
$adapter->files = ['pyproject.toml' => candidatePyproject(), 'README.md' => '# Demo'];
|
||||
$adapter->artifactSha = str_repeat('1', 64);
|
||||
$adapter->releases = [[
|
||||
'externalId' => 'release-upstream', 'version' => '1.2.3', 'title' => '1.2.3',
|
||||
'releaseUrl' => 'https://git.mrblake.cc/MrBlake/netbox-demo/releases/1',
|
||||
'downloadUrl' => 'https://git.mrblake.cc/assets/netbox_demo-1.2.3-py3-none-any.whl',
|
||||
'expectedSha256' => '', 'commitSha' => str_repeat('c', 40),
|
||||
'prerelease' => false, 'draft' => false, 'changelog' => '', 'publishedAt' => '2026-08-20T10:00:00Z',
|
||||
]];
|
||||
$service = new SyncService($repository, $config, $http, $guard, adapterFactory: static fn (array $source): SourceAdapter => $adapter);
|
||||
assertSame('success', $service->syncSource('source-sync')['status']);
|
||||
assertSame(1, $adapter->hashCalls);
|
||||
|
||||
$repository->transaction(static function (array &$draft): void {
|
||||
$plugin = &$draft['plugins'][0];
|
||||
$plugin['name'] = 'Admin Name';
|
||||
$plugin['summary'] = 'Admin Summary';
|
||||
$plugin['description'] = 'Admin Description';
|
||||
$plugin['packageName'] = 'admin-package';
|
||||
$plugin['importName'] = 'admin_plugin';
|
||||
$plugin['minNetboxVersion'] = '4.6.5';
|
||||
$plugin['maxNetboxVersion'] = '4.6.8';
|
||||
$plugin['metadataOverrides'] = array_intersect_key($plugin, array_flip(['name', 'summary', 'description', 'packageName', 'importName', 'minNetboxVersion', 'maxNetboxVersion']));
|
||||
$draft['releases'][0]['downloadUrl'] = 'https://git.mrblake.cc/assets/admin_package-1.2.3-py3-none-any.whl';
|
||||
Approval::approve($draft, 'plugins', $plugin['id'], 'admin');
|
||||
Approval::approve($draft, 'releases', $draft['releases'][0]['id'], 'admin');
|
||||
});
|
||||
$adapter->artifactSha = str_repeat('2', 64);
|
||||
assertSame('success', $service->syncSource('source-sync')['status']);
|
||||
$afterReplacement = $repository->read();
|
||||
assertSame('Admin Name', $afterReplacement['plugins'][0]['name']);
|
||||
assertSame('admin-package', $afterReplacement['plugins'][0]['packageName']);
|
||||
assertSame(str_repeat('2', 64), $afterReplacement['releases'][0]['sha256']);
|
||||
assertSame('pending', $afterReplacement['releases'][0]['status']);
|
||||
assertSame(2, $adapter->hashCalls, 'same URL must be fetched and hashed again');
|
||||
|
||||
$repository->transaction(static function (array &$draft): void {
|
||||
$draft['plugins'][0]['packageName'] = 'netbox-demo';
|
||||
$draft['plugins'][0]['importName'] = 'netbox_demo';
|
||||
$draft['plugins'][0]['metadataOverrides'] = [];
|
||||
Approval::approve($draft, 'releases', $draft['releases'][0]['id'], 'admin');
|
||||
});
|
||||
$adapter->files['pyproject.toml'] = str_replace(
|
||||
['name = "netbox-demo"', 'demo = "netbox_demo"'],
|
||||
['name = "netbox-demo-next"', 'demo = "netbox_next"'],
|
||||
candidatePyproject(),
|
||||
);
|
||||
$service->syncSource('source-sync');
|
||||
$changedMetadata = $repository->read();
|
||||
assertSame('pending', $changedMetadata['plugins'][0]['status'], 'upstream install metadata must reset plugin approval');
|
||||
assertSame('pending', $changedMetadata['releases'][0]['status']);
|
||||
|
||||
$repository->transaction(static function (array &$draft): void {
|
||||
$draft['releases'][0]['downloadUrl'] = 'https://git.mrblake.cc/assets/netbox_demo_next-1.2.3-py3-none-any.whl';
|
||||
Approval::approve($draft, 'plugins', $draft['plugins'][0]['id'], 'admin');
|
||||
Approval::approve($draft, 'releases', $draft['releases'][0]['id'], 'admin');
|
||||
});
|
||||
$adapter->releases = [];
|
||||
$service->syncSource('source-sync');
|
||||
$withdrawn = $repository->read()['releases'][0];
|
||||
assertTrue($withdrawn['withdrawn']);
|
||||
assertSame('pending', $withdrawn['status']);
|
||||
|
||||
$adapter->files = [];
|
||||
$service->syncSource('source-sync');
|
||||
$final = $repository->read();
|
||||
assertTrue($final['plugins'][0]['archived']);
|
||||
assertSame('pending', $final['plugins'][0]['status']);
|
||||
assertSame([], Catalog::approvedPlugins($final));
|
||||
});
|
||||
|
||||
$failures = 0;
|
||||
$started = microtime(true);
|
||||
foreach ($tests as $name => $testCase) {
|
||||
try {
|
||||
$testCase();
|
||||
fwrite(STDOUT, "PASS {$name}\n");
|
||||
} catch (Throwable $exception) {
|
||||
$failures++;
|
||||
fwrite(STDERR, "FAIL {$name}\n {$exception->getMessage()}\n");
|
||||
}
|
||||
}
|
||||
$duration = number_format(microtime(true) - $started, 2);
|
||||
fwrite($failures === 0 ? STDOUT : STDERR, sprintf("\n%d test(s), %d failure(s), %ss\n", count($tests), $failures, $duration));
|
||||
exit($failures === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user