feat: add NetBox plugin store
CI / php-store (push) Waiting to run
CI / python-components (push) Waiting to run

This commit is contained in:
2026-08-24 20:51:25 +02:00
commit f36d6be511
135 changed files with 15160 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
.git
.env
.env.*
!.env.example
data/*
!data/.gitkeep
vendor/
tests/
test/
.phpunit.cache/
coverage/
*.log
+59
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
/vendor/
/.env
/data/*.json
/data/*.lock
/data/*.tmp-*
!/data/.gitkeep
composer.phar
+27
View File
@@ -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
View File
@@ -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.
+91
View File
@@ -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);
}
+32
View File
@@ -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
}
}
+1023
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -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":{}}');
+1
View File
@@ -0,0 +1 @@
+16
View File
@@ -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
+19
View File
@@ -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
+6
View File
@@ -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
+15
View File
@@ -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 "$@"
+16
View File
@@ -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>
+274
View File
@@ -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; }
}
+10
View File
@@ -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

+40
View File
@@ -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();
+197
View File
@@ -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;
}
}
}
+30
View File
@@ -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();
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace NetBoxStore\Database;
interface ExclusiveLease
{
public function release(): void;
}
+165
View File
@@ -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;
}
}
}
+22
View File
@@ -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']);
}
}
+47
View File
@@ -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);
}
}
}
+28
View File
@@ -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;
}
+262
View File
@@ -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;
}
}
+138
View File
@@ -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'], '<='));
}
}
+421
View File
@@ -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;
}
}
+52
View File
@@ -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,
);
}
}
+45
View File
@@ -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;
}
}
+43
View File
@@ -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();
}
}
+143
View File
@@ -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,
]);
}
}
+270
View File
@@ -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;
}
}
+101
View File
@@ -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;
}
}
+75
View File
@@ -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);
}
}
}
}
+155
View File
@@ -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;
}
}
+22
View File
@@ -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.'),
};
}
}
+178
View File
@@ -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;
}
}
}
+181
View File
@@ -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;
}
}
}
+29
View File
@@ -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;
}
+302
View File
@@ -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 : '';
}
}
+105
View File
@@ -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;
}
}
+114
View File
@@ -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,
];
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace NetBoxStore\Sync;
use RuntimeException;
final class SyncBudgetExceeded extends RuntimeException
{
}
+494
View File
@@ -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.');
}
}
});
}
}
+186
View File
@@ -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'; ?>
+16
View File
@@ -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'; ?>
+8
View File
@@ -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'; ?>
+98
View File
@@ -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'; ?>
+16
View File
@@ -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>
+31
View File
@@ -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">
+70
View File
@@ -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'; ?>
+852
View File
@@ -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) ![Logo](images/logo.png) [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);