diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a5a9ff2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.github +.claude +legacy +node_modules +vendor +storage/logs/* +storage/framework/cache/* +storage/framework/sessions/* +storage/framework/views/* +database/*.sqlite* +.env +.env.* +!.env.example +tests +*.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..a186cd2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[compose.yaml] +indent_size = 4 diff --git a/.env.docker b/.env.docker new file mode 100644 index 0000000..49c4e1e --- /dev/null +++ b/.env.docker @@ -0,0 +1,12 @@ +# Values shared by the app and queue containers in docker-compose.yml. +# Service-specific values (DB, mail, S3) are set in the compose file itself. +APP_NAME=Registry +APP_ENV=local +APP_KEY=base64:mOXOfgDmHCBgK9Dh04IkWop7kCUkE8yo+79PR3JVvxE= +APP_DEBUG=true +APP_TIMEZONE=UTC +LOG_CHANNEL=stderr +LOG_LEVEL=debug +BCRYPT_ROUNDS=12 +REGISTRY_VERSION=2.0.0-dev +REGISTRY_CATALOG_CACHE_SECONDS=60 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..53f3bc1 --- /dev/null +++ b/.env.example @@ -0,0 +1,91 @@ +APP_NAME=Registry +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost:8000 +APP_TIMEZONE=UTC + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +# sqlite for a zero-dependency local run; docker-compose overrides this with mysql. +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=registry +# DB_USERNAME=registry +# DB_PASSWORD=registry + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +QUEUE_CONNECTION=database +CACHE_STORE=database +# CACHE_PREFIX= + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +# Verification codes go out by mail. Mailpit (docker compose) listens on 1025 / UI on 8025. +MAIL_MAILER=log +MAIL_HOST=127.0.0.1 +MAIL_PORT=1025 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="registry@fleetbase.local" +MAIL_FROM_NAME="${APP_NAME}" + +# Package store. Local disk by default; S3 / MinIO in docker and production. +FILESYSTEM_DISK=local +REGISTRY_STORAGE_DISK=local +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET=fleetbase-registry +AWS_ENDPOINT= +AWS_URL= +AWS_USE_PATH_STYLE_ENDPOINT=false + +# registry:import reads cloud bundle and icon objects from this disk (an s3 disk by default). +REGISTRY_IMPORT_SOURCE_DISK=import_source +IMPORT_SOURCE_AWS_BUCKET= +# IMPORT_SOURCE_AWS_ACCESS_KEY_ID= +# IMPORT_SOURCE_AWS_SECRET_ACCESS_KEY= +# IMPORT_SOURCE_AWS_DEFAULT_REGION= + +# Registry +REGISTRY_VERSION=2.0.0-dev +REGISTRY_CATALOG_CACHE_SECONDS=60 +# Base URL written into npm/Composer metadata; empty = the request's scheme and host. +REGISTRY_PUBLIC_URL= +REGISTRY_PACKAGE_METADATA_CACHE_SECONDS=60 +HASH_VERIFY=true + +# Release pipeline limits and the secrets scanner (auto | builtin | off). +# REGISTRY_MAX_BUNDLE_BYTES=52428800 +# REGISTRY_MAX_DIST_BYTES=104857600 +# REGISTRY_MAX_UNPACKED_BYTES=314572800 +# REGISTRY_MAX_FILE_BYTES=26214400 +# REGISTRY_MAX_ARCHIVE_ENTRIES=20000 +REGISTRY_SECRETS_SCANNER=auto +# REGISTRY_GITLEAKS_BINARY=gitleaks +# REGISTRY_CHECKS_QUEUE= + +# Sentry (optional) +SENTRY_LARAVEL_DSN= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 76f252e..7ae0726 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -52,6 +52,7 @@ jobs: VERSION: ${{ env.VERSION }} CACHE: type=gha with: + workdir: ./legacy push: true files: | ./docker-bake.hcl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ee5e5ba --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,130 @@ +name: Registry Service CI + +on: + push: + branches: ["main", "v2", "dev-*"] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + tests: + name: Pest (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ["8.3", "8.4"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, intl, bcmath, gd, zip, pdo_sqlite, sqlite3 + coverage: none + tools: composer:v2 + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ~/.composer/cache + key: composer-${{ runner.os }}-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ runner.os }}-${{ matrix.php }}- + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Prepare environment + run: | + cp .env.example .env + php artisan key:generate --ansi + + - name: Lint (Pint) + run: vendor/bin/pint --test + + - name: Run tests + run: vendor/bin/pest --ci --colors=always + + package-conformance: + name: npm and Composer conformance + runs-on: ubuntu-latest + needs: tests + # .env.example already runs on SQLite (database/database.sqlite) with the local disk; + # `artisan serve` reads .env rather than the job environment. + env: + PHP_CLI_SERVER_WORKERS: 4 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: mbstring, intl, bcmath, zip, pdo_sqlite, sqlite3, phar, zlib + coverage: none + tools: composer:v2 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Cache Composer packages + uses: actions/cache@v4 + with: + path: ~/.composer/cache + key: composer-${{ runner.os }}-8.3-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ runner.os }}-8.3- + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Prepare the service + run: | + cp .env.example .env + php artisan key:generate --ansi + touch database/database.sqlite + php artisan migrate --force + + - name: Start the service + run: | + php artisan serve --host=127.0.0.1 --port=8000 > storage/logs/serve.log 2>&1 & + for i in $(seq 1 30); do curl -sf http://127.0.0.1:8000/up > /dev/null && exit 0; sleep 1; done + cat storage/logs/serve.log; exit 1 + + - name: Run npm and Composer against seeded releases + run: tests/Conformance/packages.sh http://127.0.0.1:8000 + + - name: Service logs + if: failure() + run: | + cat storage/logs/serve.log || true + cat storage/logs/laravel.log || true + + docker: + name: Docker image builds + runs-on: ubuntu-latest + needs: tests + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build app target + uses: docker/build-push-action@v6 + with: + context: . + target: app + push: false + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 1b1fdc1..8c57825 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,25 @@ -.npmrc -.pnpm-store -docker-compose.override.yml -npm-debug.log -yarn-error.log +*.log +.DS_Store .env -*/.npmrc -storage/* -config/htpasswd -plugins/*/node_modules -minio/ \ No newline at end of file +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.fleet +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +Homestead.json +Homestead.yaml +Thumbs.db +/database/*.sqlite* diff --git a/.gitmodules b/.gitmodules index 4395015..850185d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,12 +1,12 @@ [submodule "plugins/verdaccio-composer-middleware"] - path = plugins/verdaccio-composer-middleware + path = legacy/plugins/verdaccio-composer-middleware url = git@github.com:fleetbase/verdaccio-composer-middleware.git [submodule "plugins/verdaccio-fleetbase-extensions-middleware"] - path = plugins/verdaccio-fleetbase-extensions-middleware + path = legacy/plugins/verdaccio-fleetbase-extensions-middleware url = git@github.com:fleetbase/verdaccio-fleetbase-extensions-middleware.git [submodule "plugins/verdaccio-fleetbase-s3-storage"] - path = plugins/verdaccio-fleetbase-s3-storage + path = legacy/plugins/verdaccio-fleetbase-s3-storage url = git@github.com:fleetbase/verdaccio-fleetbase-s3-storage.git [submodule "plugins/verdaccio-fleetbase-auth"] - path = plugins/verdaccio-fleetbase-auth + path = legacy/plugins/verdaccio-fleetbase-auth url = git@github.com:fleetbase/verdaccio-fleetbase-auth.git diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6e82b10 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1.7 +# +# Fleetbase Registry Service +# app : local development / generic container (nginx + php-fpm) +# aws-app : same image wrapped with ssm-parent so env comes from SSM on ECS +# +FROM ghcr.io/springload/ssm-parent:1.8.3 AS ssm-parent + +# ---- Composer dependencies ------------------------------------------------- +FROM composer:2 AS vendor +WORKDIR /app +COPY composer.json composer.lock ./ +RUN --mount=type=cache,target=/tmp/cache \ + composer install --no-dev --no-interaction --no-scripts --prefer-dist --no-progress --ignore-platform-reqs + +# ---- Runtime --------------------------------------------------------------- +FROM serversideup/php:8.3-fpm-nginx-alpine AS base + +# Release uploads: two 50 MB archives plus a 100 MB prebuilt dist (config/registry.php). +ENV PHP_OPCACHE_ENABLE=1 \ + PHP_UPLOAD_MAX_FILE_SIZE=110M \ + PHP_POST_MAX_SIZE=220M \ + AUTORUN_ENABLED=false \ + AUTORUN_LARAVEL_MIGRATION=false \ + NGINX_WEBROOT=/var/www/html/public \ + SSL_MODE=off + +USER root +RUN install-php-extensions bcmath intl gd zip pdo_mysql redis pcntl exif \ + && apk add --no-cache mysql-client + +WORKDIR /var/www/html +COPY --chown=www-data:www-data . . +COPY --from=vendor --chown=www-data:www-data /app/vendor ./vendor + +RUN composer dump-autoload --optimize --classmap-authoritative --no-dev 2>/dev/null || true \ + && mkdir -p storage/framework/{cache,sessions,views} storage/logs bootstrap/cache \ + && chown -R www-data:www-data storage bootstrap/cache + +USER www-data +EXPOSE 8080 + +# ---- Local / generic ------------------------------------------------------- +FROM base AS app +ENV AUTORUN_ENABLED=true \ + AUTORUN_LARAVEL_MIGRATION=true + +# ---- AWS ECS --------------------------------------------------------------- +FROM base AS aws-app +USER root +COPY --from=ssm-parent /usr/bin/ssm-parent /usr/bin/ssm-parent +USER www-data +ENV AUTORUN_ENABLED=true \ + AUTORUN_LARAVEL_MIGRATION=true +ENTRYPOINT ["/usr/bin/ssm-parent", "run", "-e", "-n", "$SSM_PARAMETER", "--", "docker-php-serversideup-entrypoint"] +CMD ["s6-svscan", "/etc/s6-overlay/s6-rc.d"] diff --git a/README.md b/README.md index b718991..ca3c537 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,403 @@ # Fleetbase Extensions Registry -## Introduction +The Registry Service is the single system of record for Fleetbase extensions: accounts and +publishers, the public catalog, releases and their review, package delivery for npm and +Composer, licences and purchases. Every Fleetbase instance (cloud or self-hosted), the `flb` +CLI and the publisher website are clients of it. -Welcome to the official source code repository for the Fleetbase Extensions Registry. This platform serves as a comprehensive package registry, catering to both Composer and npm packages, specifically tailored for Fleetbase extensions. Our unique focus on "Fleetbase Extensions" ensures a seamless integration and enhanced functionality for Fleetbase users. +This branch (`v2`) holds the rewrite described in the implementation plan. The previous +Verdaccio-based registry is kept under [`legacy/`](legacy/LEGACY.md) and still deploys from +there until the publishing cutover. -## Features +## Status -- **Dual Package Support**: Compatible with Composer and npm packages, offering a versatile solution for different development needs. -- **Fleetbase Extensions Specialization**: Dedicated support for Fleetbase-specific extensions, enhancing the Fleetbase ecosystem. -- **Extension Validation**: Each Fleetbase extension is required to include an `extension.json` file, ensuring standardization and quality control. +Phase 1, Workstreams A to C, and Phase 2 Workstream E: -## Getting Started +| Area | State | +| --- | --- | +| Data model (18 tables + verification codes), category seeder | done | +| Account auth: register, email verification code, login, logout, personal tokens | done | +| Public catalog API (`/v1/catalog/*`) with 60 s cache | done | +| Docker image, compose stack (app, queue, MySQL, MinIO, Mailpit), CI | done | +| Package store (`ReleaseStorage`), npm packument and tarball endpoints, Composer v2 repository, licence gate, `@fleetbase` namespace protection | done | +| Instance API: registration codes, register, me, updates, installs, licence seats | done | +| `registry:import` from the cloud registry-bridge tables (dry run, anomaly report, tarball copy) | done | +| Releases pipeline: publisher API, upload, queued checks, state machine, D6 review policy, staff review API, publishing | done | +| Publisher website (Workstream F), CLI v2 (G), Stripe (Phase 4) | later workstreams | -### Prerequisites +## Stack -- Docker and Docker Compose -- Basic understanding of Composer and npm package management +Laravel 12 on PHP 8.3, MySQL 8, S3-compatible object storage. The Fleetbase core-api +conventions the plan relies on (`Model` with UUID keys and public ids, `HasUuid`, +`HasPublicId`, the `Money` cast, `Http\Filter\Filter`, `FleetbaseResource`) are ported +into `app/` with the same names and behaviour, so code written against them reads like +any other Fleetbase API package. -### Installation +## Local development -1. **Clone the Repository**: +Zero-dependency (SQLite, mail to log): - ``` - git clone git@github.com:fleetbase/registry.git - ``` - -2. **Set Up the Environment**: +```bash +composer install +cp .env.example .env && php artisan key:generate +php artisan migrate --seed +php artisan serve +``` - ``` - sh install-plugins.sh - ``` +Full stack with Docker: -3. **Build and Run with Docker Compose**: +```bash +docker compose up --build +``` - ``` - docker-compose up --build - ``` +| Service | URL | +| --- | --- | +| API | http://localhost:8000 | +| MinIO console | http://localhost:9001 (minio / miniosecret) | +| Mailpit (verification emails) | http://localhost:8025 | +| MySQL | localhost:3307 (registry / registry) | -### Configuration +Release checks run on the queue. `docker compose` starts a worker; with `php artisan serve` +run `php artisan queue:work` beside it (or set `QUEUE_CONNECTION=sync`). -Coming soon. +Run the tests: -## Usage +```bash +composer test +``` -### Publishing a Package +Seed a published free and a paid extension from the fixture tarballs in +`database/fixtures/packages`, with install tokens for a licensed and an unlicensed +organization, then drive real npm and Composer clients against them: ```bash -npm i -g flb-cli -flb-cli publish [my-extension] +php artisan registry:seed --seed-release # add --json for scripts +php artisan serve & +tests/Conformance/packages.sh http://127.0.0.1:8000 ``` -### Installing an Extension +CI runs the same script in the `package-conformance` job. + +## API overview + +All routes live under `/v1`. Errors use the Fleetbase envelope `{"errors": ["..."]}`; +validation failures add a `fields` map. Resources are returned unwrapped; paged lists return +`{"data": [...], "meta": {total, per_page, current_page, last_page, from, to, time}}`. + +### Auth and account + +| Method and path | Purpose | +| --- | --- | +| `POST /v1/auth/register` | name, email, password, optional username. Creates the account (status `pending_verification`) and a personal organization, emails a 6-digit code | +| `POST /v1/auth/verify` | email + code. Activates the account | +| `POST /v1/auth/verify/resend` | email. Sends a new code for unverified accounts | +| `POST /v1/auth/login` | email + password (+ token_name, scopes, expires_in_days). Returns a personal token once | +| `POST /v1/auth/logout` | Revokes the token used for the request | +| `GET /v1/account` | The caller's account with organizations and roles | +| `GET /v1/account/tokens` | The caller's tokens, masked (`flbp_…a1b2`) | +| `POST /v1/account/tokens` | name, scopes (`read`, `publish`, `install`, `instance:register`, `review`, `*`), expires_in_days (max 365), optional organization. Plaintext returned once | +| `DELETE /v1/account/tokens/{id}` | Revoke | +| `POST /v1/account/instance-codes` | Optional organization (slug or id). Mints a single-use instance registration code (`XXXX-XXXX-XXXX-XXXX`) valid 15 minutes. Needs the `instance:register` scope and the owner or admin role; defaults to the token's organization, else the personal one | + +Tokens are `flbp_` + 40 random characters, stored as SHA-256, expiring, scoped and revocable. +Send them as `Authorization: Bearer flbp_…`. Passwords are bcrypt hashes checked with +`Hash::check`, the same as core-api's `Auth::checkPassword`, so cloud users import without +a reset. Keep `BCRYPT_ROUNDS` at or above the cost of imported hashes (cloud uses 10; the +default here is 12) because Laravel's `hashed` cast refuses hashes above the configured cost. + +### Public catalog (no auth, cached 60 s) + +| Method and path | Returns | +| --- | --- | +| `GET /v1/catalog/extensions?q=&category=&pricing=&hosting=&sort=&page=&limit=` | Paged listings with publisher, category, latest release, install count, compat, price, package names | +| `GET /v1/catalog/extensions/{slug}` | Full listing, screenshots, publisher, versions summary | +| `GET /v1/catalog/extensions/{slug}/releases?channel=` | Published (and yanked) releases: versions, channels, dates, sizes, notes, compat | +| `GET /v1/catalog/publishers/{slug}` | Publisher profile and their published extensions | +| `GET /v1/catalog/categories` | Categories with published extension counts | + +Filters: `pricing=free|paid`, `hosting=cloud|self_hosted|both` (listings marked `both` +match either side), `category=[,…]`, `publisher=`, `tag=`. +Sorts: `popular` (default, by installs), `newest`, `updated`, `name`, `price`. +Slugs and public ids (`ext_…`, `org_…`) are both accepted where a slug is expected. + +### Packages (npm and Composer) -Update composer.json +Package routes sit outside `/v1` because the clients treat them as registry roots. Errors +carry npm's `error` string beside the usual `errors` array. + +| Method and path | Returns | +| --- | --- | +| `GET /npm/{name}` | Packument from published, non-yanked releases: `versions` (stored `package.json` + `dist.tarball`, `dist.shasum`, `dist.integrity`), `dist-tags` (`latest` = highest stable, `beta` = highest beta; `latest` falls back to the highest beta when nothing is stable), `time`. Scoped names work encoded (`@scope%2fname`) or not. ETag, 304 | +| `GET /npm/{name}/-/{basename}-{version}.tgz` | The engine tarball. Yanked versions stay downloadable for lockfiles | +| `PUT`/`POST`/`DELETE`/`PATCH /npm/…` | 405: publish with `flb publish` | +| `GET /npm/-/ping`, `GET /npm/-/whoami` | `npm ping`, `npm whoami` | +| `GET /composer/packages.json` | `metadata-url`, `available-packages` (every package with a published release) | +| `GET /composer/p2/{vendor}/{package}.json` | Every published release, newest first, `composer/2.0` minified, `dist: {type: tar, url, shasum, reference}`. `~dev.json` is always empty. ETag, 304 | +| `GET /composer/dist/{vendor}/{package}/{version}.tar` | The API archive | + +Client setup: + +```ini +# .npmrc +@acme:registry=https://registry.fleetbase.io/npm/ +//registry.fleetbase.io/npm/:_authToken=flbp_… +``` ```json { - "repositories": [ - { - "type": "composer", - "url": "https://registry.fleetbase.io" - } - ] + "repositories": [{"type": "composer", "url": "https://registry.fleetbase.io/composer"}] } ``` -## Contributing +with `COMPOSER_AUTH='{"bearer": {"registry.fleetbase.io": "flbp_…"}}'` for paid packages. + +**Licence gate.** Free packages are open. For a paid package the packument, tarball, p2 +and dist routes need `Authorization: Bearer` with a personal token carrying the `install` +scope or an instance token (`flbi_…`). The token's organizations are its pinned +organization, or every organization of the account when it is not pinned; access is +granted when one of them owns the extension or holds an active, unexpired licence. 401 +without a usable token, 403 with one. Paid responses are `Cache-Control: private`. + +**Package store.** `App\Services\ReleaseStorage` writes objects to the +`REGISTRY_STORAGE_DISK` disk under `tarballs/`, `dist/`, `icons/` and `screenshots/`, +one `files` row each with `checksum` (sha256), `shasum` (sha1) and `integrity` (SRI +sha512). Package tarballs keep their decoded `package.json` or `composer.json` in +`files.meta`. Set `REGISTRY_PUBLIC_URL` when the service runs behind a proxy so the +URLs written into metadata use the public host. + +**Reserved namespaces.** `@fleetbase/*` and `fleetbase/*` package names can only belong +to extensions of an organization flagged `is_staff` +(`php artisan registry:staff [--revoke]`). + +### Publisher API (personal token, `publish` scope) + +The caller needs the owner, admin or publisher role in the listing's organization; a token +pinned to an organization reaches only that organization. Other publishers' listings and +releases answer 404. + +| Method and path | Purpose | +| --- | --- | +| `GET /v1/publisher/extensions` | The caller's listings, with package names, price and `readiness` | +| `POST /v1/publisher/extensions` | name, `packages: {npm, composer}` (one or both), optional slug, organization and listing fields. Creates a draft listing with a free price (201). Idempotent on package names: names that already belong to one of the caller's listings return it (200) and add any package kind it lacks; a different name for a kind it has, names spread over two listings, or names of another publisher answer 409 | +| `GET /v1/publisher/extensions/{slug}` | Listing with its releases | +| `PATCH /v1/publisher/extensions/{slug}` | Listing fields (name, subtitle, description, promotional_text, category, tags, languages, hosting, website/repo/support/privacy/tos URLs, copyright, fa_icon). No review | +| `GET /v1/publisher/extensions/{slug}/releases` | Every release with its checks | +| `POST /v1/publisher/extensions/{slug}/releases` | Multipart: `manifest` (extension.json), `engine` (npm tarball, gzipped) and/or `api` (Composer tar or tar.gz), optional `dist` (the `/` folder from ember-core's `fleetbase-build-engine-release`, tarred), optional `channel` (`stable`, `beta`; default from the manifest), `notes`, `submit` (submit as soon as the checks pass). Returns the release (201) | +| `GET /v1/publisher/releases/{id}` | Files, checks, submission state, review policy outcome, the review timeline with reasons and items | +| `POST /v1/publisher/releases/{id}/submit` | 200 once submitted (or published, see D6); 202 while checks are still running, then it is submitted when they pass; 422 with reasons when checks failed or the listing is not ready | +| `POST /v1/publisher/releases/{id}/withdraw` | Out of review, back to `uploaded`. Optional reason | +| `POST /v1/publisher/releases/{id}/yank` | Published → yanked: gone from metadata and the catalog's latest version; the archives stay downloadable for lockfiles. Optional reason | + +Upload rules: the manifest must be a JSON object with a SemVer `version` (no `v`) and the +request needs an engine or an api archive (422 otherwise). The archives are limited by +`REGISTRY_MAX_BUNDLE_BYTES` (50 MB each) and `REGISTRY_MAX_DIST_BYTES` (100 MB). Uploading a +version whose release was never submitted, failed its checks or was rejected replaces it +(files, checks and timeline deleted; the timeline is kept in `events`); any other existing +version answers 409. + +**Namespaces.** `@fleetbase/*` and `fleetbase/*` are for staff organizations. A scope or vendor +that another organization's packages use is refused. Otherwise a new scope must be the +organization's slug, one it already publishes under, or one staff granted in +`organizations.meta.namespaces`. Unscoped npm names are allowed. + +**Listing readiness** (the bridge's `isExtensionReadyForSubmission`, server-side): before a +listing's first release can be submitted it needs a name over 3 characters, subtitle, +description over 12 characters, promotional text, a tag, copyright, website, support and +privacy URLs, an icon (`fa_icon` counts) and a category. Published listings are not +re-checked. + +### Release checks + +`RunReleaseChecks` (queue `REGISTRY_CHECKS_QUEUE`, default queue otherwise) copies the stored +archives locally, reads each once without extracting it, and writes one `release_checks` row +per check with `passed` and `output` (`summary`, `errors`, `warnings`, details): -We welcome contributions to the Fleetbase Extensions Registry! Please read our [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to make a contribution. +| Check | Fails when | +| --- | --- | +| `manifest` | extension.json does not validate against `resources/schemas/extension.schema.json` (draft 2020-12, shared with registry-bridge); `engine`/`api` declared without the archive or the reverse; version or channel differ from the release | +| `compat` | `compat.console` or `compat.api` does not parse as a composer/semver range (missing ranges are a warning) | +| `package_names` | the `package.json` / `composer.json` name is not the listing's `extension_packages` name of that kind, or disagrees with the manifest; the engine's `package.json` version is not the release version | +| `version` | not greater than every published or yanked release on the same channel; not installable by Composer (`-next.1` and the like) when there is an api archive | +| `size` | an archive over its limit, more than `REGISTRY_MAX_ARCHIVE_ENTRIES` entries, unpacked size over `REGISTRY_MAX_UNPACKED_BYTES`, a file over `REGISTRY_MAX_FILE_BYTES` | +| `archive` | not a readable tar (header checksums are verified); engine not gzipped; absolute paths, `..`, drive letters or backslashes; symlinks, hard links, devices, FIFOs; a forbidden extension (`registry.releases.forbidden_extensions`); a native executable by magic number (ELF, Mach-O, PE) | +| `secrets_scan` | the built-in gitleaks-style ruleset (`App\Releases\Secrets\SecretRules`: AWS, GitHub, GitLab, Slack, Stripe, private keys, Google, npm, SendGrid, Twilio, Mailgun, OpenAI, Anthropic, Fleetbase tokens; `.env`, SSH keys, key stores, `.npmrc`/`auth.json` with literal tokens) finds something. With `REGISTRY_SECRETS_SCANNER=auto` gitleaks also runs when its binary is on the path (`REGISTRY_GITLEAKS_BINARY`); without it the check warns that only the built-in rules ran. `off` skips the check with a warning. Matches are redacted; `gitleaks:allow` on a line suppresses it | +| `dist` | only with a `dist` upload: no `manifest.json`, no `builtAgainst` version for `@fleetbase/ember-ui` or `@fleetbase/ember-core`, name or version not the engine's, no `runtime` entry, a listed file missing or with a different sha256. Skipped (passed) without a dist | + +The job records `release.meta.package_names` and `release.meta.dist` (`built_against`) for the +review policy and the installer. If the job itself fails the release goes to `failed_checks`. +It may run for up to 900 s, so the worker's queue needs `retry_after` above that +(`DB_QUEUE_RETRY_AFTER=960` in the compose stack) and `queue:work --timeout=900`. The worker +needs the same storage disk and cache store as the API, because an auto-approved release is +published from the job. + +### Release states and review (D6) + +``` +uploaded ──check──▶ checking ──pass_checks──▶ uploaded (ready to submit) + └──fail_checks──▶ failed_checks +uploaded | rejected ──submit──▶ submitted ──claim──▶ in_review +submitted | in_review ──approve──▶ approved ──publish──▶ published ──yank──▶ yanked +submitted | in_review | approved ──reject──▶ rejected +submitted | in_review | approved ──withdraw──▶ uploaded +``` + +Every transition writes a `reviews` row (`action`, `from_status`, `to_status`, `actor` +`publisher`/`staff`/`system`, reviewer, `reason`, `items`) in the same transaction, with the +release row locked. Other moves answer 409. + +On submit, review policy D6 decides. A human review is required for an extension's first +release and when, compared with the last published (or yanked) release, the package names, +the price (model, amount, currency; snapshotted on each release at publish) or the manifest's +`permissions` changed, or the listing is not published. The reasons are stored in +`release.meta.review_policy` and returned as `review.reasons`. Otherwise the release is +approved by `system` and published straight away. + +Publishing sets `published_at`, snapshots the price, publishes a draft listing (status and +`published_at`), flushes the catalog cache (`App\Support\CatalogCache` bumps a generation key, +so it works on every cache store), and emails the uploader. npm dist-tags and Composer +metadata are built from published releases, so they follow at once. Rejections and failed +checks are emailed too. + +### Admin API (personal token, `review` scope, member of an `is_staff` organization) + +| Method and path | Purpose | +| --- | --- | +| `GET /v1/admin/releases?status=&extension=&limit=` | The review queue, oldest first. Default statuses `submitted`, `in_review`, `approved` | +| `GET /v1/admin/releases/{id}` | A release with checks, policy reasons and timeline | +| `POST /v1/admin/releases/{id}/claim` | submitted → in_review | +| `POST /v1/admin/releases/{id}/approve` | Optional `reason`, `items`; publishes unless `publish: false` | +| `POST /v1/admin/releases/{id}/reject` | Required `reason` and `items: [{code, passed, note?}]` (at least one) | +| `POST /v1/admin/releases/{id}/publish` | An approved release | +| `POST /v1/admin/extensions/{slug}/suspend` | Required `reason`. The listing leaves the catalog and the package endpoints; uploads and publishing stop | + +### Instance API (instance token) + +An organization owner or admin mints a registration code, pastes it into the instance (console +admin page or `flb instance register`), and the instance exchanges it for an instance token. +Instance tokens are `flbi_` + 40 random characters, shown once and stored as SHA-256 in +`instances.token_hash`. They go through the same `TokenGuard` as personal tokens with a kind +check: the `auth:instance` guard accepts only `flbi_` tokens and `auth:token` only `flbp_` +tokens, so neither works on the other's routes. Setting `instances.status` to `revoked` +disables a token. + +| Method and path | Purpose | +| --- | --- | +| `POST /v1/instances/register` | No bearer token. Body: `code`, `name`, `kind` (`cloud` or `self_hosted`), optional `host`, `console_version`, `api_version`. Redeems the code (single use, 15 minutes) and returns `{token, token_type, instance}`; the token is never shown again | +| `GET /v1/instances/me` | The instance, its organization, and the organization's licences with `seats_used` and `activated_here` | +| `GET /v1/instances/me/updates?channel=` | Every extension installed on the instance: the installed release, the newest compatible published release, `update_available`. `channel=beta` includes beta releases | +| `POST /v1/instances/me/installs` | Body: `extension` (slug, `ext_` id or an npm/Composer package name), `version` or `release` (`rel_` id), `status` (`installing`, `installed`, `failed`), optional `error`, `installed_by`. Returns `{install, activation}` | +| `DELETE /v1/instances/me/installs/{extension}` | Reports an uninstall (scoped npm names work in the path). Returns `{install, deactivated}` | + +Install rules: + +- One `installs` row per attempt: `installing` opens it, `installed` or `failed` closes it. + When a release is installed, earlier `installed` rows for that extension become + `uninstalled`, so each instance counts once in the catalog's install counts. +- `installing` and `installed` claim a licence seat (seat = instance) and return the + activation. Free extensions get an automatic `free` licence for the instance's organization + on first install, with unlimited seats (`max_activations` null). Paid extensions need an + active licence with a free seat, else 403. A revoked or expired licence counts as none. +- A new install (`installing`) needs a published listing, a published release and a hosting + value that matches the instance kind (422 otherwise). `installed` and `failed` also accept + a yanked release, since an instance may already have it. +- `failed` releases the seat, unless an earlier release of the extension is still installed + (a failed upgrade). +- Compatibility comes from `releases.compat` (`{"console": "", "api": ""}`, + npm/Composer range syntax, evaluated with composer/semver). A side with no range accepts any + version; an instance that does not report a version, or reports one that is not semver, + is not constrained on that side; a range that does not parse never matches. + +## Importing from Fleetbase Cloud + +`registry:import` copies the cloud registry-bridge tables into the service, following the +plan's mapping rules. It reads `registry_extensions`, `registry_extension_bundles`, +`registry_extension_purchases`, `registry_extension_installs`, `registry_users`, +`registry_developer_accounts`, `users`, `companies`, `files` and `categories`. + +```bash +# Rehearse: counts and anomalies, then roll back. Nothing is written and no file is copied. +php artisan registry:import --from=/backups/fleetbase-cloud.sql.gz --dry-run + +# Import, keeping the full anomaly list for the publisher follow-up. +php artisan registry:import --from=mysql://reader:secret@cloud-replica:3306/fleetbase --report=import-report.json +``` + +| Option | Meaning | +| --- | --- | +| `--from` | `mysql://user:pass@host:3306/db` (or `mariadb://`), `sqlite:///path/file.sqlite`, or a path to a `.sql` / `.sql.gz` dump. Dumps are loaded into a temporary SQLite file without a MySQL server; mysqldump's extended inserts and one-tuple-per-line dumps both work, and statements for other tables are skipped | +| `--dry-run` | Runs the whole mapping in a transaction that is rolled back, so the counts and anomalies are exactly what a real run would produce | +| `--source-disk` | Filesystem disk holding the cloud `files` objects. Default `REGISTRY_IMPORT_SOURCE_DISK` (`import_source`, an S3 disk configured by `IMPORT_SOURCE_AWS_*`) | +| `--report` | Also writes the full report, every anomaly included, as JSON | +| `--examples` | Anomaly examples printed per type (default 10) | + +What it writes: + +| Cloud | Service | Rule | +| --- | --- | --- | +| `registry_developer_accounts` | `accounts`, personal `organizations`, `memberships` (owner) | Status kept (`active`, `suspended`, `pending_verification`); bcrypt hash copied | +| `companies` | `organizations` | One per company the registry tables reference (publishers, registry users, buyers, installers); `stripe_connect_id` copied | +| `registry_users` (cloud) + `users` | `accounts`, `memberships` | One account per user (registry_users has one row per token); owner if the user owns the company, else publisher; password hash copied; tokens not migrated | +| `categories` | `categories` | Matched to seeded categories by `internal_id`, then slug; others created | +| `registry_extensions` | `extensions`, `prices`, `extension_packages` | Package names from the current bundle's `package.json` and `composer.json`, then older bundles, then the Phase 0 `package_name` column; listings with no bundle and no package name become drafts; `self_managed` maps to hosting `self_hosted`; icon and screenshots copied | +| `registry_extension_bundles` | `releases` | Version from `extension.json`; `bundle_number` kept in `manifest`; status pending → uploaded, awaiting_review → in_review, approved, published, rejected; `compat` from `extension.json` when present. The bundle archive is copied to `tarballs/imported/…` as a `source_bundle` file with a SHA-256 checksum; a bundle whose file is missing imports as `yanked` | +| `registry_extension_purchases` | `purchases`, `licences` | One paid licence per purchase with 5 seats; a soft-deleted purchase revokes its licence | +| `registry_extension_installs` | `instances`, `installs`, `licence_activations` | One synthetic cloud instance per company (no token); soft-deleted installs become `uninstalled`; live installs claim a seat on the paid licence or an automatic free licence | + +Idempotency: every imported row stores the uuid of its source row in `source_uuid`, and +re-runs match on it and update in place; an unchanged source produces only "unchanged" +counts. Slugs, usernames and paid seat counts are set on first import and then left alone. +Tarballs already copied with a checksum are not copied again; a release imported as yanked +is restored when its tarball becomes retrievable. An account whose email already belongs to +another account (created in the service, or imported from the other source table) is mapped +onto it and never overwritten. + +The anomaly report lists: extensions without package names or bundles, bundles whose +tarball is missing (with the reason), duplicate package names (the first live, published +listing keeps the name), users without emails, accounts merged by email, bundles without a +version or repeating a version, rows that reference missing companies, users or extensions, +installs with no usable licence, renamed slugs, unknown statuses, subscription pricing, and +icons that could not be copied. + +## Repository layout + +``` +app/ + Actions/ ActivateLicence, DeactivateLicence, RegisterAccount + Auth/TokenGuard.php bearer-token guard (auth:token, auth:instance) + Casts/Money.php minor-unit money cast + Console/Commands/ registry:seed, registry:staff, registry:import + Http/Controllers/Packages/ npm and Composer endpoints + Http/Controllers/Publisher/ publisher extensions and releases + Http/Controllers/Admin/ staff review API + Jobs/RunReleaseChecks.php queued release checks + Releases/ pipeline, state machine, review policy (D6), listing readiness, + namespace rules, manifest schema, checks, archive inspection, secrets + Import/ importer: SQL dump loader, source connection, mapping, report + Http/Filter/ request-driven query filters (ExtensionFilter) + Http/Middleware/ scope:…, licence:npm|composer + Http/Resources/ Fleetbase-style JSON resources + Models/ one model per table; Model.php is the base + Services/ ReleaseStorage, LicenceGate, Packages/ (packument, Composer metadata) + Support/ Http, Utils, Semver (the one version comparator), Compat (ranges), + Tarball, CatalogCache +database/fixtures/packages/ fixture tarballs for registry:seed --seed-release +database/migrations/ the data model +database/seeders/ CategorySeeder (41 categories from registry-bridge) +docker/ compose helpers +legacy/ the Verdaccio registry (v1) +resources/schemas/ extension.json JSON Schema (copy of registry-bridge's) +tests/Feature/{Auth,Catalog,Packages,Instance,Import,Releases} Pest feature tests (SQLite in memory) +tests/Conformance/packages.sh real npm and Composer clients against a running service +tests/Fixtures/import/ mysqldump fixture of the cloud registry tables +``` -## Support and Contact +## Deployment -For support or inquiries, please [open an issue](link-to-issue-tracker). +`docker-bake.hcl` at the root builds the `aws-app` target (the image wrapped with +`ssm-parent`, as the v1 deploy does). The `deploy/*` workflow still builds the Verdaccio +image from `legacy/` until the catalog cutover; point it at the root bake file when the +service goes to staging. ## License -This project is licensed under the APGLV v3 License - see the [LICENSE.md](LICENSE.md) file for details. +AGPL-3.0-or-later. See [LICENSE.md](LICENSE.md). diff --git a/app/Actions/ActivateLicence.php b/app/Actions/ActivateLicence.php new file mode 100644 index 0000000..9c9d719 --- /dev/null +++ b/app/Actions/ActivateLicence.php @@ -0,0 +1,101 @@ +active() + ->where('extension_uuid', $extension->uuid) + ->where('organization_uuid', $instance->organization_uuid) + ->orderByRaw('case when kind = ? then 0 else 1 end', [Licence::KIND_PAID]) + ->orderBy('created_at') + ->lockForUpdate() + ->get(); + + $existing = LicenceActivation::query() + ->whereIn('licence_uuid', $licences->pluck('uuid')) + ->where('instance_uuid', $instance->uuid) + ->whereNull('deactivated_at') + ->first(); + + if ($existing) { + if ($release && $existing->release_uuid !== $release->uuid) { + $existing->forceFill(['release_uuid' => $release->uuid])->save(); + } + + return $existing->setRelation('licence', $licences->firstWhere('uuid', $existing->licence_uuid)); + } + + if ($licences->isEmpty()) { + $licences = collect([$this->freeLicence($instance, $extension)]); + } + + /** @var Licence|null $licence */ + $licence = $licences->first(fn (Licence $licence) => $licence->hasFreeSeat()); + if (! $licence) { + $seats = (int) $licences->sum('max_activations'); + + throw new LicenceRequiredException("All {$seats} seats for '{$extension->slug}' are in use. Uninstall it from another instance first."); + } + + $activation = LicenceActivation::create([ + 'licence_uuid' => $licence->uuid, + 'instance_uuid' => $instance->uuid, + 'release_uuid' => $release?->uuid, + 'activated_at' => now(), + ]); + + return $activation->setRelation('licence', $licence); + }); + } + + /** + * Free extensions get one unlimited licence per organization on first install. + * Paid extensions, and free ones whose licence staff revoked, are refused. + */ + protected function freeLicence(Instance $instance, Extension $extension): Licence + { + $extension->loadMissing('price'); + + if ($extension->isPaid()) { + throw new LicenceRequiredException("'{$extension->slug}' is a paid extension and your organization does not hold an active licence for it."); + } + + $revoked = Licence::query() + ->where('extension_uuid', $extension->uuid) + ->where('organization_uuid', $instance->organization_uuid) + ->where('status', Licence::STATUS_REVOKED) + ->exists(); + + if ($revoked) { + throw new LicenceRequiredException("Your organization's licence for '{$extension->slug}' has been revoked."); + } + + return Licence::create([ + 'extension_uuid' => $extension->uuid, + 'organization_uuid' => $instance->organization_uuid, + 'kind' => Licence::KIND_FREE, + 'max_activations' => null, + 'status' => Licence::STATUS_ACTIVE, + ]); + } +} diff --git a/app/Actions/DeactivateLicence.php b/app/Actions/DeactivateLicence.php new file mode 100644 index 0000000..07071de --- /dev/null +++ b/app/Actions/DeactivateLicence.php @@ -0,0 +1,23 @@ +where('instance_uuid', $instance->uuid) + ->whereNull('deactivated_at') + ->whereHas('licence', fn ($q) => $q->where('extension_uuid', $extension->uuid)) + ->update(['deactivated_at' => now(), 'updated_at' => now()]); + } +} diff --git a/app/Actions/RegisterAccount.php b/app/Actions/RegisterAccount.php new file mode 100644 index 0000000..5a051b0 --- /dev/null +++ b/app/Actions/RegisterAccount.php @@ -0,0 +1,88 @@ +uniqueUsername($input['email']); + + $account = Account::create([ + 'name' => $input['name'], + 'email' => Str::lower($input['email']), + 'username' => $username, + 'password' => $input['password'], + 'status' => Account::STATUS_PENDING_VERIFICATION, + 'last_login_ip' => $ip, + ]); + + $organization = Organization::create([ + 'slug' => $this->uniqueSlug($username), + 'name' => $input['organization_name'] ?? $input['name'], + 'is_personal' => true, + ]); + + Membership::create([ + 'organization_uuid' => $organization->uuid, + 'account_uuid' => $account->uuid, + 'role' => Membership::ROLE_OWNER, + ]); + + Event::record('account.registered', $account, $account, ['organization' => $organization->public_id], $ip); + + return $account; + }); + + $this->sendVerificationCode($account); + + return $account; + } + + public function sendVerificationCode(Account $account): VerificationCode + { + $code = VerificationCode::generateFor($account); + + Mail::to($account->email)->send(new VerificationCodeMail($account, $code)); + + return $code; + } + + protected function uniqueUsername(string $email): string + { + $base = Utils::slugify(Str::before($email, '@')); + $candidate = $base; + + while (Account::withTrashed()->where('username', $candidate)->exists() || Organization::withTrashed()->where('slug', $candidate)->exists()) { + $candidate = $base.'-'.Str::lower(Str::random(4)); + } + + return $candidate; + } + + protected function uniqueSlug(string $base): string + { + $candidate = Utils::slugify($base); + + while (Organization::withTrashed()->where('slug', $candidate)->exists()) { + $candidate = $base.'-'.Str::lower(Str::random(4)); + } + + return $candidate; + } +} diff --git a/app/Auth/TokenGuard.php b/app/Auth/TokenGuard.php new file mode 100644 index 0000000..97cb2fd --- /dev/null +++ b/app/Auth/TokenGuard.php @@ -0,0 +1,78 @@ +bearerToken(); + + return match ($this->kind) { + self::KIND_INSTANCE => $this->resolveInstance($plain, $request), + default => $this->resolveAccount($plain, $request), + }; + } + + protected function resolveAccount(string $plain, Request $request): ?Account + { + if ($plain === '' || ! str_starts_with($plain, Token::PREFIX_PERSONAL)) { + return null; + } + + $token = Token::findActive($plain); + if (! $token) { + return null; + } + + $account = $token->account; + if (! $account || ! $account->isActive()) { + return null; + } + + $token->touchUsage($request->ip()); + $request->attributes->set('token', $token); + + return $account->withCurrentToken($token); + } + + protected function resolveInstance(string $plain, Request $request): ?Instance + { + if ($plain === '' || ! str_starts_with($plain, Token::PREFIX_INSTANCE)) { + return null; + } + + $instance = Instance::findByToken($plain); + if (! $instance) { + return null; + } + + $instance->touchLastSeen($request->ip()); + + return $instance; + } +} diff --git a/app/Casts/Money.php b/app/Casts/Money.php new file mode 100644 index 0000000..a9f7a71 --- /dev/null +++ b/app/Casts/Money.php @@ -0,0 +1,45 @@ +option('from'); + if ($from === '') { + $this->error('--from is required: a mysql:// or sqlite:// DSN, or a path to a SQL dump.'); + + return self::INVALID; + } + + $dryRun = (bool) $this->option('dry-run'); + $sourceDisk = $this->option('source-disk') ?: config('registry.import.source_disk'); + $targetDisk = config('registry.storage.disk'); + $source = new SourceDatabase($from); + + $this->line(sprintf('%s from %s', $dryRun ? 'Dry run' : 'Importing', $source->describe())); + $this->line("Files: {$sourceDisk} → {$targetDisk}"); + + try { + $connection = $source->open(); + $report = (new RegistryImporter($connection, $sourceDisk, $targetDisk, $dryRun))->run(); + } catch (\Throwable $e) { + $this->error('Import failed: '.$e->getMessage()); + + return self::FAILURE; + } finally { + $source->close(); + } + + $this->printReport($report, max(0, (int) $this->option('examples'))); + + if (! $dryRun) { + Event::record('registry.imported', null, null, [ + 'from' => $source->describe(), + 'counts' => $report->counts, + 'anomalies' => array_map('count', $report->anomalies), + ], '127.0.0.1'); + } + + if ($path = $this->option('report')) { + file_put_contents($path, json_encode($report->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + $this->line("Full report written to {$path}"); + } + + $this->newLine(); + $this->line($dryRun ? 'Dry run: nothing was written.' : 'Import complete.'); + + return self::SUCCESS; + } + + protected function printReport(ImportReport $report, int $examples): void + { + $this->newLine(); + $this->line('Source rows read'); + $this->table(['Table', 'Rows'], collect($report->source)->map(fn ($n, $t) => [$t, $n])->values()->all()); + + $this->line(''.($report->dryRun ? 'Would write' : 'Written').''); + $outcomes = [ImportReport::CREATED, ImportReport::UPDATED, ImportReport::UNCHANGED, ImportReport::SKIPPED]; + $this->table( + array_merge(['Table'], array_map('ucfirst', $outcomes)), + collect($report->counts)->sortKeys()->map(fn ($counts, $table) => array_merge([$table], array_map(fn ($o) => $counts[$o] ?? 0, $outcomes)))->values()->all() + ); + + $this->line('Anomalies ('.$report->anomalyCount().')'); + if ($report->anomalyCount() === 0) { + $this->line(' none'); + + return; + } + + foreach ($report->anomalies as $type => $items) { + $this->newLine(); + $this->line(sprintf(' %s: %d', ImportReport::ANOMALIES[$type] ?? $type, count($items))); + foreach (array_slice($items, 0, $examples) as $item) { + $this->line(' - '.collect($item)->map(fn ($v, $k) => $k.'='.(is_scalar($v) || $v === null ? var_export($v, true) : json_encode($v)))->implode(', ')); + } + if (count($items) > $examples) { + $this->line(' … '.(count($items) - $examples).' more (use --report for all)'); + } + } + } +} diff --git a/app/Console/Commands/ManageStaffOrganization.php b/app/Console/Commands/ManageStaffOrganization.php new file mode 100644 index 0000000..e0feb21 --- /dev/null +++ b/app/Console/Commands/ManageStaffOrganization.php @@ -0,0 +1,39 @@ +argument('organization'); + $organization = Organization::query()->where('slug', $identifier)->orWhere('public_id', $identifier)->first(); + + if ($organization === null) { + $this->error("No organization matches [{$identifier}]."); + + return self::FAILURE; + } + + $organization->forceFill(['is_staff' => ! $this->option('revoke')])->save(); + + $this->info($organization->is_staff + ? "{$organization->name} ({$organization->slug}) is now a staff organization." + : "{$organization->name} ({$organization->slug}) is no longer a staff organization."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SeedRegistry.php b/app/Console/Commands/SeedRegistry.php new file mode 100644 index 0000000..8851a7e --- /dev/null +++ b/app/Console/Commands/SeedRegistry.php @@ -0,0 +1,230 @@ + [name, price in cents or null for free]. */ + public const FIXTURES = [ + 'hello' => ['Registry Demo Hello', null], + 'pro' => ['Registry Demo Pro', 4900], + ]; + + public function handle(ReleaseStorage $storage): int + { + if ($this->laravel->isProduction() && ! $this->option('force')) { + $this->error('Refusing to seed in production without --force.'); + + return self::FAILURE; + } + + $this->callSilently('db:seed', ['--class' => DatabaseSeeder::class, '--force' => true]); + + if (! $this->option('seed-release')) { + $this->option('json') ? $this->line(json_encode(['seeded' => ['categories']])) : $this->info('Seeded categories.'); + + return self::SUCCESS; + } + + $result = DB::transaction(fn () => $this->seedReleases($storage)); + + if ($this->option('json')) { + $this->line(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return self::SUCCESS; + } + + $this->info('Seeded demo releases.'); + $this->table(['Extension', 'Pricing', 'npm', 'Composer', 'Version'], array_map(fn ($e) => [ + $e['name'], $e['paid'] ? 'paid' : 'free', $e['npm'], $e['composer'], $e['version'], + ], $result['extensions'])); + $this->newLine(); + $this->line('Tokens (shown once, install scope):'); + $this->line(" licensed {$result['tokens']['licensed']} (organization {$result['organizations']['buyer']} holds a licence for the paid extension)"); + $this->line(" unlicensed {$result['tokens']['unlicensed']} (organization {$result['organizations']['outsider']} holds none)"); + + return self::SUCCESS; + } + + protected function seedReleases(ReleaseStorage $storage): array + { + $publisherAccount = $this->account('publisher@registry-demo.test', 'Registry Demo Publisher', 'registry-demo-publisher'); + $publisher = $this->organization(self::PUBLISHER, 'Registry Demo', $publisherAccount); + $category = Category::query()->orderBy('sort')->first(); + + $extensions = []; + foreach (self::FIXTURES as $key => [$name, $amount]) { + $extension = Extension::withTrashed()->updateOrCreate(['slug' => self::PUBLISHER.'-'.$key], [ + 'organization_uuid' => $publisher->uuid, + 'category_uuid' => $category?->uuid, + 'created_by_account_uuid' => $publisherAccount->uuid, + 'name' => $name, + 'subtitle' => "Fixture extension seeded by registry:seed ({$key}).", + 'description' => 'Demo listing used to exercise the npm and Composer endpoints.', + 'hosting' => Extension::HOSTING_BOTH, + 'status' => Extension::STATUS_PUBLISHED, + 'published_at' => now(), + 'fa_icon' => 'puzzle-piece', + 'tags' => ['demo'], + 'languages' => ['en'], + ]); + $extension->restore(); + + Price::query()->updateOrCreate(['extension_uuid' => $extension->uuid], $amount === null + ? ['model' => Price::MODEL_FREE, 'amount' => 0, 'currency' => 'USD'] + : ['model' => Price::MODEL_ONE_TIME, 'amount' => $amount, 'currency' => 'USD']); + + $npm = ExtensionPackage::query()->firstOrCreate( + ['kind' => ExtensionPackage::KIND_NPM, 'name' => '@'.self::PUBLISHER.'/'.$key.'-engine'], + ['extension_uuid' => $extension->uuid], + ); + $composer = ExtensionPackage::query()->firstOrCreate( + ['kind' => ExtensionPackage::KIND_COMPOSER, 'name' => self::PUBLISHER.'/'.$key.'-api'], + ['extension_uuid' => $extension->uuid], + ); + + $release = $this->release($storage, $extension, $key, '1.0.0', $publisherAccount); + + $extensions[] = [ + 'name' => $extension->name, + 'slug' => $extension->slug, + 'paid' => $amount !== null, + 'npm' => $npm->name, + 'composer' => $composer->name, + 'version' => $release->version, + ]; + } + + $paid = Extension::query()->where('slug', self::PUBLISHER.'-pro')->firstOrFail(); + + $buyerAccount = $this->account('buyer@registry-demo.test', 'Registry Demo Buyer', 'registry-demo-buyer'); + $buyer = $this->organization('registry-demo-buyer', 'Registry Demo Buyer', $buyerAccount); + Licence::withTrashed()->updateOrCreate( + ['extension_uuid' => $paid->uuid, 'organization_uuid' => $buyer->uuid], + ['kind' => Licence::KIND_PAID, 'status' => Licence::STATUS_ACTIVE, 'expires_at' => null], + )->restore(); + + $outsiderAccount = $this->account('outsider@registry-demo.test', 'Registry Demo Outsider', 'registry-demo-outsider'); + $outsider = $this->organization('registry-demo-outsider', 'Registry Demo Outsider', $outsiderAccount); + + return [ + 'extensions' => $extensions, + 'organizations' => ['publisher' => $publisher->slug, 'buyer' => $buyer->slug, 'outsider' => $outsider->slug], + 'tokens' => [ + 'licensed' => $this->reissueToken($buyerAccount), + 'unlicensed' => $this->reissueToken($outsiderAccount), + ], + ]; + } + + protected function release(ReleaseStorage $storage, Extension $extension, string $key, string $version, Account $creator): Release + { + $release = Release::withTrashed()->firstOrNew(['extension_uuid' => $extension->uuid, 'version' => $version]); + $release->fill([ + 'channel' => Release::CHANNEL_STABLE, + 'status' => Release::STATUS_PUBLISHED, + 'created_by_account_uuid' => $creator->uuid, + 'manifest' => ['id' => $extension->slug, 'name' => $extension->name, 'version' => $version], + 'compat' => ['console' => '>=0.7.0', 'api' => '>=1.6.0'], + 'notes' => 'Seeded by registry:seed --seed-release.', + 'published_at' => $release->published_at ?? now(), + ]); + $release->deleted_at = null; + $release->save(); + + $files = [ + 'engine_tarball_file_uuid' => [File::TYPE_ENGINE_TARBALL, "{$key}-engine-{$version}.tgz"], + 'api_tarball_file_uuid' => [File::TYPE_API_TARBALL, "{$key}-api-{$version}.tar.gz"], + ]; + foreach ($files as $column => [$type, $fixture]) { + $existing = $release->{$column} ? File::find($release->{$column}) : null; + if ($existing && $storage->exists($existing)) { + continue; + } + + $file = $storage->store(base_path('database/fixtures/packages/'.$fixture), $type, $release, [ + 'uploader_account_uuid' => $creator->uuid, + ]); + $release->{$column} = $file->uuid; + } + + $release->size_bytes = File::query()->whereIn('uuid', [$release->engine_tarball_file_uuid, $release->api_tarball_file_uuid])->sum('size'); + $release->checksum = 'sha256:'.File::find($release->engine_tarball_file_uuid)?->checksum; + $release->save(); + + return $release; + } + + protected function account(string $email, string $name, string $username): Account + { + $account = Account::withTrashed()->firstOrNew(['email' => $email]); + $account->fill([ + 'name' => $name, + 'username' => $username, + 'status' => Account::STATUS_ACTIVE, + 'email_verified_at' => $account->email_verified_at ?? now(), + ]); + if (! $account->exists) { + $account->password = Str::random(40); + } + $account->deleted_at = null; + $account->save(); + + return $account; + } + + protected function organization(string $slug, string $name, Account $owner): Organization + { + $organization = Organization::withTrashed()->updateOrCreate(['slug' => $slug], ['name' => $name, 'verified_at' => now()]); + $organization->restore(); + + if (! $owner->organizations()->whereKey($organization->uuid)->exists()) { + $owner->organizations()->attach($organization->uuid, ['role' => Membership::ROLE_OWNER]); + } + + return $organization; + } + + protected function reissueToken(Account $account): string + { + $account->tokens()->where('name', self::TOKEN_NAME)->whereNull('revoked_at')->get()->each->revoke(); + + return Token::issue($account, self::TOKEN_NAME, [Token::SCOPE_INSTALL, Token::SCOPE_READ], now()->addDays(30))->plainTextToken; + } +} diff --git a/app/Exceptions/LicenceRequiredException.php b/app/Exceptions/LicenceRequiredException.php new file mode 100644 index 0000000..09bb4ce --- /dev/null +++ b/app/Exceptions/LicenceRequiredException.php @@ -0,0 +1,17 @@ + "The '{$namespace}' package namespace is reserved for Fleetbase; {$name} cannot be registered by this organization.", + ]); + } +} diff --git a/app/Http/Controllers/Account/AccountController.php b/app/Http/Controllers/Account/AccountController.php new file mode 100644 index 0000000..b83ad74 --- /dev/null +++ b/app/Http/Controllers/Account/AccountController.php @@ -0,0 +1,18 @@ +user()->load('organizations')); + } +} diff --git a/app/Http/Controllers/Account/InstanceCodeController.php b/app/Http/Controllers/Account/InstanceCodeController.php new file mode 100644 index 0000000..96bd611 --- /dev/null +++ b/app/Http/Controllers/Account/InstanceCodeController.php @@ -0,0 +1,73 @@ +user(); + $token = $account->currentToken(); + $identifier = $request->input('organization'); + + if ($identifier === null && $token?->organization_uuid) { + $identifier = $token->organization_uuid; + } + + $organization = $identifier === null + ? $account->personalOrganization() + : $account->organizations() + ->where(fn ($q) => $q->where('organizations.uuid', $identifier) + ->orWhere('organizations.public_id', $identifier) + ->orWhere('organizations.slug', $identifier)) + ->first(); + + if (! $organization instanceof Organization) { + return response()->error('You are not a member of that organization.', 422); + } + + if ($token?->organization_uuid && $token->organization_uuid !== $organization->uuid) { + return response()->error('This token is limited to another organization.', 403); + } + + if (! in_array($this->roleIn($account, $organization), self::ROLES, true)) { + return response()->error('Only organization owners and admins can register instances.', 403); + } + + $code = InstanceRegistrationCode::mint($organization, $account, $request->ip()); + Event::record('instance_code.created', $organization, $account, ['code' => $code->uuid], $request->ip()); + + return response()->json([ + 'code' => $code->plainTextCode, + 'organization' => $organization->public_id, + 'expires_at' => $code->expires_at, + 'expires_in' => InstanceRegistrationCode::LIFETIME_MINUTES * 60, + ], 201); + } + + protected function roleIn(Account $account, Organization $organization): ?string + { + return Membership::query() + ->where('account_uuid', $account->uuid) + ->where('organization_uuid', $organization->uuid) + ->value('role'); + } +} diff --git a/app/Http/Controllers/Account/TokenController.php b/app/Http/Controllers/Account/TokenController.php new file mode 100644 index 0000000..2033eba --- /dev/null +++ b/app/Http/Controllers/Account/TokenController.php @@ -0,0 +1,89 @@ +user(); + + $tokens = $account->tokens()->with('organization')->whereNull('revoked_at')->orderByDesc('created_at')->get(); + + return TokenResource::collection($tokens); + } + + /** + * POST /v1/account/tokens — creates a token; the plaintext is only returned here. + */ + public function store(CreateTokenRequest $request): JsonResponse + { + /** @var Account $account */ + $account = $request->user(); + + $organizationUuid = null; + if ($request->filled('organization')) { + $organization = $account->organizations() + ->where(fn ($q) => $q->where('organizations.public_id', $request->input('organization'))->orWhere('organizations.slug', $request->input('organization'))) + ->first(); + + if (! $organization instanceof Organization) { + return response()->error('You are not a member of that organization.', 422); + } + + $organizationUuid = $organization->uuid; + } + + $token = Token::issue( + $account, + $request->input('name'), + $request->input('scopes'), + now()->addDays((int) ($request->input('expires_in_days') ?: Token::MAX_LIFETIME_DAYS)), + $organizationUuid, + ); + + $token->load('organization'); + Event::record('token.created', $token, $account, ['scopes' => $token->scopes], $request->ip()); + + return response()->json([ + 'token' => $token->plainTextToken, + 'token_type' => 'Bearer', + 'expires_at' => $token->expires_at, + 'token_details' => (new TokenResource($token))->resolve($request), + ], 201); + } + + /** + * DELETE /v1/account/tokens/{token} — revokes one of the caller's tokens. + */ + public function destroy(Request $request, string $id): JsonResponse + { + /** @var Account $account */ + $account = $request->user(); + + $token = $account->tokens()->where(fn ($q) => $q->where('public_id', $id)->orWhere('uuid', $id))->first(); + + if (! $token) { + return response()->error('Token not found.', 404); + } + + $token->revoke(); + Event::record('token.revoked', $token, $account, [], $request->ip()); + + return response()->json(['revoked' => true, 'id' => $token->public_id]); + } +} diff --git a/app/Http/Controllers/Admin/ReviewController.php b/app/Http/Controllers/Admin/ReviewController.php new file mode 100644 index 0000000..40d4582 --- /dev/null +++ b/app/Http/Controllers/Admin/ReviewController.php @@ -0,0 +1,114 @@ +validate([ + 'status' => ['nullable', 'string'], + 'extension' => ['nullable', 'string', 'max:191'], + 'limit' => ['nullable', 'integer', 'min:1', 'max:100'], + ]); + + $statuses = $request->filled('status') ? explode(',', $request->input('status')) : Release::QUEUE_STATUSES; + $request->merge(['statuses' => $statuses])->validate(['statuses.*' => [Rule::in(Release::STATUSES)]]); + + $releases = Release::query() + ->with(['extension.organization', 'engineTarball', 'apiTarball', 'engineDist', 'checks']) + ->whereIn('status', $statuses) + ->when($request->filled('extension'), fn (Builder $q) => $q->whereHas('extension', fn (Builder $q) => $q->where('slug', $request->input('extension'))->orWhere('public_id', $request->input('extension')))) + ->orderBy('updated_at') + ->orderBy('id') + ->paginate((int) $request->input('limit', 25)) + ->withQueryString(); + + return ReleaseDetailResource::collection($releases); + } + + public function show(string $release): ReleaseDetailResource + { + return new ReleaseDetailResource(PublisherController::releaseDetail(Release::findByIdOrFail($release))); + } + + /** + * POST /v1/admin/releases/{id}/claim — submitted → in_review. + */ + public function claim(Request $request, string $release): ReleaseDetailResource + { + $release = $this->pipeline->claim(Release::findByIdOrFail($release), $request->user()); + + return new ReleaseDetailResource(PublisherController::releaseDetail($release)); + } + + /** + * POST /v1/admin/releases/{id}/approve — optional reason and items; publishes unless + * `publish` is false. + */ + public function approve(ReviewDecisionRequest $request, string $release): ReleaseDetailResource + { + $release = $this->pipeline->approve( + Release::findByIdOrFail($release), + $request->user(), + $request->validated('reason'), + $request->items(), + $request->boolean('publish', true), + ); + + return new ReleaseDetailResource(PublisherController::releaseDetail($release)); + } + + /** + * POST /v1/admin/releases/{id}/reject — required reason and items. + */ + public function reject(ReviewDecisionRequest $request, string $release): ReleaseDetailResource + { + $release = $this->pipeline->reject(Release::findByIdOrFail($release), $request->user(), $request->validated('reason'), $request->items()); + + return new ReleaseDetailResource(PublisherController::releaseDetail($release)); + } + + /** + * POST /v1/admin/releases/{id}/publish — an approved release. + */ + public function publish(Request $request, string $release): ReleaseDetailResource + { + $release = $this->pipeline->publish(Release::findByIdOrFail($release), $request->user()); + + return new ReleaseDetailResource(PublisherController::releaseDetail($release)); + } + + /** + * POST /v1/admin/extensions/{slug}/suspend — required reason. + */ + public function suspend(SuspendExtensionRequest $request, string $slug): PublisherExtensionResource + { + $extension = Extension::query()->where(fn (Builder $q) => $q->where('slug', $slug)->orWhere('public_id', $slug))->firstOrFail(); + $extension = $this->pipeline->suspend($extension, $request->user(), $request->validated('reason')); + + return new PublisherExtensionResource($extension->load(['organization', 'category', 'packages', 'price'])); + } +} diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php new file mode 100644 index 0000000..f45bc76 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthController.php @@ -0,0 +1,135 @@ +validated(), $request->ip()); + + return response()->json([ + 'account' => (new AccountResource($account))->resolve($request), + 'message' => 'Account created. Check your email for a verification code.', + ], 201); + } + + /** + * POST /v1/auth/verify + */ + public function verify(VerifyEmailRequest $request): JsonResponse + { + $account = Account::where('email', Str::lower($request->input('email')))->first(); + + if (! $account) { + return response()->error('No account found for that email.', 404); + } + + if ($account->hasVerifiedEmail()) { + return response()->json(['verified' => true, 'message' => 'Email already verified.']); + } + + if (! VerificationCode::consume($account, $request->input('code'))) { + return response()->error('Invalid or expired verification code.', 422); + } + + $account->markEmailAsVerified(); + Event::record('account.verified', $account, $account, [], $request->ip()); + + return response()->json(['verified' => true, 'message' => 'Email verified. You can now log in.']); + } + + /** + * POST /v1/auth/verify/resend + */ + public function resendVerification(Request $request, RegisterAccount $registerAccount): JsonResponse + { + $request->validate(['email' => ['required', 'email:rfc']]); + + $account = Account::where('email', Str::lower($request->input('email')))->first(); + + // Always answer the same way so this cannot be used to enumerate accounts. + if ($account && ! $account->hasVerifiedEmail()) { + $registerAccount->sendVerificationCode($account); + } + + return response()->json(['message' => 'If that account needs verification, a new code has been sent.']); + } + + /** + * POST /v1/auth/login + * + * Exchanges email and password for a personal token, shown once. + * The password check uses Hash::check, the same as core-api's Auth::checkPassword. + */ + public function login(LoginRequest $request): JsonResponse + { + $account = Account::where('email', Str::lower($request->input('email')))->first(); + + if (! $account || ! $account->password || ! Hash::check($request->input('password'), $account->password)) { + Event::record('account.login_failed', $account, null, ['email' => Str::lower($request->input('email'))], $request->ip()); + + return response()->error('Invalid email or password.', 401); + } + + if (! $account->hasVerifiedEmail()) { + return response()->error('Email address is not verified.', 403, ['code' => 'email_unverified']); + } + + if (! $account->isActive()) { + return response()->error('This account is '.$account->status.'.', 403, ['code' => 'account_'.$account->status]); + } + + $token = Token::issue( + $account, + $request->input('token_name') ?: 'login '.now()->toDateString(), + $request->input('scopes') ?: [Token::SCOPE_ALL], + now()->addDays((int) ($request->input('expires_in_days') ?: 30)), + ); + + $account->forceFill(['last_login_at' => now(), 'last_login_ip' => $request->ip()])->save(); + Event::record('account.login', $account, $account, ['token' => $token->public_id], $request->ip()); + + return response()->json([ + 'token' => $token->plainTextToken, + 'token_type' => 'Bearer', + 'expires_at' => $token->expires_at, + 'token_details' => (new TokenResource($token))->resolve($request), + 'account' => (new AccountResource($account->load('organizations')))->resolve($request), + ]); + } + + /** + * POST /v1/auth/logout — revokes the token used for this request. + */ + public function logout(Request $request): JsonResponse + { + /** @var Account $account */ + $account = $request->user(); + $token = $account->currentToken(); + + $token?->revoke(); + Event::record('account.logout', $account, $account, ['token' => $token?->public_id], $request->ip()); + + return response()->json(['message' => 'Logged out.']); + } +} diff --git a/app/Http/Controllers/Catalog/CatalogController.php b/app/Http/Controllers/Catalog/CatalogController.php new file mode 100644 index 0000000..a32006b --- /dev/null +++ b/app/Http/Controllers/Catalog/CatalogController.php @@ -0,0 +1,157 @@ +cached($request, function () use ($request) { + $perPage = min(max((int) $request->input('limit', $request->input('per_page', 24)), 1), self::MAX_PER_PAGE); + + $filter = new ExtensionFilter($request); + $query = $filter->apply($this->listingQuery()); + $filter->applySort($request->input('sort')); + + $paginator = $query->paginate($perPage)->withQueryString(); + + return ExtensionSummaryResource::collection($paginator)->response($request)->getData(true); + }); + } + + /** + * GET /v1/catalog/extensions/{slug} + */ + public function extension(Request $request, string $slug): JsonResponse + { + return $this->cached($request, function () use ($request, $slug) { + $extension = $this->listingQuery() + ->with(['screenshots', 'publishedReleases']) + ->withCount('publishedReleases') + ->where(fn (Builder $q) => $q->where('slug', $slug)->orWhere('public_id', $slug)) + ->firstOrFail(); + + return (new ExtensionResource($extension))->resolve($request); + }); + } + + /** + * GET /v1/catalog/extensions/{slug}/releases + */ + public function releases(Request $request, string $slug): JsonResponse + { + return $this->cached($request, function () use ($request, $slug) { + $extension = Extension::query()->published() + ->where(fn (Builder $q) => $q->where('slug', $slug)->orWhere('public_id', $slug)) + ->firstOrFail(); + + $releases = $extension->releases() + ->whereIn('status', [Release::STATUS_PUBLISHED, Release::STATUS_YANKED]) + ->when($request->filled('channel'), fn ($q) => $q->where('channel', $request->input('channel'))) + ->orderByDesc('published_at') + ->orderByDesc('id') + ->get(); + + return [ + 'extension' => $extension->slug, + 'latest' => $releases->first(fn (Release $r) => $r->isPublished() && $r->channel === Release::CHANNEL_STABLE)?->version + ?? $releases->first(fn (Release $r) => $r->isPublished())?->version, + 'channels' => $releases->where('status', Release::STATUS_PUBLISHED) + ->groupBy('channel') + ->map(fn ($group) => $group->first()->version) + ->all(), + 'releases' => ReleaseResource::collection($releases)->resolve($request), + ]; + }); + } + + /** + * GET /v1/catalog/publishers/{slug} + */ + public function publisher(Request $request, string $slug): JsonResponse + { + return $this->cached($request, function () use ($request, $slug) { + $organization = Organization::query() + ->where(fn (Builder $q) => $q->where('slug', $slug)->orWhere('public_id', $slug)) + ->with(['extensions' => fn ($q) => $this->listingScopes($q->published())->orderByDesc('published_at')]) + ->withCount(['extensions' => fn ($q) => $q->published()]) + ->firstOrFail(); + + return (new PublisherResource($organization))->resolve($request); + }); + } + + /** + * GET /v1/catalog/categories + */ + public function categories(Request $request): JsonResponse + { + return $this->cached($request, function () use ($request) { + $categories = Category::query() + ->withCount(['extensions' => fn ($q) => $q->published()]) + ->orderBy('sort') + ->orderBy('name') + ->get(); + + return CategoryResource::collection($categories)->resolve($request); + }); + } + + /** + * Base query for published listings with everything the summary resource needs. + */ + protected function listingQuery(): Builder + { + return $this->listingScopes(Extension::query()->published()); + } + + protected function listingScopes(Builder|Relation $query) + { + return $query + ->with(['organization', 'category', 'icon', 'price', 'packages', 'latestRelease']) + ->withCount(['installs' => fn ($q) => $q->where('status', Install::STATUS_INSTALLED)]); + } + + /** + * Serve from cache for 60 seconds. The closure must return array data. Publishing + * flushes the cache (`CatalogCache::flush()`). + */ + protected function cached(Request $request, \Closure $build): JsonResponse + { + $data = Cache::remember(CatalogCache::key($request), self::CACHE_SECONDS, $build); + + return response()->json($data) + ->header('Cache-Control', 'public, max-age='.self::CACHE_SECONDS.', s-maxage='.self::CACHE_SECONDS) + ->header('X-Cache-Ttl', (string) self::CACHE_SECONDS); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +user(); + $status = $request->input('status'); + + $extension = $this->resolveExtension($request->input('extension')); + if (! $extension) { + return response()->error('Extension not found.', 404); + } + + $release = $extension->releases() + ->where(fn ($q) => $request->filled('release') + ? $q->where('public_id', $request->input('release'))->orWhere('uuid', $request->input('release')) + : $q->where('version', ltrim((string) $request->input('version'), 'vV'))) + ->first(); + + if (! $release) { + return response()->error('Release not found for this extension.', 404); + } + + if ($status === Install::STATUS_INSTALLING && ($error = $this->installRefusal($instance, $extension, $release))) { + return response()->error($error, 422); + } + + if (! in_array($release->status, [Release::STATUS_PUBLISHED, Release::STATUS_YANKED], true)) { + return response()->error('Only published releases can be installed.', 422); + } + + $activation = $status === Install::STATUS_FAILED ? null : $activate($instance, $extension, $release); + + [$install, $created] = DB::transaction(function () use ($instance, $extension, $release, $status, $request) { + $open = Install::query() + ->where('instance_uuid', $instance->uuid) + ->where('extension_uuid', $extension->uuid) + ->where('status', Install::STATUS_INSTALLING) + ->orderByDesc('id') + ->first(); + + if ($status === Install::STATUS_INSTALLING && $open) { + $open->forceFill(['status' => Install::STATUS_FAILED, 'error' => 'Superseded by a newer install attempt.', 'finished_at' => now()])->save(); + $open = null; + } + + $install = $open ?? new Install([ + 'instance_uuid' => $instance->uuid, + 'extension_uuid' => $extension->uuid, + 'started_at' => now(), + ]); + $created = ! $install->exists; + + $install->fill([ + 'release_uuid' => $release->uuid, + 'status' => $status, + 'installed_by' => $request->input('installed_by', $install->installed_by), + 'error' => $status === Install::STATUS_FAILED ? $request->input('error') : null, + 'finished_at' => $status === Install::STATUS_INSTALLING ? null : now(), + ])->save(); + + if ($status === Install::STATUS_INSTALLED) { + Install::query() + ->where('instance_uuid', $instance->uuid) + ->where('extension_uuid', $extension->uuid) + ->where('status', Install::STATUS_INSTALLED) + ->whereKeyNot($install->getKey()) + ->update(['status' => Install::STATUS_UNINSTALLED, 'finished_at' => now(), 'updated_at' => now()]); + } + + return [$install, $created]; + }); + + if ($status === Install::STATUS_FAILED && ! $this->hasInstalled($instance, $extension)) { + $deactivate($instance, $extension); + } + + Event::record('install.'.$status, $install, $instance, ['extension' => $extension->public_id, 'version' => $release->version], $request->ip()); + + return response()->json([ + 'install' => (new InstallResource($install->load(['extension', 'release'])))->resolve($request), + 'activation' => $activation ? (new LicenceActivationResource($activation->load('release')))->resolve($request) : null, + ], $created ? 201 : 200); + } + + /** + * DELETE /v1/instances/me/installs/{extension} — report an uninstall and release + * the instance's licence seat. + */ + public function destroy(Request $request, string $extension, DeactivateLicence $deactivate): JsonResponse + { + /** @var Instance $instance */ + $instance = $request->user(); + + $model = $this->resolveExtension($extension); + if (! $model) { + return response()->error('Extension not found.', 404); + } + + $installs = Install::query() + ->where('instance_uuid', $instance->uuid) + ->where('extension_uuid', $model->uuid) + ->whereIn('status', [Install::STATUS_INSTALLED, Install::STATUS_INSTALLING]) + ->orderByDesc('id') + ->get(); + + if ($installs->isEmpty()) { + return response()->error('This extension is not installed on this instance.', 404); + } + + Install::query()->whereKey($installs->modelKeys())->update([ + 'status' => Install::STATUS_UNINSTALLED, + 'finished_at' => now(), + 'updated_at' => now(), + ]); + $deactivated = $deactivate($instance, $model); + + $install = $installs->first()->refresh()->load(['extension', 'release']); + Event::record('install.uninstalled', $install, $instance, ['extension' => $model->public_id], $request->ip()); + + return response()->json([ + 'install' => (new InstallResource($install))->resolve($request), + 'deactivated' => $deactivated, + ]); + } + + /** + * Extensions are addressed by slug, public id, uuid, or one of their npm or + * Composer package names (what an instance knows from its installed packages). + */ + protected function resolveExtension(string $identifier): ?Extension + { + $identifier = urldecode($identifier); + + $extension = Extension::query() + ->where(fn ($q) => $q->where('slug', $identifier)->orWhere('public_id', $identifier)) + ->when(Str::isUuid($identifier), fn ($q) => $q->orWhere('uuid', $identifier)) + ->first(); + + if ($extension) { + return $extension; + } + + $package = ExtensionPackage::query()->where('name', $identifier)->first(); + + return $package ? Extension::find($package->extension_uuid) : null; + } + + /** + * Why a new install may not start, or null when it may. + */ + protected function installRefusal(Instance $instance, Extension $extension, Release $release): ?string + { + if ($extension->status !== Extension::STATUS_PUBLISHED) { + return 'This extension is not published.'; + } + + if (! $release->isPublished()) { + return 'Only published releases can be installed.'; + } + + if ($extension->hosting !== Extension::HOSTING_BOTH && $extension->hosting !== $instance->kind) { + return "This extension is only available for {$extension->hosting} instances."; + } + + return null; + } + + protected function hasInstalled(Instance $instance, Extension $extension): bool + { + return Install::query() + ->where('instance_uuid', $instance->uuid) + ->where('extension_uuid', $extension->uuid) + ->where('status', Install::STATUS_INSTALLED) + ->exists(); + } +} diff --git a/app/Http/Controllers/Instance/InstanceController.php b/app/Http/Controllers/Instance/InstanceController.php new file mode 100644 index 0000000..5efa1c6 --- /dev/null +++ b/app/Http/Controllers/Instance/InstanceController.php @@ -0,0 +1,150 @@ +input('code')); + if (! $code) { + return response()->error('The registration code is invalid, expired or already used.', 401); + } + + $organization = Organization::find($code->organization_uuid); + if (! $organization) { + return response()->error('The organization for this registration code no longer exists.', 422); + } + + [$instance, $plain] = DB::transaction(function () use ($request, $code, $organization) { + $instance = Instance::create([ + 'organization_uuid' => $organization->uuid, + 'registered_by_account_uuid' => $code->account_uuid, + 'name' => $request->input('name'), + 'host' => $request->input('host'), + 'kind' => $request->input('kind'), + 'console_version' => $request->input('console_version'), + 'api_version' => $request->input('api_version'), + 'status' => Instance::STATUS_ACTIVE, + 'last_seen_at' => now(), + ]); + + $plain = $instance->issueToken(); + $code->forceFill(['instance_uuid' => $instance->uuid])->save(); + + return [$instance, $plain]; + }); + + Event::record('instance.registered', $instance, $code->account, ['code' => $code->uuid], $request->ip()); + + return response()->json([ + 'token' => $plain, + 'token_type' => 'Bearer', + 'instance' => (new InstanceResource($instance->load('organization')))->resolve($request), + ], 201); + } + + /** + * GET /v1/instances/me — the instance, its organization and the organization's + * licences with seat usage and whether this instance holds a seat. + */ + public function me(Request $request): JsonResponse + { + /** @var Instance $instance */ + $instance = $request->user(); + + $licences = Licence::query() + ->where('organization_uuid', $instance->organization_uuid) + ->with('extension') + ->withCount('activeActivations') + ->addSelect(['activated_here' => LicenceActivation::query() + ->selectRaw('count(*)') + ->whereColumn('licence_activations.licence_uuid', 'licences.uuid') + ->where('licence_activations.instance_uuid', $instance->uuid) + ->whereNull('licence_activations.deactivated_at'), + ]) + ->orderByDesc('created_at') + ->get(); + + return response()->json([ + 'instance' => (new InstanceResource($instance->load('organization')))->resolve($request), + 'licences' => LicenceResource::collection($licences)->resolve($request), + ]); + } + + /** + * GET /v1/instances/me/updates?channel=stable|beta — every extension installed on + * the instance with its installed release and the newest published release whose + * `compat` ranges accept the instance's console and API versions. + */ + public function updates(Request $request): JsonResponse + { + /** @var Instance $instance */ + $instance = $request->user(); + $channels = $request->input('channel') === Release::CHANNEL_BETA + ? [Release::CHANNEL_STABLE, Release::CHANNEL_BETA] + : [Release::CHANNEL_STABLE]; + + $installs = Install::query() + ->where('instance_uuid', $instance->uuid) + ->where('status', Install::STATUS_INSTALLED) + ->with(['extension', 'release']) + ->orderBy('created_at') + ->get() + ->filter(fn (Install $install) => $install->extension !== null); + + $published = Release::query() + ->published() + ->whereIn('extension_uuid', $installs->pluck('extension_uuid')->unique()) + ->whereIn('channel', $channels) + ->get() + ->groupBy('extension_uuid'); + + $data = $installs->map(function (Install $install) use ($instance, $published, $request) { + $compatible = ($published->get($install->extension_uuid) ?? collect()) + ->filter(fn (Release $release) => Compat::releaseIsCompatible($release, $instance->console_version, $instance->api_version)); + $latest = Compat::latest($compatible); + + return [ + 'extension' => (new ExtensionReferenceResource($install->extension))->resolve($request), + 'installed' => $install->release ? (new ReleaseResource($install->release))->resolve($request) : null, + 'latest' => $latest ? (new ReleaseResource($latest))->resolve($request) : null, + 'update_available' => $latest !== null && Semver::isNewer($latest->version, $install->release?->version), + ]; + })->values(); + + return response()->json([ + 'data' => $data, + 'meta' => [ + 'console_version' => $instance->console_version, + 'api_version' => $instance->api_version, + 'channels' => $channels, + 'updates_available' => $data->where('update_available', true)->count(), + ], + ]); + } +} diff --git a/app/Http/Controllers/Packages/ComposerController.php b/app/Http/Controllers/Packages/ComposerController.php new file mode 100644 index 0000000..4030a56 --- /dev/null +++ b/app/Http/Controllers/Packages/ComposerController.php @@ -0,0 +1,59 @@ +metadataResponse($this->metadata->root(), $request, true); + } + + /** + * GET /composer/p2/{vendor}/{package}.json and {package}~dev.json + */ + public function versions(Request $request, string $vendor, string $package): Response + { + $dev = str_ends_with($package, '~dev'); + $name = $vendor.'/'.($dev ? substr($package, 0, -4) : $package); + + $resolved = $this->resolvePackage($request, ExtensionPackage::KIND_COMPOSER, $name); + $document = $this->metadata->versions($resolved, $dev); + + abort_if(! $dev && $document['packages'][$resolved->name] === [], 404, 'Package not found.'); + + return $this->metadataResponse($document, $request, ! $resolved->extension->isPaid()); + } + + /** + * GET /composer/dist/{vendor}/{package}/{version}.tar + */ + public function dist(Request $request, string $vendor, string $package, string $version): Response + { + $resolved = $this->resolvePackage($request, ExtensionPackage::KIND_COMPOSER, $vendor.'/'.$package); + + $archive = $resolved->downloadableRelease($version)?->apiTarball; + abort_if($archive === null, 404, 'Archive not found.'); + + return $this->storage->download($archive, $request, $this->archiveCacheHeaders(! $resolved->extension->isPaid())); + } +} diff --git a/app/Http/Controllers/Packages/NpmController.php b/app/Http/Controllers/Packages/NpmController.php new file mode 100644 index 0000000..2625085 --- /dev/null +++ b/app/Http/Controllers/Packages/NpmController.php @@ -0,0 +1,87 @@ +resolvePackage($request, ExtensionPackage::KIND_NPM, $package); + $document = $this->packument->build($resolved); + + abort_if((array) $document['versions'] === [], 404, 'Package not found.'); + + return $this->metadataResponse($document, $request, ! $resolved->extension->isPaid()); + } + + /** + * GET /npm/{name}/-/{basename}-{version}.tgz + */ + public function tarball(Request $request, string $package, string $file): Response + { + $resolved = $this->resolvePackage($request, ExtensionPackage::KIND_NPM, $package); + + $basename = str_contains($resolved->name, '/') ? substr($resolved->name, strpos($resolved->name, '/') + 1) : $resolved->name; + abort_unless(str_starts_with($file, $basename.'-') && str_ends_with($file, '.tgz'), 404, 'Tarball not found.'); + $version = substr($file, strlen($basename) + 1, -strlen('.tgz')); + + $tarball = $resolved->downloadableRelease($version)?->engineTarball; + abort_if($tarball === null, 404, 'Tarball not found.'); + + return $this->storage->download($tarball, $request, array_merge( + ['Content-Type' => 'application/octet-stream'], + $this->archiveCacheHeaders(! $resolved->extension->isPaid()), + )); + } + + /** + * PUT/POST/DELETE/PATCH under /npm: publish, unpublish, dist-tag, deprecate, adduser. + */ + public function readOnly(): JsonResponse + { + return response()->packageError(self::READ_ONLY_MESSAGE, 405, ['Allow' => 'GET, HEAD']); + } + + /** + * GET /npm/-/ping (`npm ping`) + */ + public function ping(): JsonResponse + { + return response()->json(new \stdClass); + } + + /** + * GET /npm/-/whoami (`npm whoami` with a registry token configured) + */ + public function whoami(Request $request): JsonResponse + { + $account = auth('token')->user(); + if (! $account instanceof Account) { + return response()->packageError('Unauthenticated.', 401); + } + + return response()->json(['username' => $account->username ?? $account->email]); + } +} diff --git a/app/Http/Controllers/Packages/PackageController.php b/app/Http/Controllers/Packages/PackageController.php new file mode 100644 index 0000000..beeca96 --- /dev/null +++ b/app/Http/Controllers/Packages/PackageController.php @@ -0,0 +1,66 @@ +attributes->get(self::PACKAGE_ATTRIBUTE) ?? ExtensionPackage::resolve($kind, $name); + + abort_if($package === null || $package->kind !== $kind, 404, 'Package not found.'); + + return $package; + } + + protected function metadataResponse(array $document, Request $request, bool $public): Response + { + $json = json_encode($document, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + + $response = new Response($json, 200, ['Content-Type' => 'application/json']); + $response->setEtag(sha1($json)); + $this->cacheFor($response, $public, config('registry.package_metadata_cache_seconds', 60)); + $response->isNotModified($request); + + return $response; + } + + /** + * Free packages may sit in shared caches; paid ones are private and revalidated every + * time so a revoked licence takes effect on the next request. + */ + protected function cacheFor(SymfonyResponse $response, bool $public, int $maxAge): SymfonyResponse + { + if ($public) { + $response->setPublic(); + $response->setMaxAge($maxAge); + } else { + $response->setPrivate(); + $response->headers->addCacheControlDirective('no-cache'); + $response->setVary('Authorization', false); + } + + return $response; + } + + protected function archiveCacheHeaders(bool $public): array + { + return $public + ? ['Cache-Control' => 'public, max-age=31536000, immutable'] + : ['Cache-Control' => 'private, no-cache', 'Vary' => 'Authorization']; + } +} diff --git a/app/Http/Controllers/Publisher/ExtensionController.php b/app/Http/Controllers/Publisher/ExtensionController.php new file mode 100644 index 0000000..992e750 --- /dev/null +++ b/app/Http/Controllers/Publisher/ExtensionController.php @@ -0,0 +1,228 @@ +with(self::DETAIL) + ->whereIn('organization_uuid', $this->publishableOrganizations($request)) + ->orderBy('name') + ->get(); + + return response()->json(PublisherExtensionResource::collection($extensions)->resolve($request)); + } + + /** + * GET /v1/publisher/extensions/{slug} + */ + public function show(Request $request, string $slug): PublisherExtensionResource + { + $extension = $this->findExtension($request, $slug, self::DETAIL); + $extension->load(['releases' => fn ($q) => $q->orderByDesc('created_at')->orderByDesc('id')]); + + return new PublisherExtensionResource($extension); + } + + /** + * POST /v1/publisher/extensions — create a listing, idempotent on package names: when the + * names already belong to one of the caller's listings, that listing is returned (200) + * and any package kind it lacks is added. Names owned by another publisher answer 409. + */ + public function store(CreateExtensionRequest $request): JsonResponse + { + $account = $this->account($request); + $packages = $request->packages(); + + $existing = $this->listingOwning($packages); + if ($existing !== null) { + if (! $this->canPublishFor($request, $existing->organization_uuid)) { + throw new ConflictHttpException('These package names are registered to another publisher\'s extension.'); + } + + if ($request->filled('organization')) { + $organization = $this->targetOrganization($request, $request->input('organization')); + if ($organization->uuid !== $existing->organization_uuid) { + throw new ConflictHttpException("These package names belong to {$existing->slug}, which another of your organizations owns."); + } + } + + $this->attachPackages($existing, $existing->organization()->firstOrFail(), $packages); + + return (new PublisherExtensionResource($existing->load(self::DETAIL)))->response()->setStatusCode(200); + } + + $organization = $this->targetOrganization($request, $request->input('organization')); + $slug = $request->input('slug') ?? Str::slug($request->input('name')); + + $bySlug = Extension::withTrashed()->where('slug', $slug)->first(); + if ($bySlug !== null) { + if ($bySlug->trashed() || $bySlug->organization_uuid !== $organization->uuid) { + throw ValidationException::withMessages(['slug' => "The slug {$slug} is taken."]); + } + + // A listing of this organization with the slug and none of these names: claim them. + $this->attachPackages($bySlug, $organization, $packages); + + return (new PublisherExtensionResource($bySlug->load(self::DETAIL)))->response()->setStatusCode(200); + } + + $this->assertClaimable($organization, $packages); + + $extension = DB::transaction(function () use ($request, $account, $organization, $slug, $packages) { + $extension = Extension::create(array_merge($request->listingAttributes(), [ + 'organization_uuid' => $organization->uuid, + 'created_by_account_uuid' => $account->uuid, + 'category_uuid' => $this->categoryUuid($request->input('category')), + 'slug' => $slug, + 'status' => Extension::STATUS_DRAFT, + 'hosting' => $request->input('hosting', Extension::HOSTING_BOTH), + ])); + $extension->price()->create(['model' => Price::MODEL_FREE, 'amount' => 0, 'currency' => 'USD']); + foreach ($packages as $kind => $name) { + $extension->packages()->create(['kind' => $kind, 'name' => $name]); + } + + return $extension; + }); + + Event::record('extension.created', $extension, $account, ['packages' => $packages]); + + return (new PublisherExtensionResource($extension->load(self::DETAIL)))->response()->setStatusCode(201); + } + + /** + * PATCH /v1/publisher/extensions/{slug} — listing copy, links, category, tags, hosting. + * Listing edits never need review. + */ + public function update(UpdateExtensionRequest $request, string $slug): PublisherExtensionResource + { + $extension = $this->findExtension($request, $slug); + $attributes = $request->listingAttributes(); + if ($request->has('category')) { + $attributes['category_uuid'] = $this->categoryUuid($request->input('category')); + } + + $extension->fill($attributes)->save(); + Event::record('extension.updated', $extension, $this->account($request), ['fields' => array_keys($attributes)]); + + if ($extension->status === Extension::STATUS_PUBLISHED) { + CatalogCache::flush(); + } + + return new PublisherExtensionResource($extension->load(self::DETAIL)); + } + + /** + * The one listing that owns any of the names, or null. Names spread over two listings + * answer 409. + */ + protected function listingOwning(array $packages): ?Extension + { + $owners = ExtensionPackage::query() + ->where(function (Builder $q) use ($packages) { + foreach ($packages as $kind => $name) { + $q->orWhere(fn (Builder $q) => $q->where('kind', $kind)->where('name', $name)); + } + }) + ->pluck('extension_uuid') + ->unique(); + + if ($owners->count() > 1) { + throw new ConflictHttpException('These package names belong to different extensions.'); + } + + if ($owners->isEmpty()) { + return null; + } + + $extension = Extension::withTrashed()->find($owners->first()); + if ($extension === null || $extension->trashed()) { + throw new ConflictHttpException('These package names belong to a deleted extension; contact Fleetbase staff.'); + } + + return $extension; + } + + /** + * Add package kinds the listing does not have yet. A different name for a kind the + * listing already has is a rename, which this endpoint does not do. + */ + protected function attachPackages(Extension $extension, Organization $organization, array $packages): void + { + $current = $extension->packages()->pluck('name', 'kind'); + $new = []; + + foreach ($packages as $kind => $name) { + if (! $current->has($kind)) { + $new[$kind] = $name; + } elseif ($current->get($kind) !== $name) { + throw new ConflictHttpException("{$extension->slug} already has the {$kind} package {$current->get($kind)}; package names cannot be changed here."); + } + } + + $this->assertClaimable($organization, $new); + foreach ($new as $kind => $name) { + $extension->packages()->create(['kind' => $kind, 'name' => $name]); + } + } + + protected function assertClaimable(Organization $organization, array $packages): void + { + $errors = []; + foreach ($packages as $kind => $name) { + if ($messages = $this->namespaces->errors($organization, $kind, $name)) { + $errors['packages.'.$kind] = $messages; + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + } + + protected function categoryUuid(?string $identifier): ?string + { + if ($identifier === null || $identifier === '') { + return null; + } + + $category = Category::query() + ->where(fn (Builder $q) => $q->where('slug', $identifier)->orWhere('public_id', $identifier)->orWhere('uuid', $identifier)) + ->first(); + + if ($category === null) { + throw ValidationException::withMessages(['category' => "Unknown category {$identifier}."]); + } + + return $category->uuid; + } +} diff --git a/app/Http/Controllers/Publisher/PublisherController.php b/app/Http/Controllers/Publisher/PublisherController.php new file mode 100644 index 0000000..245346b --- /dev/null +++ b/app/Http/Controllers/Publisher/PublisherController.php @@ -0,0 +1,117 @@ +user(); + } + + /** + * Organizations the caller may publish for, honouring the token's organization pin. + * + * @return list organization uuids + */ + protected function publishableOrganizations(Request $request): array + { + $account = $this->account($request); + $pinned = $account->currentToken()?->organization_uuid; + + return Membership::query() + ->where('account_uuid', $account->uuid) + ->whereIn('role', Membership::PUBLISHING_ROLES) + ->when($pinned, fn (Builder $q) => $q->where('organization_uuid', $pinned)) + ->pluck('organization_uuid') + ->all(); + } + + /** + * The organization a new listing is created in: `organization` from the body, else the + * token's organization, else the caller's personal organization. + */ + protected function targetOrganization(Request $request, ?string $identifier): Organization + { + $account = $this->account($request); + $token = $account->currentToken(); + $identifier ??= $token?->organization_uuid; + + $organization = $identifier === null + ? $account->personalOrganization() + : $account->organizations() + ->where(fn ($q) => $q->where('organizations.uuid', $identifier) + ->orWhere('organizations.public_id', $identifier) + ->orWhere('organizations.slug', $identifier)) + ->first(); + + if (! $organization instanceof Organization) { + throw new UnprocessableEntityHttpException('You are not a member of that organization.'); + } + + if ($token?->organization_uuid && $token->organization_uuid !== $organization->uuid) { + throw new AccessDeniedHttpException('This token is limited to another organization.'); + } + + if (! in_array($account->roleIn($organization), Membership::PUBLISHING_ROLES, true)) { + throw new AccessDeniedHttpException('Only organization owners, admins and publishers can publish extensions.'); + } + + return $organization; + } + + protected function findExtension(Request $request, string $slug, array $with = []): Extension + { + $extension = Extension::query() + ->with($with) + ->where(fn (Builder $q) => $q->where('slug', $slug)->orWhere('public_id', $slug)) + ->first(); + + if ($extension === null || ! $this->canPublishFor($request, $extension->organization_uuid)) { + throw new NotFoundHttpException('Extension not found.'); + } + + return $extension; + } + + protected function findRelease(Request $request, string $id): Release + { + $release = Release::findById($id, ['extension']); + + if ($release === null || $release->extension === null || ! $this->canPublishFor($request, $release->extension->organization_uuid)) { + throw new NotFoundHttpException('Release not found.'); + } + + return $release; + } + + protected function canPublishFor(Request $request, string $organizationUuid): bool + { + return in_array($organizationUuid, $this->publishableOrganizations($request), true); + } + + /** + * Relations the release detail resource reads. + */ + public static function releaseDetail(Release $release): Release + { + return $release->load(['extension.organization', 'engineTarball', 'apiTarball', 'engineDist', 'checks', 'reviews.reviewer']); + } +} diff --git a/app/Http/Controllers/Publisher/ReleaseController.php b/app/Http/Controllers/Publisher/ReleaseController.php new file mode 100644 index 0000000..13318be --- /dev/null +++ b/app/Http/Controllers/Publisher/ReleaseController.php @@ -0,0 +1,99 @@ +findExtension($request, $slug); + $releases = $extension->releases() + ->with(['extension.organization', 'engineTarball', 'apiTarball', 'engineDist', 'checks']) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->get(); + + return response()->json(ReleaseDetailResource::collection($releases)->resolve($request)); + } + + /** + * POST /v1/publisher/extensions/{slug}/releases — multipart `manifest`, `engine`, `api`, + * optional `dist`, `channel`, `notes`, `submit`. Stores the files and queues the checks; + * returns the release (201) with whatever checks have already run. + */ + public function store(UploadReleaseRequest $request, string $slug): JsonResponse + { + $extension = $this->findExtension($request, $slug); + if ($extension->status === Extension::STATUS_SUSPENDED) { + throw new AccessDeniedHttpException('This extension is suspended.'); + } + + $release = $this->pipeline->upload( + $extension, + $this->account($request), + $request->manifest(), + $request->channel(), + $request->input('notes'), + $request->uploads(), + $request->boolean('submit'), + ); + + return (new ReleaseDetailResource(self::releaseDetail($release->fresh())))->response()->setStatusCode(201); + } + + /** + * GET /v1/publisher/releases/{id} — checks, submission state, review status and reasons. + */ + public function show(Request $request, string $release): ReleaseDetailResource + { + return new ReleaseDetailResource(self::releaseDetail($this->findRelease($request, $release))); + } + + /** + * POST /v1/publisher/releases/{id}/submit — 200 once submitted (or published under D6), + * 202 while checks are still running (submitted when they pass). + */ + public function submit(Request $request, string $release): JsonResponse + { + $release = $this->findRelease($request, $release); + $pending = $this->pipeline->checksPending($release); + $release = $this->pipeline->submit($release, $this->account($request)); + + return (new ReleaseDetailResource(self::releaseDetail($release)))->response()->setStatusCode($pending ? 202 : 200); + } + + /** + * POST /v1/publisher/releases/{id}/withdraw + */ + public function withdraw(Request $request, string $release): ReleaseDetailResource + { + $request->validate(['reason' => ['nullable', 'string', 'max:2000']]); + $release = $this->pipeline->withdraw($this->findRelease($request, $release), $this->account($request), $request->input('reason')); + + return new ReleaseDetailResource(self::releaseDetail($release)); + } + + /** + * POST /v1/publisher/releases/{id}/yank + */ + public function yank(Request $request, string $release): ReleaseDetailResource + { + $request->validate(['reason' => ['nullable', 'string', 'max:2000']]); + $release = $this->pipeline->yank($this->findRelease($request, $release), $this->account($request), $request->input('reason')); + + return new ReleaseDetailResource(self::releaseDetail($release)); + } +} diff --git a/app/Http/Filter/ExtensionFilter.php b/app/Http/Filter/ExtensionFilter.php new file mode 100644 index 0000000..3535349 --- /dev/null +++ b/app/Http/Filter/ExtensionFilter.php @@ -0,0 +1,138 @@ +builder->getConnection()->getDriverName() === 'mysql') { + $this->builder->where(function (Builder $builder) use ($query) { + $builder->whereFullText(['name', 'subtitle', 'description'], $query) + ->orWhere('name', 'like', $query.'%') + ->orWhere('slug', 'like', Str::slug($query).'%'); + }); + + return; + } + + $like = '%'.$query.'%'; + $this->builder->where(function (Builder $builder) use ($like) { + $builder->where('name', 'like', $like) + ->orWhere('subtitle', 'like', $like) + ->orWhere('description', 'like', $like) + ->orWhere('slug', 'like', $like); + }); + } + + /** + * Category by slug or public id; a comma-separated list matches any. + */ + public function category(string $category): void + { + $values = array_filter(array_map('trim', explode(',', $category))); + + $this->builder->whereIn('category_uuid', Category::query() + ->where(fn (Builder $q) => $q->whereIn('slug', $values)->orWhereIn('public_id', $values)) + ->select('uuid')); + } + + /** + * `free` or `paid`. + */ + public function pricing(string $pricing): void + { + $pricing = Str::lower($pricing); + + if ($pricing === 'free') { + $this->builder->where(function (Builder $builder) { + $builder->whereDoesntHave('price') + ->orWhereHas('price', fn (Builder $q) => $q->where('model', Price::MODEL_FREE)->orWhere('amount', '<=', 0)); + }); + } elseif ($pricing === 'paid') { + $this->builder->whereHas('price', fn (Builder $q) => $q->where('model', '!=', Price::MODEL_FREE)->where('amount', '>', 0)); + } + } + + /** + * `cloud`, `self_hosted` or `both`. Extensions marked `both` match either side. + */ + public function hosting(string $hosting): void + { + $hosting = Str::lower($hosting); + + if ($hosting === Extension::HOSTING_BOTH) { + $this->builder->where('hosting', Extension::HOSTING_BOTH); + } elseif (in_array($hosting, Extension::HOSTINGS, true)) { + $this->builder->whereIn('hosting', [$hosting, Extension::HOSTING_BOTH]); + } + } + + /** + * Publisher organization by slug or public id. + */ + public function publisher(string $publisher): void + { + $this->builder->whereHas('organization', fn (Builder $q) => $q->where('slug', $publisher)->orWhere('public_id', $publisher)); + } + + /** + * Any of the listing's tags. + */ + public function tag(string $tag): void + { + $this->builder->whereJsonContains('tags', $tag); + } + + /** + * Applied by the catalog controller because `sort` is skipped by the generic loop. + */ + public function applySort(?string $sort): void + { + $sort = in_array($sort, self::SORTS, true) ? $sort : 'popular'; + + match ($sort) { + 'newest' => $this->builder->orderByDesc('published_at')->orderByDesc('created_at'), + 'updated' => $this->builder->orderByDesc('updated_at'), + 'name' => $this->builder->orderBy('name'), + 'price' => $this->builder->orderBy( + Price::query()->select('amount')->whereColumn('prices.extension_uuid', 'extensions.uuid')->limit(1) + )->orderBy('name'), + default => $this->builder + ->orderByDesc( + Install::query()->selectRaw('count(*)') + ->whereColumn('installs.extension_uuid', 'extensions.uuid') + ->where('installs.status', Install::STATUS_INSTALLED) + ) + ->orderByDesc('published_at'), + }; + } + + /** + * Only published listings on the public API. + */ + public function queryForPublic(): void + { + $this->builder->where('extensions.status', Extension::STATUS_PUBLISHED); + } +} diff --git a/app/Http/Filter/Filter.php b/app/Http/Filter/Filter.php new file mode 100644 index 0000000..f7c877d --- /dev/null +++ b/app/Http/Filter/Filter.php @@ -0,0 +1,107 @@ +Between()`. Ported from `Fleetbase\Http\Filter\Filter`. + */ +abstract class Filter +{ + protected Request $request; + + protected Builder $builder; + + protected array $methodCache = []; + + protected static array $skipParams = [ + 'limit', 'offset', 'page', 'per_page', 'sort', 'order', 'with', 'expand', 'without', 'with_count', 'without_relations', + ]; + + protected static array $rangePatterns = ['after:before', 'from:to', 'min:max', 'start:end', 'gte:lte', 'greater:less']; + + public function __construct(Request $request) + { + $this->request = $request; + } + + public function apply(Builder $builder): Builder + { + $this->builder = $builder; + + $filterParams = array_diff_key($this->request->all(), array_flip(static::$skipParams)); + + foreach ($filterParams as $name => $value) { + $this->applyFilter((string) $name, $value); + } + + $this->applyRangeFilters(); + + if (Http::isInternalRequest($this->request) && method_exists($this, 'queryForInternal')) { + $this->queryForInternal(); + } + + if (Http::isPublicRequest($this->request) && method_exists($this, 'queryForPublic')) { + $this->queryForPublic(); + } + + return $this->builder; + } + + private function applyFilter(string $name, $value): void + { + if ($value === null || $value === '' || $value === []) { + return; + } + + if (! array_key_exists($name, $this->methodCache)) { + $this->methodCache[$name] = null; + foreach ([$name, Str::camel($name)] as $methodName) { + if (method_exists($this, $methodName) && ! in_array($methodName, ['apply', 'applyFilter', 'applyRangeFilters'], true)) { + $this->methodCache[$name] = $methodName; + break; + } + } + } + + if ($this->methodCache[$name]) { + $this->{$this->methodCache[$name]}($value); + } + } + + private function applyRangeFilters(): void + { + $prepositions = Arr::flatten(array_map(fn ($range) => explode(':', $range), static::$rangePatterns)); + + $callbacks = collect($this->request->all()) + ->keys() + ->filter(fn ($param) => Str::endsWith($param, array_map(fn ($p) => '_'.$p, $prepositions))) + ->mapWithKeys(function ($param) { + $preposition = Arr::last(explode('_', $param)); + $column = Str::beforeLast($param, '_'.$preposition); + + if ($column === '') { + return []; + } + + $range = Arr::first(static::$rangePatterns, fn ($range) => in_array($preposition, explode(':', $range), true)); + $values = $this->request->all(array_map(fn ($p) => $column.'_'.$p, explode(':', $range))); + + return [Str::camel($column).'Between' => array_values($values)]; + }) + ->toArray(); + + foreach ($callbacks as $method => $values) { + if (method_exists($this, $method)) { + $this->{$method}(...$values); + } + } + } +} diff --git a/app/Http/Middleware/EnsurePackageLicence.php b/app/Http/Middleware/EnsurePackageLicence.php new file mode 100644 index 0000000..66fc5bf --- /dev/null +++ b/app/Http/Middleware/EnsurePackageLicence.php @@ -0,0 +1,64 @@ +packageName($request, $kind); + $package = $name !== null ? ExtensionPackage::resolve($kind, $name) : null; + + if ($package === null) { + return $next($request); + } + + $request->attributes->set(PackageController::PACKAGE_ATTRIBUTE, $package); + + if (! $package->extension->isPaid()) { + return $next($request); + } + + $decision = $this->gate->check($request, $package->extension); + if ($decision['status'] === LicenceGate::STATUS_ALLOWED) { + return $next($request); + } + + $headers = ['Cache-Control' => 'private, no-store']; + if ($decision['status'] === LicenceGate::STATUS_UNAUTHENTICATED) { + $headers['WWW-Authenticate'] = 'Bearer realm="Fleetbase Registry"'; + } + + return response()->packageError($decision['message'], $decision['status'], $headers); + } + + protected function packageName(Request $request, string $kind): ?string + { + $route = $request->route(); + + return match ($kind) { + ExtensionPackage::KIND_NPM => $route?->parameter('package'), + ExtensionPackage::KIND_COMPOSER => $route?->parameter('vendor') && $route?->parameter('package') + ? $route->parameter('vendor').'/'.preg_replace('/~dev$/', '', $route->parameter('package')) + : null, + default => throw new \InvalidArgumentException("Unknown package kind [{$kind}]."), + }; + } +} diff --git a/app/Http/Middleware/EnsureStaff.php b/app/Http/Middleware/EnsureStaff.php new file mode 100644 index 0000000..718b61d --- /dev/null +++ b/app/Http/Middleware/EnsureStaff.php @@ -0,0 +1,25 @@ +user(); + + if (! $account instanceof Account || ! $account->isStaff()) { + return response()->error('Only Fleetbase staff can do this.', 403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureTokenScope.php b/app/Http/Middleware/EnsureTokenScope.php new file mode 100644 index 0000000..373e08c --- /dev/null +++ b/app/Http/Middleware/EnsureTokenScope.php @@ -0,0 +1,31 @@ +user(); + + if (! $account instanceof Account || ! $account->currentToken()) { + return response()->error('Unauthenticated.', 401); + } + + foreach ($scopes as $scope) { + if (! $account->tokenCan($scope)) { + return response()->error("This token does not have the '{$scope}' scope.", 403); + } + } + + return $next($request); + } +} diff --git a/app/Http/Requests/Account/CreateInstanceCodeRequest.php b/app/Http/Requests/Account/CreateInstanceCodeRequest.php new file mode 100644 index 0000000..87b84c9 --- /dev/null +++ b/app/Http/Requests/Account/CreateInstanceCodeRequest.php @@ -0,0 +1,20 @@ + ['nullable', 'string', 'max:191'], + ]; + } +} diff --git a/app/Http/Requests/Account/CreateTokenRequest.php b/app/Http/Requests/Account/CreateTokenRequest.php new file mode 100644 index 0000000..164cb1a --- /dev/null +++ b/app/Http/Requests/Account/CreateTokenRequest.php @@ -0,0 +1,26 @@ + ['required', 'string', 'max:80'], + 'scopes' => ['required', 'array', 'min:1'], + 'scopes.*' => ['string', Rule::in(Token::SCOPES)], + 'expires_in_days' => ['nullable', 'integer', 'min:1', 'max:'.Token::MAX_LIFETIME_DAYS], + 'organization' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Http/Requests/Admin/ReviewDecisionRequest.php b/app/Http/Requests/Admin/ReviewDecisionRequest.php new file mode 100644 index 0000000..1f29904 --- /dev/null +++ b/app/Http/Requests/Admin/ReviewDecisionRequest.php @@ -0,0 +1,43 @@ +routeIs('v1.admin.releases.reject'); + + return [ + 'reason' => [$rejecting ? 'required' : 'nullable', 'string', 'min:3', 'max:5000'], + 'items' => [$rejecting ? 'required' : 'nullable', 'array', $rejecting ? 'min:1' : 'min:0', 'max:50'], + 'items.*' => ['array:code,passed,note'], + 'items.*.code' => ['required', 'string', 'max:32'], + 'items.*.passed' => ['required', 'boolean'], + 'items.*.note' => ['nullable', 'string', 'max:2000'], + 'publish' => ['nullable', 'boolean'], + ]; + } + + public function items(): ?array + { + $items = $this->validated('items'); + + return $items === null ? null : array_map(fn (array $item) => [ + 'code' => $item['code'], + 'passed' => (bool) $item['passed'], + 'note' => $item['note'] ?? null, + ], array_values($items)); + } +} diff --git a/app/Http/Requests/Admin/SuspendExtensionRequest.php b/app/Http/Requests/Admin/SuspendExtensionRequest.php new file mode 100644 index 0000000..fa549b9 --- /dev/null +++ b/app/Http/Requests/Admin/SuspendExtensionRequest.php @@ -0,0 +1,18 @@ + ['required', 'string', 'min:3', 'max:5000']]; + } +} diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php new file mode 100644 index 0000000..c5624db --- /dev/null +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -0,0 +1,27 @@ + ['required', 'string', 'email:rfc'], + 'password' => ['required', 'string'], + 'token_name' => ['nullable', 'string', 'max:80'], + 'scopes' => ['nullable', 'array'], + 'scopes.*' => ['string', Rule::in(Token::SCOPES)], + 'expires_in_days' => ['nullable', 'integer', 'min:1', 'max:'.Token::MAX_LIFETIME_DAYS], + ]; + } +} diff --git a/app/Http/Requests/Auth/RegisterRequest.php b/app/Http/Requests/Auth/RegisterRequest.php new file mode 100644 index 0000000..71b0432 --- /dev/null +++ b/app/Http/Requests/Auth/RegisterRequest.php @@ -0,0 +1,32 @@ + ['required', 'string', 'max:120'], + 'email' => ['required', 'string', 'email:rfc', 'max:191', 'unique:accounts,email'], + 'username' => ['nullable', 'string', 'min:3', 'max:40', 'regex:/^[a-z0-9][a-z0-9-]*$/', 'unique:accounts,username', 'unique:organizations,slug'], + 'password' => ['required', 'string', Password::min(8)->letters()->numbers(), 'max:128'], + 'organization_name' => ['nullable', 'string', 'max:120'], + ]; + } + + public function messages(): array + { + return [ + 'username.regex' => 'The username may only contain lowercase letters, numbers and dashes.', + ]; + } +} diff --git a/app/Http/Requests/Auth/VerifyEmailRequest.php b/app/Http/Requests/Auth/VerifyEmailRequest.php new file mode 100644 index 0000000..1fdda9d --- /dev/null +++ b/app/Http/Requests/Auth/VerifyEmailRequest.php @@ -0,0 +1,21 @@ + ['required', 'string', 'email:rfc'], + 'code' => ['required', 'string', 'digits:6'], + ]; + } +} diff --git a/app/Http/Requests/Instance/RegisterInstanceRequest.php b/app/Http/Requests/Instance/RegisterInstanceRequest.php new file mode 100644 index 0000000..54ac6b5 --- /dev/null +++ b/app/Http/Requests/Instance/RegisterInstanceRequest.php @@ -0,0 +1,27 @@ + ['required', 'string', 'max:64'], + 'name' => ['required', 'string', 'max:191'], + 'host' => ['nullable', 'string', 'max:255'], + 'kind' => ['required', 'string', Rule::in(Instance::KINDS)], + 'console_version' => ['nullable', 'string', 'max:64'], + 'api_version' => ['nullable', 'string', 'max:64'], + ]; + } +} diff --git a/app/Http/Requests/Instance/ReportInstallRequest.php b/app/Http/Requests/Instance/ReportInstallRequest.php new file mode 100644 index 0000000..78bfd0d --- /dev/null +++ b/app/Http/Requests/Instance/ReportInstallRequest.php @@ -0,0 +1,29 @@ + ['required', 'string', 'max:191'], + 'version' => ['required_without:release', 'nullable', 'string', 'max:64'], + 'release' => ['required_without:version', 'nullable', 'string', 'max:191'], + 'status' => ['required', 'string', Rule::in(self::STATUSES)], + 'error' => ['nullable', 'string', 'max:10000'], + 'installed_by' => ['nullable', 'string', 'max:191'], + ]; + } +} diff --git a/app/Http/Requests/Publisher/CreateExtensionRequest.php b/app/Http/Requests/Publisher/CreateExtensionRequest.php new file mode 100644 index 0000000..b35a82d --- /dev/null +++ b/app/Http/Requests/Publisher/CreateExtensionRequest.php @@ -0,0 +1,50 @@ +input('packages'); + if (is_array($packages)) { + $this->merge(['packages' => array_map(fn ($name) => is_string($name) ? strtolower(trim($name)) : $name, $packages)]); + } + } + + public function rules(): array + { + return array_merge([ + 'name' => ['required', 'string', 'min:2', 'max:120'], + 'slug' => ['nullable', 'string', 'min:2', 'max:100', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/'], + 'organization' => ['nullable', 'string', 'max:191'], + 'packages' => ['required', 'array'], + 'packages.npm' => ['nullable', 'required_without:packages.composer', 'string', 'max:214', 'regex:'.self::NPM_NAME], + 'packages.composer' => ['nullable', 'required_without:packages.npm', 'string', 'max:214', 'regex:'.self::COMPOSER_NAME], + ], $this->listingRules()); + } + + /** + * @return array kind => name + */ + public function packages(): array + { + return array_filter([ + 'npm' => $this->validated('packages.npm'), + 'composer' => $this->validated('packages.composer'), + ]); + } +} diff --git a/app/Http/Requests/Publisher/ListingRules.php b/app/Http/Requests/Publisher/ListingRules.php new file mode 100644 index 0000000..b3f642d --- /dev/null +++ b/app/Http/Requests/Publisher/ListingRules.php @@ -0,0 +1,44 @@ + ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string', 'max:20000'], + 'promotional_text' => ['nullable', 'string', 'max:1000'], + 'category' => ['nullable', 'string', 'max:191'], + 'tags' => ['nullable', 'array', 'max:20'], + 'tags.*' => ['string', 'min:1', 'max:64'], + 'languages' => ['nullable', 'array', 'max:50'], + 'languages.*' => ['string', 'max:16'], + 'hosting' => ['nullable', Rule::in(Extension::HOSTINGS)], + 'website_url' => ['nullable', 'url:http,https', 'max:255'], + 'repo_url' => ['nullable', 'url:http,https', 'max:255'], + 'support_url' => ['nullable', 'url:http,https', 'max:255'], + 'privacy_url' => ['nullable', 'url:http,https', 'max:255'], + 'tos_url' => ['nullable', 'url:http,https', 'max:255'], + 'copyright' => ['nullable', 'string', 'max:255'], + 'fa_icon' => ['nullable', 'string', 'max:64', 'regex:/^[a-z0-9-]+$/'], + ]; + } + + /** + * The listing columns present in the request (category resolved by the controller). + */ + public function listingAttributes(): array + { + return collect($this->validated()) + ->only(['name', 'subtitle', 'description', 'promotional_text', 'tags', 'languages', 'hosting', 'website_url', 'repo_url', 'support_url', 'privacy_url', 'tos_url', 'copyright', 'fa_icon']) + ->all(); + } +} diff --git a/app/Http/Requests/Publisher/UpdateExtensionRequest.php b/app/Http/Requests/Publisher/UpdateExtensionRequest.php new file mode 100644 index 0000000..4bf4cd6 --- /dev/null +++ b/app/Http/Requests/Publisher/UpdateExtensionRequest.php @@ -0,0 +1,20 @@ + ['sometimes', 'string', 'min:2', 'max:120']], $this->listingRules()); + } +} diff --git a/app/Http/Requests/Publisher/UploadReleaseRequest.php b/app/Http/Requests/Publisher/UploadReleaseRequest.php new file mode 100644 index 0000000..e61eb7a --- /dev/null +++ b/app/Http/Requests/Publisher/UploadReleaseRequest.php @@ -0,0 +1,93 @@ + ['required', 'file', 'max:256'], + 'engine' => ['nullable', 'required_without:api', 'file', 'max:'.$bundleKb], + 'api' => ['nullable', 'required_without:engine', 'file', 'max:'.$bundleKb], + 'dist' => ['nullable', 'file', 'max:'.$distKb], + 'channel' => ['nullable', Rule::in(Release::CHANNELS)], + 'notes' => ['nullable', 'string', 'max:20000'], + 'submit' => ['nullable', 'boolean'], + ]; + } + + public function after(): array + { + return [function (Validator $validator) { + if ($validator->errors()->has('manifest')) { + return; + } + + $manifest = ExtensionManifest::decode((string) file_get_contents($this->file('manifest')->getRealPath())); + if ($manifest === null) { + $validator->errors()->add('manifest', 'The manifest must be a JSON object (extension.json).'); + + return; + } + + $version = $manifest['version'] ?? null; + if (! is_string($version) || ! Semver::valid($version) || str_starts_with($version, 'v')) { + $validator->errors()->add('manifest', 'The manifest needs a SemVer `version` (for example 1.2.0 or 1.3.0-beta.1).'); + + return; + } + + $channel = $this->input('channel'); + $declared = $manifest['channel'] ?? null; + if ($channel !== null && is_string($declared) && $declared !== $channel) { + $validator->errors()->add('channel', "The channel `{$channel}` does not match the manifest's `{$declared}`."); + } + + $this->decodedManifest = $manifest; + }]; + } + + public function manifest(): array + { + return $this->decodedManifest ?? []; + } + + public function channel(): string + { + $declared = $this->decodedManifest['channel'] ?? null; + + return $this->input('channel') ?? (in_array($declared, Release::CHANNELS, true) ? $declared : Release::CHANNEL_STABLE); + } + + public function uploads(): array + { + return array_filter([ + 'engine' => $this->file('engine'), + 'api' => $this->file('api'), + 'dist' => $this->file('dist'), + ]); + } +} diff --git a/app/Http/Resources/AccountResource.php b/app/Http/Resources/AccountResource.php new file mode 100644 index 0000000..c2114e7 --- /dev/null +++ b/app/Http/Resources/AccountResource.php @@ -0,0 +1,34 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'public_id' => $this->when(Http::isInternalRequest(), $this->public_id), + 'name' => $this->name, + 'username' => $this->username, + 'email' => $this->email, + 'avatar_url' => $this->avatar_url, + 'timezone' => $this->timezone, + 'status' => $this->status, + 'email_verified_at' => $this->email_verified_at, + 'organizations' => $this->whenLoaded('organizations', fn () => $this->organizations->map(fn ($organization) => [ + 'id' => $organization->public_id, + 'slug' => $organization->slug, + 'name' => $organization->name, + 'role' => $organization->pivot->role, + 'is_personal' => $organization->is_personal, + ])->all()), + 'last_login_at' => $this->last_login_at, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/CategoryResource.php b/app/Http/Resources/CategoryResource.php new file mode 100644 index 0000000..c284f8e --- /dev/null +++ b/app/Http/Resources/CategoryResource.php @@ -0,0 +1,23 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'slug' => $this->slug, + 'name' => $this->name, + 'description' => $this->description, + 'icon' => $this->icon, + 'tags' => $this->tags ?? [], + 'sort' => $this->sort, + 'extensions_count' => $this->whenCounted('extensions'), + ]; + } +} diff --git a/app/Http/Resources/ExtensionReferenceResource.php b/app/Http/Resources/ExtensionReferenceResource.php new file mode 100644 index 0000000..4894381 --- /dev/null +++ b/app/Http/Resources/ExtensionReferenceResource.php @@ -0,0 +1,20 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'slug' => $this->slug, + 'name' => $this->name, + ]; + } +} diff --git a/app/Http/Resources/ExtensionResource.php b/app/Http/Resources/ExtensionResource.php new file mode 100644 index 0000000..4b59394 --- /dev/null +++ b/app/Http/Resources/ExtensionResource.php @@ -0,0 +1,38 @@ + $this->description, + 'promotional_text' => $this->promotional_text, + 'languages' => $this->languages ?? [], + 'website_url' => $this->website_url, + 'repo_url' => $this->repo_url, + 'support_url' => $this->support_url, + 'privacy_url' => $this->privacy_url, + 'tos_url' => $this->tos_url, + 'copyright' => $this->copyright, + 'screenshots' => $this->whenLoaded('screenshots', fn () => $this->screenshots->map(fn ($file) => [ + 'id' => $file->public_id, + 'url' => $file->url, + 'name' => $file->name, + 'mime' => $file->mime, + ])->all()), + 'versions' => $this->whenLoaded('publishedReleases', fn () => $this->publishedReleases->map(fn ($release) => [ + 'id' => $release->public_id, + 'version' => $release->version, + 'channel' => $release->channel, + 'published_at' => $release->published_at, + ])->all()), + 'versions_count' => $this->whenCounted('publishedReleases'), + 'publisher' => $this->whenLoaded('organization', fn () => (new PublisherResource($this->organization))->resolve($request)), + ]); + } +} diff --git a/app/Http/Resources/ExtensionSummaryResource.php b/app/Http/Resources/ExtensionSummaryResource.php new file mode 100644 index 0000000..7aac8dc --- /dev/null +++ b/app/Http/Resources/ExtensionSummaryResource.php @@ -0,0 +1,51 @@ +whenLoaded('latestRelease', fn () => $this->latestRelease); + + return [ + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'slug' => $this->slug, + 'name' => $this->name, + 'subtitle' => $this->subtitle, + 'fa_icon' => $this->fa_icon, + 'icon_url' => $this->whenLoaded('icon', fn () => $this->icon?->url, null), + 'tags' => $this->tags ?? [], + 'hosting' => $this->hosting, + 'status' => $this->status, + 'publisher' => $this->whenLoaded('organization', fn () => [ + 'id' => $this->organization->public_id, + 'slug' => $this->organization->slug, + 'name' => $this->organization->name, + 'is_verified' => $this->organization->verified_at !== null, + ]), + 'category' => $this->whenLoaded('category', fn () => $this->category ? [ + 'id' => $this->category->public_id, + 'slug' => $this->category->slug, + 'name' => $this->category->name, + 'icon' => $this->category->icon, + ] : null), + 'latest_release' => $this->whenLoaded('latestRelease', fn () => $this->latestRelease ? (new ReleaseResource($this->latestRelease))->resolve($request) : null), + 'latest_version' => $this->whenLoaded('latestRelease', fn () => $this->latestRelease?->version), + 'compat' => $this->whenLoaded('latestRelease', fn () => $this->latestRelease?->compat ?? []), + 'packages' => $this->whenLoaded('packages', fn () => $this->packages->mapWithKeys(fn ($package) => [$package->kind => $package->name])->all()), + 'price' => (new PriceResource($this->whenLoaded('price', fn () => $this->price, null)))->resolve($request), + 'is_paid' => $this->whenLoaded('price', fn () => $this->price !== null && ! $this->price->isFree(), false), + 'installs_count' => (int) ($this->installs_count ?? 0), + 'published_at' => $this->published_at, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/FleetbaseResource.php b/app/Http/Resources/FleetbaseResource.php new file mode 100644 index 0000000..a3248ee --- /dev/null +++ b/app/Http/Resources/FleetbaseResource.php @@ -0,0 +1,69 @@ +filterExcluded(parent::toArray($request)); + } + + public static function collection($resource) + { + return tap(new FleetbaseResourceCollection($resource, static::class), function ($collection) { + if (property_exists(static::class, 'preserveKeys')) { + $collection->preserveKeys = (new static([]))->preserveKeys === true; + } + }); + } + + public function isEmpty(): bool + { + return is_null($this->resource) || is_null($this->resource->resource ?? $this->resource); + } + + /** + * Every `*_uuid` attribute, included only on internal requests. + */ + public function getInternalIds(): array + { + $internalIds = []; + + foreach ($this->resource?->getAttributes() ?? [] as $key => $value) { + if (Str::endsWith($key, '_uuid')) { + $internalIds[$key] = $this->when(Http::isInternalRequest(), $value); + } + } + + return $internalIds; + } + + public function without(array|string $keys): static + { + $clone = clone $this; + $clone->excluded = array_merge($this->excluded, (array) $keys); + + return $clone; + } + + protected function filterExcluded(array $data): array + { + foreach ($this->excluded as $key) { + Arr::forget($data, $key); + } + + return $data; + } +} diff --git a/app/Http/Resources/FleetbaseResourceCollection.php b/app/Http/Resources/FleetbaseResourceCollection.php new file mode 100644 index 0000000..29d2c82 --- /dev/null +++ b/app/Http/Resources/FleetbaseResourceCollection.php @@ -0,0 +1,75 @@ +collects = $collects; + + parent::__construct($resource); + } + + public function without(array|string $keys): static + { + $clone = clone $this; + $clone->excluded = array_values(array_unique(array_merge($this->excluded, (array) $keys))); + + return $clone; + } + + public function toArray($request): array + { + return $this->collection->map(function ($item) use ($request) { + if ($item instanceof FleetbaseResource) { + return $this->applyExclusions($item->without($this->excluded)->resolve($request)); + } + + if ($item instanceof JsonResource) { + return $this->applyExclusions($item->resolve($request)); + } + + if (is_string($this->collects) && class_exists($this->collects)) { + $resource = new $this->collects($item); + + return $resource instanceof FleetbaseResource + ? $resource->without($this->excluded)->resolve($request) + : $this->applyExclusions($resource->resolve($request)); + } + + return $this->applyExclusions(is_array($item) ? $item : (array) $item); + })->all(); + } + + public function toResponse($request) + { + return $this->resource instanceof Paginator + ? (new FleetbasePaginatedResourceResponse($this))->toResponse($request) + : parent::toResponse($request); + } + + protected function applyExclusions(array $array): array + { + foreach ($this->excluded as $key) { + Arr::forget($array, $key); + } + + return $array; + } +} diff --git a/app/Http/Resources/InstallResource.php b/app/Http/Resources/InstallResource.php new file mode 100644 index 0000000..06002ed --- /dev/null +++ b/app/Http/Resources/InstallResource.php @@ -0,0 +1,21 @@ + $this->uuid, + 'extension' => $this->whenLoaded('extension', fn () => (new ExtensionReferenceResource($this->extension))->resolve($request)), + 'release' => $this->whenLoaded('release', fn () => $this->release ? (new ReleaseResource($this->release))->resolve($request) : null), + 'status' => $this->status, + 'installed_by' => $this->installed_by, + 'error' => $this->error, + 'started_at' => $this->started_at, + 'finished_at' => $this->finished_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/InstanceResource.php b/app/Http/Resources/InstanceResource.php new file mode 100644 index 0000000..3842418 --- /dev/null +++ b/app/Http/Resources/InstanceResource.php @@ -0,0 +1,28 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'name' => $this->name, + 'host' => $this->host, + 'kind' => $this->kind, + 'console_version' => $this->console_version, + 'api_version' => $this->api_version, + 'status' => $this->status, + 'organization' => $this->whenLoaded('organization', fn () => (new OrganizationResource($this->organization))->resolve($request)), + 'last_seen_at' => $this->last_seen_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/Json/FleetbasePaginatedResourceResponse.php b/app/Http/Resources/Json/FleetbasePaginatedResourceResponse.php new file mode 100644 index 0000000..23a2100 --- /dev/null +++ b/app/Http/Resources/Json/FleetbasePaginatedResourceResponse.php @@ -0,0 +1,34 @@ +resource->resource->toArray(); + + return ['meta' => $this->meta($paginated)]; + } + + protected function meta($paginated) + { + $metaData = parent::meta($paginated); + $startedAt = defined('LARAVEL_START') ? LARAVEL_START : microtime(true); + + return [ + 'total' => $metaData['total'] ?? null, + 'per_page' => $metaData['per_page'] ?? null, + 'current_page' => $metaData['current_page'] ?? null, + 'last_page' => $metaData['last_page'] ?? null, + 'from' => $metaData['from'] ?? null, + 'to' => $metaData['to'] ?? null, + 'time' => (int) round((microtime(true) - $startedAt) * 1000), + ]; + } +} diff --git a/app/Http/Resources/LicenceActivationResource.php b/app/Http/Resources/LicenceActivationResource.php new file mode 100644 index 0000000..23edfce --- /dev/null +++ b/app/Http/Resources/LicenceActivationResource.php @@ -0,0 +1,17 @@ + $this->uuid, + 'licence' => $this->whenLoaded('licence', fn () => (new LicenceResource($this->licence))->resolve($request)), + 'release' => $this->whenLoaded('release', fn () => $this->release?->version), + 'activated_at' => $this->activated_at, + 'deactivated_at' => $this->deactivated_at, + ]; + } +} diff --git a/app/Http/Resources/LicenceResource.php b/app/Http/Resources/LicenceResource.php new file mode 100644 index 0000000..da05a19 --- /dev/null +++ b/app/Http/Resources/LicenceResource.php @@ -0,0 +1,28 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'extension' => $this->whenLoaded('extension', fn () => $this->extension ? (new ExtensionReferenceResource($this->extension))->resolve($request) : null), + 'kind' => $this->kind, + 'status' => $this->status, + 'max_activations' => $this->max_activations, + 'seats_used' => $this->whenCounted('activeActivations', fn () => (int) $this->active_activations_count), + 'activated_here' => $this->when(array_key_exists('activated_here', $this->resource->getAttributes()), fn () => (bool) $this->resource->getAttribute('activated_here')), + 'expires_at' => $this->expires_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/OrganizationResource.php b/app/Http/Resources/OrganizationResource.php new file mode 100644 index 0000000..e84fbb4 --- /dev/null +++ b/app/Http/Resources/OrganizationResource.php @@ -0,0 +1,27 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'slug' => $this->slug, + 'name' => $this->name, + 'website' => $this->website, + 'bio' => $this->bio, + 'avatar_url' => $this->avatar_url, + 'is_personal' => $this->is_personal, + 'is_verified' => $this->verified_at !== null, + 'verified_at' => $this->verified_at, + 'extensions_count' => $this->whenCounted('extensions'), + 'extensions' => $this->whenLoaded('extensions', fn () => ExtensionSummaryResource::collection($this->extensions)->resolve($request)), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/PriceResource.php b/app/Http/Resources/PriceResource.php new file mode 100644 index 0000000..59652c8 --- /dev/null +++ b/app/Http/Resources/PriceResource.php @@ -0,0 +1,22 @@ +resource === null) { + return ['model' => 'free', 'amount' => 0, 'currency' => 'USD', 'on_sale' => false, 'sale_amount' => null, 'effective_amount' => 0]; + } + + return [ + 'model' => $this->model, + 'amount' => (int) $this->amount, + 'currency' => $this->currency, + 'on_sale' => (bool) $this->on_sale, + 'sale_amount' => $this->sale_amount !== null ? (int) $this->sale_amount : null, + 'effective_amount' => $this->effective_amount, + ]; + } +} diff --git a/app/Http/Resources/PublisherExtensionResource.php b/app/Http/Resources/PublisherExtensionResource.php new file mode 100644 index 0000000..fa90be3 --- /dev/null +++ b/app/Http/Resources/PublisherExtensionResource.php @@ -0,0 +1,55 @@ +errors($this->resource); + + return [ + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'slug' => $this->slug, + 'name' => $this->name, + 'subtitle' => $this->subtitle, + 'description' => $this->description, + 'promotional_text' => $this->promotional_text, + 'status' => $this->status, + 'hosting' => $this->hosting, + 'organization' => $this->whenLoaded('organization', fn () => ['id' => $this->organization->public_id, 'slug' => $this->organization->slug, 'name' => $this->organization->name]), + 'category' => $this->whenLoaded('category', fn () => $this->category ? ['id' => $this->category->public_id, 'slug' => $this->category->slug, 'name' => $this->category->name] : null), + 'tags' => $this->tags ?? [], + 'languages' => $this->languages ?? [], + 'website_url' => $this->website_url, + 'repo_url' => $this->repo_url, + 'support_url' => $this->support_url, + 'privacy_url' => $this->privacy_url, + 'tos_url' => $this->tos_url, + 'copyright' => $this->copyright, + 'fa_icon' => $this->fa_icon, + 'packages' => $this->whenLoaded('packages', fn () => $this->packages->map(fn ($package) => ['kind' => $package->kind, 'name' => $package->name])->values()->all()), + 'price' => $this->whenLoaded('price', fn () => (new PriceResource($this->price))->resolve($request)), + 'readiness' => ['ready' => $missing === [], 'missing' => $missing], + 'releases' => $this->whenLoaded('releases', fn () => $this->releases->map(fn ($release) => [ + 'id' => $release->public_id, + 'version' => $release->version, + 'channel' => $release->channel, + 'status' => $release->status, + 'published_at' => $release->published_at, + 'created_at' => $release->created_at, + ])->values()->all()), + 'suspension' => $this->meta['suspension'] ?? null, + 'published_at' => $this->published_at, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/PublisherResource.php b/app/Http/Resources/PublisherResource.php new file mode 100644 index 0000000..79dac43 --- /dev/null +++ b/app/Http/Resources/PublisherResource.php @@ -0,0 +1,25 @@ + $this->public_id, + 'slug' => $this->slug, + 'name' => $this->name, + 'website' => $this->website, + 'bio' => $this->bio, + 'avatar_url' => $this->avatar_url, + 'is_verified' => $this->verified_at !== null, + 'extensions_count' => $this->whenCounted('extensions'), + 'extensions' => $this->whenLoaded('extensions', fn () => ExtensionSummaryResource::collection($this->extensions)->resolve($request)), + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/ReleaseCheckResource.php b/app/Http/Resources/ReleaseCheckResource.php new file mode 100644 index 0000000..687b001 --- /dev/null +++ b/app/Http/Resources/ReleaseCheckResource.php @@ -0,0 +1,22 @@ +output ?? []; + + return [ + 'check' => $this->check, + 'passed' => (bool) $this->passed, + 'skipped' => (bool) ($output['skipped'] ?? false), + 'summary' => $output['summary'] ?? null, + 'errors' => $output['errors'] ?? [], + 'warnings' => $output['warnings'] ?? [], + 'output' => $output, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/ReleaseDetailResource.php b/app/Http/Resources/ReleaseDetailResource.php new file mode 100644 index 0000000..308f864 --- /dev/null +++ b/app/Http/Resources/ReleaseDetailResource.php @@ -0,0 +1,71 @@ + $file === null ? null : [ + 'id' => $file->public_id, + 'name' => $file->name, + 'original_filename' => $file->original_filename, + 'size' => (int) $file->size, + 'sha256' => $file->checksum, + 'integrity' => $file->integrity, + ]; + + $checks = $this->relationLoaded('checks') ? $this->checks : null; + + return [ + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'extension' => $this->whenLoaded('extension', fn () => [ + 'id' => $this->extension->public_id, + 'slug' => $this->extension->slug, + 'name' => $this->extension->name, + 'status' => $this->extension->status, + 'organization' => $this->extension->relationLoaded('organization') && $this->extension->organization + ? ['id' => $this->extension->organization->public_id, 'slug' => $this->extension->organization->slug, 'name' => $this->extension->organization->name] + : null, + ]), + 'version' => $this->version, + 'channel' => $this->channel, + 'status' => $this->status, + 'compat' => $this->compat ?? [], + 'notes' => $this->notes, + 'size_bytes' => (int) $this->size_bytes, + 'checksum' => $this->checksum, + 'files' => [ + 'engine' => $file($this->engineTarball), + 'api' => $file($this->apiTarball), + 'dist' => $file($this->engineDist), + ], + 'checks_passed' => $checks === null || $this->metaValue('checks_completed_at') === null ? null : $this->checksPassed(), + 'checks' => $checks === null ? [] : ReleaseCheckResource::collection($checks->sortBy(fn ($check) => array_search($check->check, ReleaseCheck::CHECKS, true))->values())->resolve($request), + 'submission' => [ + 'requested' => (bool) $this->metaValue('submission.requested', false), + 'submitted_at' => $this->metaValue('submission.submitted_at'), + 'errors' => $this->metaValue('submission.errors', []), + ], + 'review' => [ + 'required' => $this->metaValue('review_policy.required'), + 'reasons' => (object) $this->metaValue('review_policy.reasons', []), + ], + 'dist' => $this->metaValue('dist'), + 'reviews' => $this->whenLoaded('reviews', fn () => ReviewResource::collection($this->reviews)->resolve($request), []), + 'manifest' => $this->manifest ?? [], + 'published_at' => $this->published_at, + 'yanked_at' => $this->yanked_at, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Http/Resources/ReleaseResource.php b/app/Http/Resources/ReleaseResource.php new file mode 100644 index 0000000..82d6aed --- /dev/null +++ b/app/Http/Resources/ReleaseResource.php @@ -0,0 +1,26 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'version' => $this->version, + 'channel' => $this->channel, + 'status' => $this->status, + 'compat' => $this->compat ?? [], + 'size_bytes' => (int) $this->size_bytes, + 'checksum' => $this->checksum, + 'notes' => $this->notes, + 'published_at' => $this->published_at, + 'yanked_at' => $this->yanked_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/ReviewResource.php b/app/Http/Resources/ReviewResource.php new file mode 100644 index 0000000..c5e6595 --- /dev/null +++ b/app/Http/Resources/ReviewResource.php @@ -0,0 +1,23 @@ + $this->action, + 'from_status' => $this->from_status, + 'to_status' => $this->to_status, + 'actor' => $this->actor, + 'by' => $this->whenLoaded('reviewer', fn () => $this->reviewer ? ['id' => $this->reviewer->public_id, 'name' => $this->reviewer->name] : null), + 'reason' => $this->reason, + 'items' => $this->items ?? [], + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/TokenResource.php b/app/Http/Resources/TokenResource.php new file mode 100644 index 0000000..bb95a78 --- /dev/null +++ b/app/Http/Resources/TokenResource.php @@ -0,0 +1,30 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'uuid' => $this->when(Http::isInternalRequest(), $this->uuid), + 'name' => $this->name, + 'masked' => $this->masked, + 'prefix' => $this->prefix, + 'last_four' => $this->last_four, + 'scopes' => $this->scopes ?? [], + 'organization' => $this->whenLoaded('organization', fn () => $this->organization?->public_id, null), + 'expires_at' => $this->expires_at, + 'last_used_at' => $this->last_used_at, + 'last_used_ip' => $this->last_used_ip, + 'revoked_at' => $this->revoked_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Import/ImportReport.php b/app/Import/ImportReport.php new file mode 100644 index 0000000..7f12276 --- /dev/null +++ b/app/Import/ImportReport.php @@ -0,0 +1,87 @@ + rows read per source table */ + public array $source = []; + + /** @var array> target table => outcome => count */ + public array $counts = []; + + /** @var array>> anomaly type => items */ + public array $anomalies = []; + + /** @var array anomaly type => human description */ + public const ANOMALIES = [ + 'extension_without_packages' => 'Extensions without package names or bundles', + 'tarball_missing' => 'Bundles whose tarball path is missing (imported as yanked)', + 'duplicate_package_name' => 'Duplicate package names (first listing keeps the name)', + 'user_without_email' => 'Users without emails (not imported)', + 'merged_account' => 'Accounts merged by email across cloud users and developer accounts', + 'missing_reference' => 'Rows that reference a missing company, user, extension or developer account', + 'bundle_without_version' => 'Bundles without a version (not imported)', + 'duplicate_version' => 'Bundles that repeat a version of the same extension (older ones not imported)', + 'slug_renamed' => 'Extension or organization slugs changed to stay unique', + 'unknown_status' => 'Unknown statuses (mapped to a safe default)', + 'subscription' => 'Subscription pricing or purchases (imported as one-time)', + 'install_without_licence' => 'Installs with no usable licence (install imported, no activation)', + 'duplicate_install' => 'Several live installs of one extension by one company (older ones marked uninstalled)', + 'duplicate_purchase' => 'Repeat purchases of one extension by one buyer (each imported with its own licence)', + 'icon_missing' => 'Icons or screenshots whose file could not be retrieved', + 'username_taken' => 'Usernames already taken by another account (left empty)', + 'conflict' => 'Rows that conflict with records created in the service (not imported)', + ]; + + public function __construct(public readonly bool $dryRun = false) {} + + public function count(string $table, string $outcome, int $by = 1): void + { + $this->counts[$table][$outcome] = ($this->counts[$table][$outcome] ?? 0) + $by; + } + + public function anomaly(string $type, array $item): void + { + $this->anomalies[$type][] = $item; + } + + public function anomalyCount(?string $type = null): int + { + if ($type !== null) { + return count($this->anomalies[$type] ?? []); + } + + return array_sum(array_map('count', $this->anomalies)); + } + + public function total(string $table, string $outcome): int + { + return $this->counts[$table][$outcome] ?? 0; + } + + public function toArray(): array + { + return [ + 'dry_run' => $this->dryRun, + 'source' => $this->source, + 'counts' => $this->counts, + 'anomalies' => collect($this->anomalies)->map(fn ($items, $type) => [ + 'description' => self::ANOMALIES[$type] ?? $type, + 'count' => count($items), + 'items' => $items, + ])->all(), + ]; + } +} diff --git a/app/Import/IncompleteStatement.php b/app/Import/IncompleteStatement.php new file mode 100644 index 0000000..d126625 --- /dev/null +++ b/app/Import/IncompleteStatement.php @@ -0,0 +1,8 @@ + */ + protected Collection $extensions; + + /** @var Collection */ + protected Collection $bundles; + + /** @var Collection */ + protected Collection $purchases; + + /** @var Collection */ + protected Collection $installs; + + /** @var Collection */ + protected Collection $registryUsers; + + /** @var Collection */ + protected Collection $developers; + + /** @var Collection */ + protected Collection $companies; + + /** @var Collection */ + protected Collection $users; + + /** @var Collection */ + protected Collection $files; + + /** @var Collection> extension uuid => screenshot file rows */ + protected Collection $screenshots; + + /** @var Collection */ + protected Collection $categories; + + /** @var array source category uuid => category uuid */ + protected array $categoryMap = []; + + /** @var array company uuid => organization */ + protected array $companyOrganizations = []; + + /** @var array developer account uuid => personal organization */ + protected array $developerOrganizations = []; + + /** @var array developer account uuid => account */ + protected array $developerAccounts = []; + + /** @var array user uuid => account */ + protected array $userAccounts = []; + + /** @var array source extension uuid => extension */ + protected array $extensionMap = []; + + /** @var array source bundle uuid => release */ + protected array $releaseMap = []; + + /** @var array company uuid => synthetic instance */ + protected array $instances = []; + + public function __construct( + protected Connection $source, + protected string $sourceDisk, + protected string $targetDisk, + protected bool $dryRun = false, + ) { + $this->report = new ImportReport($dryRun); + } + + public function run(): ImportReport + { + $this->loadSource(); + + DB::beginTransaction(); + + try { + $this->importCategories(); + $this->importDeveloperAccounts(); + $this->importCompanies(); + $this->importCloudUsers(); + $this->importExtensions(); + $this->importReleases(); + $this->importPurchases(); + $this->importInstalls(); + } catch (\Throwable $e) { + DB::rollBack(); + + throw $e; + } + + $this->dryRun ? DB::rollBack() : DB::commit(); + + return $this->report; + } + + // --------------------------------------------------------------------- + // Source + // --------------------------------------------------------------------- + + protected function loadSource(): void + { + $this->extensions = $this->rows('registry_extensions'); + $this->bundles = $this->rows('registry_extension_bundles'); + $this->purchases = $this->rows('registry_extension_purchases'); + $this->installs = $this->rows('registry_extension_installs'); + $this->registryUsers = $this->rows('registry_users'); + $this->developers = $this->rows('registry_developer_accounts'); + + $companyUuids = collect() + ->merge($this->extensions->pluck('company_uuid')) + ->merge($this->extensions->filter(fn ($e) => ($e->publisher_type ?? 'cloud') !== 'developer')->pluck('publisher_uuid')) + ->merge($this->registryUsers->pluck('company_uuid')) + ->merge($this->purchases->pluck('company_uuid')) + ->merge($this->purchases->filter(fn ($p) => $this->purchaserKind($p) === 'company')->pluck('purchaser_uuid')) + ->merge($this->installs->pluck('company_uuid')); + $this->companies = $this->rowsWhereIn('companies', 'uuid', $companyUuids); + + $userUuids = collect() + ->merge($this->registryUsers->pluck('user_uuid')) + ->merge($this->extensions->pluck('created_by_uuid')) + ->merge($this->bundles->pluck('created_by_uuid')); + $this->users = $this->rowsWhereIn('users', 'uuid', $userUuids); + + $this->files = $this->rowsWhereIn('files', 'uuid', collect() + ->merge($this->extensions->pluck('icon_uuid')) + ->merge($this->bundles->pluck('bundle_uuid'))); + + $this->screenshots = $this->hasTable('files') && $this->extensions->isNotEmpty() + ? $this->rowsWhereIn('files', 'subject_uuid', $this->extensions->keys(), false) + ->filter(fn ($f) => ($f->type ?? null) === 'extension_screenshot') + ->groupBy('subject_uuid') + : collect(); + + $this->categories = $this->rowsWhereIn('categories', 'uuid', $this->extensions->pluck('category_uuid')); + } + + protected function hasTable(string $table): bool + { + return $this->source->getSchemaBuilder()->hasTable($table); + } + + /** + * @return Collection + */ + protected function rows(string $table): Collection + { + if (! $this->hasTable($table)) { + $this->report->source[$table] = 0; + + return collect(); + } + + $rows = $this->source->table($table)->orderBy('id')->get()->keyBy('uuid'); + $this->report->source[$table] = $rows->count(); + + return $rows; + } + + /** + * @return Collection + */ + protected function rowsWhereIn(string $table, string $column, Collection $values, bool $keyByUuid = true): Collection + { + $values = $values->filter()->unique()->values(); + if (! $this->hasTable($table) || $values->isEmpty()) { + $this->report->source[$table] = $this->report->source[$table] ?? 0; + + return collect(); + } + + $rows = collect(); + foreach ($values->chunk(500) as $chunk) { + $rows = $rows->merge($this->source->table($table)->whereIn($column, $chunk->all())->get()); + } + + $this->report->source[$table] = ($this->report->source[$table] ?? 0) + $rows->count(); + + return $keyByUuid ? $rows->keyBy('uuid') : $rows; + } + + // --------------------------------------------------------------------- + // Categories + // --------------------------------------------------------------------- + + protected function importCategories(): void + { + foreach ($this->categories as $row) { + $category = Category::withTrashed()->where('source_uuid', $row->uuid)->first() + ?? ($row->internal_id ? Category::withTrashed()->where('internal_id', $row->internal_id)->first() : null) + ?? ($row->slug ? Category::withTrashed()->where('slug', $row->slug)->first() : null); + + if ($category) { + // Seeded categories keep their own names; only the link to the source is recorded. + $this->save($category, ['source_uuid' => $row->uuid], 'categories'); + } else { + $category = $this->save(new Category, [ + 'source_uuid' => $row->uuid, + 'slug' => $this->uniqueSlug(Category::class, $row->slug ?: $row->name ?: 'category'), + 'name' => $row->name ?: 'Uncategorised', + 'internal_id' => $row->internal_id, + 'description' => $row->description ?? null, + 'icon' => $row->icon ?? null, + 'tags' => $this->json($row->tags ?? null), + ], 'categories'); + } + + $this->categoryMap[$row->uuid] = $category->uuid; + } + } + + // --------------------------------------------------------------------- + // Accounts, organizations, memberships + // --------------------------------------------------------------------- + + /** + * `registry_developer_accounts` → account + personal organization (owner). + */ + protected function importDeveloperAccounts(): void + { + foreach ($this->developers as $row) { + if (blank($row->email ?? null)) { + $this->report->anomaly('user_without_email', ['source' => 'registry_developer_accounts', 'uuid' => $row->uuid, 'username' => $row->username ?? null]); + $this->report->count('accounts', ImportReport::SKIPPED); + + continue; + } + + $status = match ($row->status ?? null) { + 'active' => Account::STATUS_ACTIVE, + 'suspended' => Account::STATUS_SUSPENDED, + 'pending_verification' => Account::STATUS_PENDING_VERIFICATION, + default => null, + }; + if ($status === null) { + $this->report->anomaly('unknown_status', ['table' => 'registry_developer_accounts', 'uuid' => $row->uuid, 'status' => $row->status ?? null, 'mapped_to' => Account::STATUS_PENDING_VERIFICATION]); + $status = Account::STATUS_PENDING_VERIFICATION; + } + + $account = $this->importAccount($row->uuid, 'registry_developer_accounts', [ + 'email' => Str::lower(trim($row->email)), + 'name' => $row->name ?? null, + 'username' => $row->username ?? null, + 'avatar_url' => $row->avatar_url ?? null, + 'email_verified_at' => $this->date($row->email_verified_at ?? null), + 'status' => $status, + 'created_at' => $this->date($row->created_at ?? null), + 'deleted_at' => $this->date($row->deleted_at ?? null), + 'meta' => array_filter([ + 'source' => 'registry_developer_accounts', + 'github_username' => $row->github_username ?? null, + ]), + ], $row->password ?? null); + + $this->developerAccounts[$row->uuid] = $account; + + $organization = Organization::withTrashed()->where('source_uuid', $row->uuid)->first() ?? new Organization; + $organization = $this->save($organization, [ + 'source_uuid' => $row->uuid, + 'slug' => $organization->slug ?? $this->uniqueSlug(Organization::class, $row->username ?: Str::before($row->email, '@')), + 'name' => $row->name ?: $row->username, + 'website' => $row->website ?? null, + 'bio' => $row->bio ?? null, + 'avatar_url' => $row->avatar_url ?? null, + 'is_personal' => true, + 'created_at' => $this->date($row->created_at ?? null), + 'meta' => ['source' => 'registry_developer_accounts'], + ], 'organizations'); + + $this->developerOrganizations[$row->uuid] = $organization; + $this->membership($organization, $account, Membership::ROLE_OWNER, $row->uuid); + } + } + + /** + * Every company the registry tables reference → organization. Companies are the + * publishers, buyers and installers of the cloud registry. + */ + protected function importCompanies(): void + { + foreach ($this->companies as $row) { + $organization = Organization::withTrashed()->where('source_uuid', $row->uuid)->first() ?? new Organization; + $name = $row->name ?: 'Company '.($row->public_id ?? Str::limit($row->uuid, 8, '')); + + $this->companyOrganizations[$row->uuid] = $this->save($organization, [ + 'source_uuid' => $row->uuid, + 'slug' => $organization->slug ?? $this->uniqueSlug(Organization::class, $row->slug ?: $name), + 'name' => $name, + 'website' => $row->website_url ?? null, + 'bio' => $row->description ?? null, + 'is_personal' => false, + 'stripe_connect_id' => $row->stripe_connect_id ?? null, + 'created_at' => $this->date($row->created_at ?? null), + 'deleted_at' => $this->date($row->deleted_at ?? null), + 'meta' => array_filter(['source' => 'companies', 'public_id' => $row->public_id ?? null]), + ], 'organizations'); + } + } + + /** + * Cloud `registry_users` (one row per token) + `users` → one account per user and a + * membership in the company's organization: owner for the company owner, else publisher. + */ + protected function importCloudUsers(): void + { + $pairs = $this->registryUsers + ->filter(fn ($r) => ($r->account_type ?? 'cloud') !== 'developer') + ->unique(fn ($r) => ($r->company_uuid ?? '').'|'.($r->user_uuid ?? '')); + + foreach ($pairs as $row) { + $user = $this->users->get($row->user_uuid ?? ''); + $organization = $this->companyOrganizations[$row->company_uuid ?? ''] ?? null; + + if (! $user) { + $this->report->anomaly('missing_reference', ['table' => 'registry_users', 'uuid' => $row->uuid, 'missing' => 'users', 'reference' => $row->user_uuid ?? null]); + $this->report->count('accounts', ImportReport::SKIPPED); + + continue; + } + + if (blank($user->email ?? null)) { + $this->report->anomaly('user_without_email', ['source' => 'users', 'uuid' => $user->uuid, 'name' => $user->name ?? null]); + $this->report->count('accounts', ImportReport::SKIPPED); + + continue; + } + + $account = $this->userAccounts[$user->uuid] ??= $this->importAccount($user->uuid, 'users', [ + 'email' => Str::lower(trim($user->email)), + 'name' => $user->name ?? null, + 'username' => $user->username ?? null, + 'email_verified_at' => $this->date($user->email_verified_at ?? null), + 'status' => Account::STATUS_ACTIVE, + 'timezone' => $user->timezone ?? null, + 'created_at' => $this->date($user->created_at ?? null), + 'meta' => array_filter(['source' => 'users', 'public_id' => $user->public_id ?? null]), + ], $user->password ?? null); + + if (! $organization) { + $this->report->anomaly('missing_reference', ['table' => 'registry_users', 'uuid' => $row->uuid, 'missing' => 'companies', 'reference' => $row->company_uuid ?? null]); + + continue; + } + + $owner = ($this->companies->get($row->company_uuid)->owner_uuid ?? null) === $user->uuid; + $this->membership($organization, $account, $owner ? Membership::ROLE_OWNER : Membership::ROLE_PUBLISHER, $row->uuid); + } + } + + /** + * Find or create the account for a source person, matched on `source_uuid`. A person + * whose email already belongs to another account (created in the service, or imported + * from the other source table) is mapped onto that account without changing it. + * Password hashes are copied verbatim (bcrypt, as core-api). + */ + protected function importAccount(string $sourceUuid, string $sourceTable, array $attributes, ?string $passwordHash): Account + { + $account = Account::withTrashed()->where('source_uuid', $sourceUuid)->first(); + + if (! $account && ($existing = Account::withTrashed()->where('email', $attributes['email'])->first())) { + // Same email as an account created in the service or imported from another + // source: map onto it and leave it untouched. + $this->report->anomaly('merged_account', [ + 'email' => $attributes['email'], 'source' => $sourceTable, 'uuid' => $sourceUuid, + 'into' => $existing->source_uuid === null ? 'service account' : 'imported account', + ]); + $this->report->count('accounts', ImportReport::UNCHANGED); + + return $existing; + } + + $account ??= new Account; + + // Usernames are set once; a taken username is left empty rather than renamed. + $username = $attributes['username'] ?? null; + unset($attributes['username']); + if ($account->exists) { + $username = $account->username; + } elseif ($username !== null && Account::withTrashed()->where('username', $username)->exists()) { + $this->report->anomaly('username_taken', ['username' => $username, 'source' => $sourceTable, 'uuid' => $sourceUuid]); + $username = null; + } + + $account->forceFill(array_merge($attributes, ['source_uuid' => $sourceUuid, 'username' => $username])); + + // Bypass the `hashed` cast: the hash is copied as-is and its cost may exceed BCRYPT_ROUNDS. + $account->setRawAttributes(array_merge($account->getAttributes(), ['password' => $passwordHash ?: null])); + + return $this->persist($account, 'accounts'); + } + + protected function membership(Organization $organization, Account $account, string $role, string $sourceUuid): void + { + $membership = Membership::query() + ->where('organization_uuid', $organization->uuid) + ->where('account_uuid', $account->uuid) + ->first(); + + if ($membership && $membership->source_uuid === null) { + // Created in the service; leave it alone. + $this->report->count('memberships', ImportReport::UNCHANGED); + + return; + } + + $membership ??= new Membership; + $membership->forceFill([ + 'organization_uuid' => $organization->uuid, + 'account_uuid' => $account->uuid, + 'role' => $role, + 'source_uuid' => $sourceUuid, + ]); + + $this->persist($membership, 'memberships'); + } + + // --------------------------------------------------------------------- + // Extensions, prices, packages, icons + // --------------------------------------------------------------------- + + protected function importExtensions(): void + { + $claimed = [ExtensionPackage::KIND_NPM => [], ExtensionPackage::KIND_COMPOSER => []]; + + // Live and published listings first, so they win package names and slugs. + $ordered = $this->extensions->sortBy(fn ($e) => [ + $this->date($e->deleted_at ?? null) ? 1 : 0, + ($e->status ?? null) === 'published' ? 0 : 1, + (string) ($e->created_at ?? ''), + (int) ($e->id ?? 0), + ]); + + foreach ($ordered as $row) { + $organization = $this->publisherOrganization($row); + if (! $organization) { + $this->report->anomaly('missing_reference', ['table' => 'registry_extensions', 'uuid' => $row->uuid, 'name' => $row->name, 'missing' => 'publisher', 'reference' => $row->publisher_uuid ?? $row->company_uuid ?? null]); + $this->report->count('extensions', ImportReport::SKIPPED); + + continue; + } + + $extensionBundles = $this->bundles->where('extension_uuid', $row->uuid); + $packages = $this->packageNames($row, $extensionBundles); + + $status = $this->mapExtensionStatus($row); + if ($extensionBundles->isEmpty() && $packages === []) { + $status = Extension::STATUS_DRAFT; + } + if ($packages === []) { + $this->report->anomaly('extension_without_packages', [ + 'uuid' => $row->uuid, 'name' => $row->name, 'bundles' => $extensionBundles->count(), 'imported_status' => $status, + ]); + } + + if ($this->bool($row->subscription_required ?? false)) { + $this->report->anomaly('subscription', ['table' => 'registry_extensions', 'uuid' => $row->uuid, 'name' => $row->name]); + } + + $extension = Extension::withTrashed()->where('source_uuid', $row->uuid)->first() ?? new Extension; + $slug = $extension->slug; + if ($slug === null) { + $base = Str::slug($row->slug ?: $row->name) ?: 'extension'; + $slug = $this->uniqueSlug(Extension::class, $base); + if ($slug !== $base) { + $this->report->anomaly('slug_renamed', ['table' => 'extensions', 'uuid' => $row->uuid, 'from' => $base, 'to' => $slug]); + } + } + + $extension = $this->save($extension, [ + 'source_uuid' => $row->uuid, + 'organization_uuid' => $organization->uuid, + 'category_uuid' => $this->categoryMap[$row->category_uuid ?? ''] ?? null, + 'created_by_account_uuid' => ($this->userAccounts[$row->created_by_uuid ?? ''] ?? null)?->uuid, + 'slug' => $slug, + 'name' => $row->name, + 'subtitle' => $row->subtitle ?? null, + 'description' => $row->description ?? null, + 'promotional_text' => $row->promotional_text ?? null, + 'tags' => $this->json($row->tags ?? null), + 'languages' => $this->json($row->languages ?? null), + 'hosting' => $this->bool($row->self_managed ?? false) ? Extension::HOSTING_SELF_HOSTED : Extension::HOSTING_BOTH, + 'status' => $status, + 'website_url' => $row->website_url ?? null, + 'repo_url' => $row->repo_url ?? null, + 'support_url' => $row->support_url ?? null, + 'privacy_url' => $row->privacy_policy_url ?? null, + 'tos_url' => $row->tos_url ?? null, + 'copyright' => $row->copyright ?? null, + 'fa_icon' => $row->fa_icon ?? null, + 'published_at' => $this->date($row->published_at ?? null), + 'created_at' => $this->date($row->created_at ?? null), + 'deleted_at' => $this->date($row->deleted_at ?? null), + 'meta' => array_filter([ + 'source' => array_filter([ + 'public_id' => $row->public_id ?? null, + 'status' => $row->status ?? null, + 'core_extension' => $this->bool($row->core_extension ?? false) ?: null, + 'primary_language' => $row->primary_language ?? null, + ]), + ]), + ], 'extensions'); + + $this->extensionMap[$row->uuid] = $extension; + + $this->importPrice($row, $extension); + $this->importPackages($row, $extension, $packages, $claimed); + $this->importIcon($row, $extension); + $this->importScreenshots($row, $extension); + } + } + + protected function publisherOrganization(object $row): ?Organization + { + if (($row->publisher_type ?? 'cloud') === 'developer') { + return $this->developerOrganizations[$row->publisher_uuid ?? ''] ?? null; + } + + return $this->companyOrganizations[($row->publisher_uuid ?? null) ?: ($row->company_uuid ?? '')] ?? null; + } + + /** + * npm and Composer names from the current bundle's package.json / composer.json, then + * from the newest bundle that has them, then from the Phase 0 `package_name` column. + * + * @return array kind => name + */ + protected function packageNames(object $row, Collection $bundles): array + { + $candidates = collect([$bundles->get($row->current_bundle_uuid ?? '')]) + ->merge($bundles->sortByDesc(fn ($b) => [(string) ($b->created_at ?? ''), (int) ($b->id ?? 0)])) + ->filter(); + + $names = []; + foreach ($candidates as $bundle) { + $meta = $this->json($bundle->meta ?? null) ?? []; + $names[ExtensionPackage::KIND_NPM] ??= $this->bundleFile($meta, 'package.json', 'name') ?: null; + $names[ExtensionPackage::KIND_COMPOSER] ??= $this->bundleFile($meta, 'composer.json', 'name') ?: null; + } + + $legacy = trim((string) ($row->package_name ?? '')); + if ($legacy !== '') { + $kind = str_starts_with($legacy, '@') || ! str_contains($legacy, '/') ? ExtensionPackage::KIND_NPM : ExtensionPackage::KIND_COMPOSER; + $names[$kind] ??= $legacy; + } + + return array_filter($names); + } + + protected function mapExtensionStatus(object $row): string + { + $status = match ($row->status ?? null) { + 'pending' => Extension::STATUS_DRAFT, + 'awaiting_review', 'in_review' => Extension::STATUS_IN_REVIEW, + 'approved' => Extension::STATUS_APPROVED, + 'rejected' => Extension::STATUS_REJECTED, + 'published' => Extension::STATUS_PUBLISHED, + 'unpublished' => Extension::STATUS_UNPUBLISHED, + default => null, + }; + + if ($status === null) { + $this->report->anomaly('unknown_status', ['table' => 'registry_extensions', 'uuid' => $row->uuid, 'status' => $row->status ?? null, 'mapped_to' => Extension::STATUS_DRAFT]); + } + + return $status ?? Extension::STATUS_DRAFT; + } + + protected function importPrice(object $row, Extension $extension): void + { + $amount = (int) ($row->price ?? 0); + $paid = $this->bool($row->payment_required ?? false) && $amount > 0; + $price = Price::query()->where('extension_uuid', $extension->uuid)->first() ?? new Price; + + $this->save($price, [ + 'source_uuid' => $row->uuid, + 'extension_uuid' => $extension->uuid, + 'model' => $paid ? Price::MODEL_ONE_TIME : Price::MODEL_FREE, + 'amount' => $paid ? $amount : 0, + 'currency' => Str::upper($row->currency ?? '') ?: 'USD', + 'sale_amount' => ($row->sale_price ?? null) !== null ? (int) $row->sale_price : null, + 'on_sale' => $this->bool($row->on_sale ?? false), + 'stripe_product_id' => $row->stripe_product_id ?? null, + ], 'prices'); + } + + /** + * @param array $packages + * @param array> $claimed + */ + protected function importPackages(object $row, Extension $extension, array $packages, array &$claimed): void + { + foreach ($packages as $kind => $name) { + $owner = $claimed[$kind][$name] ?? null; + $existing = ExtensionPackage::query()->where('kind', $kind)->where('name', $name)->first(); + + if (($owner !== null && $owner !== $extension->uuid) || ($existing && $existing->extension_uuid !== $extension->uuid)) { + $this->report->anomaly('duplicate_package_name', [ + 'kind' => $kind, 'name' => $name, 'extension' => $row->name, 'uuid' => $row->uuid, + 'kept_by' => Extension::withTrashed()->find($owner ?? $existing?->extension_uuid)?->slug, + ]); + $this->report->count('extension_packages', ImportReport::SKIPPED); + + continue; + } + + $claimed[$kind][$name] = $extension->uuid; + + $this->save($existing ?? new ExtensionPackage, [ + 'source_uuid' => $row->uuid, + 'extension_uuid' => $extension->uuid, + 'kind' => $kind, + 'name' => $name, + ], 'extension_packages'); + } + } + + protected function importIcon(object $row, Extension $extension): void + { + if (blank($row->icon_uuid ?? null)) { + return; + } + + $file = $this->copyFile($this->files->get($row->icon_uuid), File::TYPE_ICON, 'icons', $extension); + if (! $file) { + $this->report->anomaly('icon_missing', ['extension' => $row->name, 'uuid' => $row->uuid, 'file' => $row->icon_uuid]); + + return; + } + + if ($extension->icon_file_uuid !== $file->uuid) { + $extension->forceFill(['icon_file_uuid' => $file->uuid])->save(); + } + } + + protected function importScreenshots(object $row, Extension $extension): void + { + foreach ($this->screenshots->get($row->uuid, collect()) as $screenshot) { + if (! $this->copyFile($screenshot, File::TYPE_SCREENSHOT, 'screenshots', $extension)) { + $this->report->anomaly('icon_missing', ['extension' => $row->name, 'uuid' => $row->uuid, 'file' => $screenshot->uuid]); + } + } + } + + // --------------------------------------------------------------------- + // Releases and tarballs + // --------------------------------------------------------------------- + + protected function importReleases(): void + { + foreach ($this->bundles->groupBy('extension_uuid') as $extensionUuid => $bundles) { + $extension = $this->extensionMap[$extensionUuid] ?? null; + $sourceExtension = $this->extensions->get($extensionUuid); + + if (! $extension) { + foreach ($bundles as $bundle) { + if (! $sourceExtension) { + $this->report->anomaly('missing_reference', ['table' => 'registry_extension_bundles', 'uuid' => $bundle->uuid, 'missing' => 'registry_extensions', 'reference' => $extensionUuid]); + } + $this->report->count('releases', ImportReport::SKIPPED); + } + + continue; + } + + // The current bundle, then the newest, keep a version that appears twice. + $ordered = $bundles->sortBy(fn ($b) => [ + $b->uuid === ($sourceExtension->current_bundle_uuid ?? null) ? 0 : 1, + -strtotime((string) ($b->created_at ?? '1970-01-01')), + -(int) ($b->id ?? 0), + ]); + + $seen = []; + foreach ($ordered as $bundle) { + $meta = $this->json($bundle->meta ?? null) ?? []; + $version = ltrim((string) ($this->bundleFile($meta, 'extension.json', 'version') ?: ($bundle->version ?? null) ?: $this->bundleFile($meta, 'package.json', 'version') ?: ''), 'vV'); + + if ($version === '') { + $this->report->anomaly('bundle_without_version', ['extension' => $extension->slug, 'bundle' => $bundle->bundle_id ?? $bundle->uuid]); + $this->report->count('releases', ImportReport::SKIPPED); + + continue; + } + + if (isset($seen[$version])) { + $this->report->anomaly('duplicate_version', ['extension' => $extension->slug, 'version' => $version, 'bundle' => $bundle->bundle_id ?? $bundle->uuid, 'kept' => $seen[$version]]); + $this->report->count('releases', ImportReport::SKIPPED); + + continue; + } + $seen[$version] = $bundle->bundle_id ?? $bundle->uuid; + + $this->importRelease($bundle, $meta, $version, $extension, $sourceExtension); + } + } + } + + protected function importRelease(object $bundle, array $meta, string $version, Extension $extension, object $sourceExtension): void + { + $release = Release::withTrashed()->where('source_uuid', $bundle->uuid)->first(); + + $conflict = Release::withTrashed() + ->where('extension_uuid', $extension->uuid) + ->where('version', $version) + ->when($release, fn ($q) => $q->whereKeyNot($release->getKey())) + ->first(); + if ($conflict) { + $this->report->anomaly('conflict', ['table' => 'releases', 'extension' => $extension->slug, 'version' => $version, 'bundle' => $bundle->bundle_id ?? $bundle->uuid]); + $this->report->count('releases', ImportReport::SKIPPED); + + return; + } + + $release ??= new Release; + $status = $this->mapReleaseStatus($bundle); + $isCurrent = $bundle->uuid === ($sourceExtension->current_bundle_uuid ?? null); + + $publishedAt = null; + if ($status === Release::STATUS_PUBLISHED) { + $publishedAt = ($isCurrent ? $this->date($sourceExtension->published_at ?? null) : null) + ?? $this->date($bundle->updated_at ?? null) + ?? $this->date($bundle->created_at ?? null); + } + + // Save first so the tarball file can point at the release as its subject. + $attributes = [ + 'source_uuid' => $bundle->uuid, + 'extension_uuid' => $extension->uuid, + 'created_by_account_uuid' => ($this->userAccounts[$bundle->created_by_uuid ?? ''] ?? null)?->uuid, + 'version' => $version, + 'channel' => Semver::normalize($version) !== null && str_contains($version, '-') ? Release::CHANNEL_BETA : Release::CHANNEL_STABLE, + 'manifest' => array_merge($this->bundleFile($meta, 'extension.json') ?: [], array_filter(['bundle_number' => $bundle->bundle_number ?? null])), + 'compat' => $this->compat($meta), + 'published_at' => $publishedAt, + 'created_at' => $this->date($bundle->created_at ?? null), + 'deleted_at' => $this->date($bundle->deleted_at ?? null), + ]; + + $tarball = $this->files->get($bundle->bundle_uuid ?? ''); + $reason = match (true) { + blank($bundle->bundle_uuid ?? null) => 'bundle has no file', + $tarball === null => 'file row not found', + blank($tarball->path ?? null) => 'file has no path', + ! $this->sourceExists($tarball->path) => 'object not found on source disk', + default => null, + }; + + $release->forceFill($attributes); + if (! $release->exists) { + $release->forceFill(['status' => $status])->save(); + } + + $file = $reason === null ? $this->copyFile($tarball, File::TYPE_SOURCE_BUNDLE, 'tarballs', $release) : null; + if ($reason === null && ! $file) { + $reason = 'copy failed'; + } + + $releaseMeta = array_filter([ + 'source' => array_filter([ + 'bundle_id' => $bundle->bundle_id ?? null, + 'public_id' => $bundle->public_id ?? null, + 'bundle_number' => $bundle->bundle_number ?? null, + 'status' => $bundle->status ?? null, + ]), + 'package_json' => $this->bundleFile($meta, 'package.json'), + 'composer_json' => $this->bundleFile($meta, 'composer.json'), + 'source_bundle_file_uuid' => $file?->uuid, + 'import_yank_reason' => $reason, + ]); + + if ($reason !== null) { + $this->report->anomaly('tarball_missing', [ + 'extension' => $extension->slug, 'version' => $version, 'bundle' => $bundle->bundle_id ?? $bundle->uuid, + 'path' => $tarball->path ?? null, 'reason' => $reason, + ]); + } + + $release->forceFill([ + 'status' => $reason !== null ? Release::STATUS_YANKED : $status, + 'yanked_at' => $reason !== null ? ($release->yanked_at ?? now()) : null, + 'size_bytes' => $file?->size ?? (int) ($tarball->file_size ?? 0), + 'checksum' => $file?->checksum, + 'meta' => $releaseMeta, + ]); + + $this->releaseMap[$bundle->uuid] = $this->persist($release, 'releases', $release->wasRecentlyCreated); + } + + protected function mapReleaseStatus(object $bundle): string + { + $status = match ($bundle->status ?? null) { + 'pending' => Release::STATUS_UPLOADED, + 'awaiting_review', 'in_review' => Release::STATUS_IN_REVIEW, + 'approved' => Release::STATUS_APPROVED, + 'published' => Release::STATUS_PUBLISHED, + 'rejected' => Release::STATUS_REJECTED, + default => null, + }; + + if ($status === null) { + $this->report->anomaly('unknown_status', ['table' => 'registry_extension_bundles', 'uuid' => $bundle->uuid, 'status' => $bundle->status ?? null, 'mapped_to' => Release::STATUS_UPLOADED]); + } + + return $status ?? Release::STATUS_UPLOADED; + } + + /** + * `compat` from the manifest when it declares console or API ranges. + */ + protected function compat(array $meta): ?array + { + $compat = $this->bundleFile($meta, 'extension.json', 'compat') ?? $this->bundleFile($meta, 'extension.json', 'fleetbase'); + if (! is_array($compat)) { + return null; + } + + $compat = array_filter(['console' => $compat['console'] ?? null, 'api' => $compat['api'] ?? null]); + + return $compat ?: null; + } + + // --------------------------------------------------------------------- + // Purchases and licences + // --------------------------------------------------------------------- + + protected function importPurchases(): void + { + $seen = []; + + foreach ($this->purchases as $row) { + $extension = $this->extensionMap[$row->extension_uuid ?? ''] ?? null; + $buyer = $this->buyerOrganization($row); + + if (! $extension || ! $buyer) { + $this->report->anomaly('missing_reference', [ + 'table' => 'registry_extension_purchases', 'uuid' => $row->uuid, + 'missing' => $extension ? 'buyer' : 'extension', + 'reference' => $extension ? (($row->purchaser_uuid ?? null) ?: ($row->company_uuid ?? null)) : ($row->extension_uuid ?? null), + ]); + $this->report->count('purchases', ImportReport::SKIPPED); + + continue; + } + + if ($this->bool($row->is_subcription ?? false)) { + $this->report->anomaly('subscription', ['table' => 'registry_extension_purchases', 'uuid' => $row->uuid, 'extension' => $extension->slug]); + } + + $key = $extension->uuid.'|'.$buyer->uuid; + if (isset($seen[$key])) { + $this->report->anomaly('duplicate_purchase', ['uuid' => $row->uuid, 'extension' => $extension->slug, 'buyer' => $buyer->slug]); + } + $seen[$key] = true; + + $sessionId = $row->stripe_checkout_session_id ?? null; + if ($sessionId && Purchase::withTrashed()->where('stripe_checkout_session_id', $sessionId)->where(fn ($q) => $q->whereNull('source_uuid')->orWhere('source_uuid', '!=', $row->uuid))->exists()) { + $this->report->anomaly('conflict', ['table' => 'purchases', 'uuid' => $row->uuid, 'stripe_checkout_session_id' => $sessionId]); + $sessionId = null; + } + + $extension->loadMissing('price'); + $amount = ($row->locked_price ?? null) !== null ? (int) $row->locked_price : (int) ($extension->price?->amount ?? 0); + $currency = $extension->price?->currency ?? 'USD'; + $deletedAt = $this->date($row->deleted_at ?? null); + + $purchase = $this->save(Purchase::withTrashed()->where('source_uuid', $row->uuid)->first() ?? new Purchase, [ + 'source_uuid' => $row->uuid, + 'extension_uuid' => $extension->uuid, + 'buyer_organization_uuid' => $buyer->uuid, + 'price_snapshot' => ['model' => Price::MODEL_ONE_TIME, 'amount' => $amount, 'currency' => $currency, 'source' => 'import'], + 'amount_total' => $amount, + 'fee_amount' => 0, + 'net_amount' => 0, + 'currency' => $currency, + 'stripe_checkout_session_id' => $sessionId, + 'stripe_payment_intent_id' => $row->stripe_payment_intent_id ?? null, + 'status' => Purchase::STATUS_PAID, + 'created_at' => $this->date($row->created_at ?? null), + 'deleted_at' => $deletedAt, + 'meta' => ['source' => 'registry_extension_purchases', 'fees_unknown' => true], + ], 'purchases'); + + $licence = Licence::withTrashed()->where('source_uuid', $row->uuid)->first() ?? new Licence; + $this->save($licence, [ + 'source_uuid' => $row->uuid, + 'extension_uuid' => $extension->uuid, + 'organization_uuid' => $buyer->uuid, + 'purchase_uuid' => $purchase->uuid, + 'kind' => Licence::KIND_PAID, + // Seat counts are set once; staff may raise them in the service later. + 'max_activations' => $licence->exists ? $licence->max_activations : self::DEFAULT_PAID_SEATS, + 'status' => $deletedAt ? Licence::STATUS_REVOKED : Licence::STATUS_ACTIVE, + 'created_at' => $this->date($row->created_at ?? null), + ], 'licences'); + } + } + + protected function buyerOrganization(object $row): ?Organization + { + return match ($this->purchaserKind($row)) { + 'developer' => $this->developerOrganizations[$row->purchaser_uuid ?? ''] ?? null, + 'company' => $this->companyOrganizations[$row->purchaser_uuid ?? ''] ?? null, + default => $this->companyOrganizations[$row->company_uuid ?? ''] ?? null, + }; + } + + protected function purchaserKind(object $row): ?string + { + $type = (string) ($row->purchaser_type ?? ''); + if ($type === '' || blank($row->purchaser_uuid ?? null)) { + return null; + } + + return str_contains($type, 'Developer') ? 'developer' : 'company'; + } + + // --------------------------------------------------------------------- + // Instances, installs, activations + // --------------------------------------------------------------------- + + /** + * One synthetic cloud instance per company; soft-deleted installs become + * `uninstalled` rows. Live installs claim a licence seat on the company's + * instance (the paid licence from the purchase, or an automatic free licence). + */ + protected function importInstalls(): void + { + $activate = app(ActivateLicence::class); + $deactivate = app(DeactivateLicence::class); + + $groups = $this->installs->groupBy(fn ($i) => ($i->company_uuid ?? '').'|'.($i->extension_uuid ?? '')); + + foreach ($groups as $rows) { + $first = $rows->first(); + $extension = $this->extensionMap[$first->extension_uuid ?? ''] ?? null; + $instance = $this->syntheticInstance($first->company_uuid ?? null); + + if (! $extension || ! $instance) { + foreach ($rows as $row) { + $this->report->anomaly('missing_reference', [ + 'table' => 'registry_extension_installs', 'uuid' => $row->uuid, + 'missing' => $extension ? 'companies' : 'extension', + 'reference' => $extension ? ($row->company_uuid ?? null) : ($row->extension_uuid ?? null), + ]); + $this->report->count('installs', ImportReport::SKIPPED); + } + + continue; + } + + $release = $this->installedRelease($first->extension_uuid, $extension); + $live = $rows->filter(fn ($r) => ! $this->date($r->deleted_at ?? null)) + ->sortByDesc(fn ($r) => [(string) ($r->created_at ?? ''), (int) ($r->id ?? 0)]); + $current = $live->first(); + + if ($live->count() > 1) { + $this->report->anomaly('duplicate_install', ['extension' => $extension->slug, 'company' => $first->company_uuid, 'live_rows' => $live->count()]); + } + + $currentInstall = null; + foreach ($rows as $row) { + $installed = $current !== null && $row->uuid === $current->uuid; + $createdAt = $this->date($row->created_at ?? null); + + $install = $this->save(Install::query()->where('source_uuid', $row->uuid)->first() ?? new Install, [ + 'source_uuid' => $row->uuid, + 'instance_uuid' => $instance->uuid, + 'extension_uuid' => $extension->uuid, + 'release_uuid' => $release?->uuid, + 'status' => $installed ? Install::STATUS_INSTALLED : Install::STATUS_UNINSTALLED, + 'installed_by' => 'cloud-import', + 'started_at' => $createdAt, + 'finished_at' => $installed ? $createdAt : ($this->date($row->deleted_at ?? null) ?? $this->date($row->updated_at ?? null) ?? $createdAt), + 'created_at' => $createdAt, + ], 'installs'); + + if ($installed) { + $currentInstall = $install; + } + } + + if (! $currentInstall) { + $closed = $deactivate($instance, $extension); + if ($closed > 0) { + $this->report->count('licence_activations', ImportReport::UPDATED, $closed); + } + + continue; + } + + $licencesBefore = Licence::query()->where('extension_uuid', $extension->uuid)->where('organization_uuid', $instance->organization_uuid)->count(); + + try { + $activation = $activate($instance, $extension, $release); + } catch (LicenceRequiredException $e) { + $this->report->anomaly('install_without_licence', ['extension' => $extension->slug, 'company' => $first->company_uuid, 'reason' => $e->getMessage()]); + + continue; + } + + if (Licence::query()->where('extension_uuid', $extension->uuid)->where('organization_uuid', $instance->organization_uuid)->count() > $licencesBefore) { + $this->report->count('licences', ImportReport::CREATED); + } + + if ($activation->wasRecentlyCreated) { + $activation->forceFill(['source_uuid' => $current->uuid])->save(); + $this->report->count('licence_activations', ImportReport::CREATED); + } else { + $this->report->count('licence_activations', ImportReport::UNCHANGED); + } + } + } + + protected function syntheticInstance(?string $companyUuid): ?Instance + { + if ($companyUuid === null || $companyUuid === '') { + return null; + } + + if (isset($this->instances[$companyUuid])) { + return $this->instances[$companyUuid]; + } + + $organization = $this->companyOrganizations[$companyUuid] ?? null; + if (! $organization) { + return null; + } + + $company = $this->companies->get($companyUuid); + $instance = Instance::withTrashed()->where('source_uuid', $companyUuid)->where('kind', Instance::KIND_CLOUD)->first() ?? new Instance; + + return $this->instances[$companyUuid] = $this->save($instance, [ + 'source_uuid' => $companyUuid, + 'organization_uuid' => $organization->uuid, + 'name' => ($company->name ?? $organization->name).' (Fleetbase Cloud)', + 'kind' => Instance::KIND_CLOUD, + 'status' => Instance::STATUS_ACTIVE, + 'meta' => array_filter(['synthetic' => true, 'source' => 'companies', 'company_public_id' => $company->public_id ?? null]), + ], 'instances'); + } + + /** + * The release an imported install points at: the extension's current bundle, else + * its newest published release. + */ + protected function installedRelease(string $sourceExtensionUuid, Extension $extension): ?Release + { + $current = $this->extensions->get($sourceExtensionUuid)->current_bundle_uuid ?? null; + if ($current && isset($this->releaseMap[$current])) { + return $this->releaseMap[$current]; + } + + return Compat::latest(collect($this->releaseMap)->filter(fn (Release $r) => $r->extension_uuid === $extension->uuid && $r->isPublished())); + } + + // --------------------------------------------------------------------- + // Files + // --------------------------------------------------------------------- + + protected function sourceExists(string $path): bool + { + try { + return Storage::disk($this->sourceDisk)->exists($path); + } catch (\Throwable) { + return false; + } + } + + /** + * Copy a cloud `files` object into service storage with a SHA-256 checksum. Re-runs + * skip objects already copied with a checksum. A dry run records the file row (rolled + * back) but copies nothing. + */ + protected function copyFile(?object $source, string $type, string $prefix, EloquentModel $subject): ?File + { + if (! $source || blank($source->path ?? null) || ! $this->sourceExists($source->path)) { + return null; + } + + $file = File::withTrashed()->where('source_uuid', $source->uuid)->first() ?? new File; + $target = trim(config("registry.storage.prefixes.{$prefix}", $prefix), '/').'/imported/'.$source->uuid.'/'.basename($source->path); + $attributes = [ + 'source_uuid' => $source->uuid, + 'subject_type' => $subject->getMorphClass(), + 'subject_uuid' => $subject->getKey(), + 'type' => $type, + 'disk' => $this->targetDisk, + 'path' => $target, + 'name' => basename($source->path), + 'original_filename' => $source->original_filename ?? basename($source->path), + 'mime' => $source->content_type ?? null, + ]; + + $copied = $file->exists && $file->checksum && $file->disk === $this->targetDisk && $file->path === $target + && Storage::disk($this->targetDisk)->exists($target); + + if ($copied || $this->dryRun) { + $attributes['size'] = $copied ? $file->size : (int) ($source->file_size ?? 0); + + return $this->save($file, $attributes, 'files'); + } + + [$size, $checksum] = $this->streamCopy($source->path, $target); + + return $this->save($file, array_merge($attributes, ['size' => $size, 'checksum' => 'sha256:'.$checksum]), 'files'); + } + + /** + * @return array{0: int, 1: string} size in bytes and hex SHA-256 + */ + protected function streamCopy(string $from, string $to): array + { + $in = Storage::disk($this->sourceDisk)->readStream($from); + $buffer = tmpfile(); + + try { + stream_copy_to_stream($in, $buffer); + $path = stream_get_meta_data($buffer)['uri']; + $checksum = hash_file('sha256', $path); + $size = filesize($path); + + rewind($buffer); + Storage::disk($this->targetDisk)->writeStream($to, $buffer); + + return [(int) $size, $checksum]; + } finally { + is_resource($in) && fclose($in); + is_resource($buffer) && fclose($buffer); + } + } + + // --------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------- + + /** + * Fill and persist a model, counting the outcome under `$table`. + * + * @template T of EloquentModel + * + * @param T $model + * @return T + */ + protected function save(EloquentModel $model, array $attributes, string $table): EloquentModel + { + $model->forceFill($attributes); + + return $this->persist($model, $table); + } + + /** + * @template T of EloquentModel + * + * @param T $model + * @return T + */ + protected function persist(EloquentModel $model, string $table, bool $created = false): EloquentModel + { + $outcome = match (true) { + $created || ! $model->exists => ImportReport::CREATED, + $model->isDirty() => ImportReport::UPDATED, + default => ImportReport::UNCHANGED, + }; + + if ($model->isDirty() || ! $model->exists) { + $model->save(); + } + + $this->report->count($table, $outcome); + + return $model; + } + + /** + * A slug unique among the model's rows (including soft-deleted ones). + * + * @param class-string $model + */ + protected function uniqueSlug(string $model, string $base): string + { + $base = Str::slug($base) ?: Str::lower(Str::random(8)); + $candidate = $base; + $n = 2; + + $query = fn () => in_array(SoftDeletes::class, class_uses_recursive($model), true) ? $model::withTrashed() : $model::query(); + while ($query()->where('slug', $candidate)->exists()) { + $candidate = $base.'-'.$n++; + } + + if ($candidate !== $base && $model === Organization::class) { + $this->report->anomaly('slug_renamed', ['table' => 'organizations', 'from' => $base, 'to' => $candidate]); + } + + return $candidate; + } + + /** + * A value from one of the files a bundle's `meta` holds, keyed by file name + * (`extension.json`, `package.json`, `composer.json`). Not `data_get`: the dots in + * those keys are part of the name. + */ + protected function bundleFile(array $meta, string $file, ?string $path = null): mixed + { + $contents = $meta[$file] ?? null; + + return $path === null ? $contents : data_get($contents, $path); + } + + protected function bool(mixed $value): bool + { + return in_array($value, [true, 1, '1', 'true'], true); + } + + protected function json(mixed $value): ?array + { + if (is_array($value)) { + return $value; + } + if (is_object($value)) { + return json_decode(json_encode($value), true); + } + if (! is_string($value) || trim($value) === '') { + return null; + } + + $decoded = json_decode($value, true); + + // Double-encoded JSON (a JSON string holding JSON) appears in older rows. + if (is_string($decoded)) { + $decoded = json_decode($decoded, true); + } + + return is_array($decoded) ? $decoded : null; + } + + protected function date(mixed $value): ?Carbon + { + if ($value === null || $value === '' || str_starts_with((string) $value, '0000-00-00')) { + return null; + } + + try { + return Carbon::parse($value); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Import/SourceDatabase.php b/app/Import/SourceDatabase.php new file mode 100644 index 0000000..6cf622c --- /dev/null +++ b/app/Import/SourceDatabase.php @@ -0,0 +1,107 @@ + rows loaded per table, when the source is a dump */ + public array $dumpRowCounts = []; + + public function __construct(public readonly string $from) {} + + public function open(): Connection + { + $config = $this->connectionConfig(); + + Config::set('database.connections.'.self::CONNECTION, $config); + DB::purge(self::CONNECTION); + + return DB::connection(self::CONNECTION); + } + + public function describe(): string + { + if ($this->isDsn()) { + return (string) preg_replace('#//([^:/@]+):[^@]*@#', '//$1:***@', $this->from); + } + + return $this->from; + } + + public function close(): void + { + DB::purge(self::CONNECTION); + + if ($this->temporaryFile && is_file($this->temporaryFile)) { + @unlink($this->temporaryFile); + } + $this->temporaryFile = null; + } + + protected function isDsn(): bool + { + return (bool) preg_match('#^(mysql|mariadb|sqlite)://#i', $this->from); + } + + protected function connectionConfig(): array + { + if (preg_match('#^sqlite://(.+)$#i', $this->from, $m)) { + $path = $m[1]; + if (! is_file($path)) { + throw new RuntimeException("SQLite source not found: {$path}"); + } + + return ['driver' => 'sqlite', 'database' => $path, 'prefix' => '', 'foreign_key_constraints' => false]; + } + + if (preg_match('#^(mysql|mariadb)://#i', $this->from)) { + $parts = parse_url($this->from); + if ($parts === false || empty($parts['host']) || empty($parts['path']) || trim($parts['path'], '/') === '') { + throw new RuntimeException('Expected a DSN like mysql://user:pass@host:3306/database.'); + } + parse_str($parts['query'] ?? '', $query); + + return [ + 'driver' => strtolower($parts['scheme']), + 'host' => $parts['host'], + 'port' => $parts['port'] ?? 3306, + 'database' => trim($parts['path'], '/'), + 'username' => isset($parts['user']) ? urldecode($parts['user']) : 'root', + 'password' => isset($parts['pass']) ? urldecode($parts['pass']) : '', + 'charset' => $query['charset'] ?? 'utf8mb4', + 'collation' => $query['collation'] ?? 'utf8mb4_unicode_ci', + 'prefix' => '', + 'strict' => false, + ]; + } + + $this->temporaryFile = tempnam(sys_get_temp_dir(), 'registry-import-').'.sqlite'; + $this->dumpRowCounts = (new SqlDumpLoader(self::TABLES))->load($this->from, $this->temporaryFile); + + return ['driver' => 'sqlite', 'database' => $this->temporaryFile, 'prefix' => '', 'foreign_key_constraints' => false]; + } +} diff --git a/app/Import/SqlDumpLoader.php b/app/Import/SqlDumpLoader.php new file mode 100644 index 0000000..65b66dd --- /dev/null +++ b/app/Import/SqlDumpLoader.php @@ -0,0 +1,340 @@ +> */ + protected array $columns = []; + + /** @var array */ + protected array $rowCounts = []; + + protected PDO $pdo; + + /** + * @param list $tables tables to keep + */ + public function __construct(protected array $tables) {} + + /** + * @return array rows loaded per table + */ + public function load(string $dumpPath, string $sqlitePath): array + { + if (! is_file($dumpPath) || ! is_readable($dumpPath)) { + throw new RuntimeException("SQL dump not found or not readable: {$dumpPath}"); + } + + $handle = fopen(str_ends_with($dumpPath, '.gz') ? 'compress.zlib://'.$dumpPath : $dumpPath, 'rb'); + if ($handle === false) { + throw new RuntimeException("Could not open SQL dump: {$dumpPath}"); + } + + $this->pdo = new PDO('sqlite:'.$sqlitePath, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); + $this->pdo->exec('PRAGMA journal_mode = OFF'); + $this->pdo->exec('PRAGMA synchronous = OFF'); + $this->pdo->beginTransaction(); + + try { + while (($line = fgets($handle)) !== false) { + if (preg_match('/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`"]?([^`"\s(]+)[`"]?/i', $line, $match)) { + $statement = $this->readUntilTerminated($handle, $line); + if ($this->wanted($match[1])) { + $this->defineTable($match[1], $this->parseCreateColumns($statement), true); + } + + continue; + } + + if (preg_match('/^(?:INSERT|REPLACE)(?:\s+IGNORE)?\s+INTO\s+[`"]?([^`"\s(]+)[`"]?/i', $line, $match) && $this->wanted($match[1])) { + $this->loadInsert($handle, $match[1], $line); + } + } + + $this->pdo->commit(); + } catch (\Throwable $e) { + $this->pdo->rollBack(); + + throw $e; + } finally { + fclose($handle); + } + + return $this->rowCounts; + } + + protected function wanted(string $table): bool + { + return in_array($table, $this->tables, true); + } + + /** + * @param resource $handle + */ + protected function readUntilTerminated($handle, string $statement): string + { + $line = $statement; + while (! str_ends_with(rtrim($line), ';')) { + $line = fgets($handle); + if ($line === false) { + break; + } + $statement .= $line; + } + + return $statement; + } + + /** + * @return list + */ + protected function parseCreateColumns(string $statement): array + { + $columns = []; + foreach (preg_split('/\R/', $statement) as $line) { + if (preg_match('/^\s*[`"]((?:[^`"]|``)+)[`"]\s+\w/', $line, $match)) { + $columns[] = str_replace('``', '`', $match[1]); + } + } + + if ($columns === []) { + throw new RuntimeException('Could not read the columns of a CREATE TABLE statement in the dump.'); + } + + return $columns; + } + + /** + * @param list $columns + */ + protected function defineTable(string $table, array $columns, bool $replace): void + { + if (isset($this->columns[$table]) && ! $replace) { + return; + } + + $quoted = implode(', ', array_map(fn ($c) => $this->quote($c).' TEXT', $columns)); + $this->pdo->exec('DROP TABLE IF EXISTS '.$this->quote($table)); + $this->pdo->exec('CREATE TABLE '.$this->quote($table)." ({$quoted})"); + + $this->columns[$table] = $columns; + $this->rowCounts[$table] = 0; + } + + /** + * @param resource $handle + */ + protected function loadInsert($handle, string $table, string $statement): void + { + while (true) { + try { + [$columns, $rows] = $this->parseInsert($statement); + break; + } catch (IncompleteStatement) { + $next = fgets($handle); + if ($next === false) { + throw new RuntimeException("The dump ends inside an INSERT statement for `{$table}`."); + } + $statement .= $next; + } + } + + if (! isset($this->columns[$table])) { + if ($columns === null) { + throw new RuntimeException("The dump has rows for `{$table}` but no CREATE TABLE statement or column list for it."); + } + $this->defineTable($table, $columns, false); + } + + $tableColumns = $this->columns[$table]; + $insertColumns = $columns ?? $tableColumns; + $placeholders = implode(', ', array_fill(0, count($insertColumns), '?')); + $insert = $this->pdo->prepare( + 'INSERT INTO '.$this->quote($table).' ('.implode(', ', array_map(fn ($c) => $this->quote($c), $insertColumns)).") VALUES ({$placeholders})" + ); + + foreach ($rows as $row) { + if (count($row) !== count($insertColumns)) { + throw new RuntimeException("A row for `{$table}` has ".count($row).' values but '.count($insertColumns).' columns.'); + } + $insert->execute($row); + $this->rowCounts[$table]++; + } + } + + /** + * Parse one INSERT statement into its column list (null when implicit) and rows. + * + * @return array{0: list|null, 1: list>} + * + * @throws IncompleteStatement when the statement continues on the next line + */ + public function parseInsert(string $sql): array + { + if (! preg_match('/^(?:INSERT|REPLACE)(?:\s+IGNORE)?\s+INTO\s+[`"]?[^`"\s(]+[`"]?\s*(?:\(([^)]*)\))?\s*VALUES\s*/i', $sql, $match)) { + if (! preg_match('/\bVALUES\b/i', $sql)) { + throw new IncompleteStatement; + } + + throw new RuntimeException('Unrecognised INSERT statement in the dump.'); + } + + $columns = isset($match[1]) && $match[1] !== '' + ? array_map(fn ($c) => trim($c, " \t\n\r`\""), explode(',', $match[1])) + : null; + + $i = strlen($match[0]); + $len = strlen($sql); + $rows = []; + + while (true) { + $i = $this->skipWhitespace($sql, $i, $len); + if ($sql[$i] !== '(') { + throw new RuntimeException('Expected "(" in an INSERT statement at offset '.$i.'.'); + } + $i++; + + $row = []; + while (true) { + $i = $this->skipWhitespace($sql, $i, $len); + [$value, $i] = $this->parseValue($sql, $i, $len); + $row[] = $value; + + $i = $this->skipWhitespace($sql, $i, $len); + if ($sql[$i] === ',') { + $i++; + + continue; + } + if ($sql[$i] === ')') { + $i++; + break; + } + + throw new RuntimeException('Unexpected "'.$sql[$i].'" in an INSERT statement at offset '.$i.'.'); + } + $rows[] = $row; + + $i = $this->skipWhitespace($sql, $i, $len); + if ($sql[$i] === ',') { + $i++; + + continue; + } + if ($sql[$i] === ';') { + return [$columns, $rows]; + } + + throw new RuntimeException('Unexpected "'.$sql[$i].'" after a row in an INSERT statement at offset '.$i.'.'); + } + } + + protected function skipWhitespace(string $sql, int $i, int $len): int + { + $i += strspn($sql, " \t\r\n", $i); + if ($i >= $len) { + throw new IncompleteStatement; + } + + return $i; + } + + /** + * @return array{0: string|null, 1: int} + */ + protected function parseValue(string $sql, int $i, int $len): array + { + $char = $sql[$i]; + + if ($char === "'" || $char === '"') { + return $this->parseString($sql, $i, $len); + } + + // Charset introducers: _binary '…', _utf8mb4'…' + if ($char === '_' && preg_match('/\G_[a-z0-9]+\s*(?=[\'"])/Ai', $sql, $m, 0, $i)) { + return $this->parseString($sql, $i + strlen($m[0]), $len); + } + + $token = substr($sql, $i, strcspn($sql, ",) \t\r\n", $i)); + if ($i + strlen($token) >= $len) { + throw new IncompleteStatement; + } + $next = $i + strlen($token); + + if (strcasecmp($token, 'NULL') === 0) { + return [null, $next]; + } + if (strcasecmp($token, 'TRUE') === 0 || strcasecmp($token, 'FALSE') === 0) { + return [strcasecmp($token, 'TRUE') === 0 ? '1' : '0', $next]; + } + if (preg_match('/^0x([0-9a-f]*)$/i', $token, $m)) { + return [hex2bin(strlen($m[1]) % 2 ? '0'.$m[1] : $m[1]), $next]; + } + if (preg_match("/^b'([01]*)'$/i", $token, $m)) { + return [(string) bindec($m[1] ?: '0'), $next]; + } + + return [$token, $next]; + } + + /** + * @return array{0: string, 1: int} + */ + protected function parseString(string $sql, int $i, int $len): array + { + static $escapes = ['0' => "\0", "'" => "'", '"' => '"', 'b' => "\x08", 'n' => "\n", 'r' => "\r", 't' => "\t", 'Z' => "\x1A", '\\' => '\\', '%' => '\\%', '_' => '\\_']; + + $quote = $sql[$i]; + $i++; + $value = ''; + + while (true) { + $chunk = strcspn($sql, $quote.'\\', $i); + $value .= substr($sql, $i, $chunk); + $i += $chunk; + + if ($i >= $len) { + throw new IncompleteStatement; + } + + if ($sql[$i] === '\\') { + if ($i + 1 >= $len) { + throw new IncompleteStatement; + } + $value .= $escapes[$sql[$i + 1]] ?? $sql[$i + 1]; + $i += 2; + + continue; + } + + // Doubled quote inside the string. + if ($i + 1 < $len && $sql[$i + 1] === $quote) { + $value .= $quote; + $i += 2; + + continue; + } + + return [$value, $i + 1]; + } + } + + protected function quote(string $identifier): string + { + return '"'.str_replace('"', '""', $identifier).'"'; + } +} diff --git a/app/Jobs/RunReleaseChecks.php b/app/Jobs/RunReleaseChecks.php new file mode 100644 index 0000000..cd6e54e --- /dev/null +++ b/app/Jobs/RunReleaseChecks.php @@ -0,0 +1,50 @@ +onQueue(config('registry.releases.checks_queue')); + } + + public function handle(ReleasePipeline $pipeline): void + { + $release = Release::query()->whereKey($this->release->getKey())->first(); + + if ($release === null || ! $pipeline->checksPending($release)) { + return; + } + + $pipeline->runChecks($release); + } + + public function failed(?\Throwable $exception): void + { + $release = Release::query()->whereKey($this->release->getKey())->first(); + + if ($release !== null) { + app(ReleasePipeline::class)->failChecks($release, 'The checks could not run. Upload the release again; if this keeps happening, contact Fleetbase staff.'); + } + } +} diff --git a/app/Mail/ReleaseDecisionMail.php b/app/Mail/ReleaseDecisionMail.php new file mode 100644 index 0000000..d4a46c4 --- /dev/null +++ b/app/Mail/ReleaseDecisionMail.php @@ -0,0 +1,61 @@ +release->loadMissing('extension'); + $subject = match ($this->decision) { + self::PUBLISHED => 'is published', + self::REJECTED => 'was not approved', + default => 'failed its checks', + }; + + return new Envelope(subject: $this->release->extension->name.' '.$this->release->version.' '.$subject); + } + + public function content(): Content + { + $this->release->loadMissing(['extension', 'createdBy', 'checks']); + $review = $this->release->reviews()->reorder()->latest('id')->first(); + + return new Content( + markdown: 'mail.release-decision', + with: [ + 'name' => $this->release->createdBy?->name, + 'extension' => $this->release->extension->name, + 'version' => $this->release->version, + 'decision' => $this->decision, + 'reason' => $review?->action === Review::ACTION_REJECT ? $review->reason : null, + 'items' => $review?->action === Review::ACTION_REJECT ? ($review->items ?? []) : [], + 'failedChecks' => $this->release->checks->where('passed', false)->map(fn ($check) => $check->check.': '.($check->output['summary'] ?? ''))->values()->all(), + 'releaseId' => $this->release->public_id, + ], + ); + } +} diff --git a/app/Mail/VerificationCodeMail.php b/app/Mail/VerificationCodeMail.php new file mode 100644 index 0000000..a5e1cdb --- /dev/null +++ b/app/Mail/VerificationCodeMail.php @@ -0,0 +1,36 @@ + $this->account->name, + 'code' => $this->verificationCode->code, + 'minutes' => VerificationCode::LIFETIME_MINUTES, + ], + ); + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php new file mode 100644 index 0000000..cdc845a --- /dev/null +++ b/app/Models/Account.php @@ -0,0 +1,146 @@ + 'datetime', + 'last_login_at' => 'datetime', + 'meta' => 'array', + 'password' => 'hashed', + ]; + + /** The token used to authenticate the current request, if any. */ + protected ?Token $currentToken = null; + + protected static function newFactory(): AccountFactory + { + return AccountFactory::new(); + } + + public function tokens(): HasMany + { + return $this->hasMany(Token::class, 'account_uuid', 'uuid'); + } + + public function memberships(): HasMany + { + return $this->hasMany(Membership::class, 'account_uuid', 'uuid'); + } + + public function organizations(): BelongsToMany + { + return $this->belongsToMany(Organization::class, 'memberships', 'account_uuid', 'organization_uuid', 'uuid', 'uuid') + ->using(Membership::class) + ->withPivot('role') + ->withTimestamps(); + } + + public function verificationCodes(): HasMany + { + return $this->hasMany(VerificationCode::class, 'subject_uuid', 'uuid'); + } + + public function hasVerifiedEmail(): bool + { + return $this->email_verified_at !== null; + } + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE; + } + + public function markEmailAsVerified(): static + { + $this->forceFill([ + 'email_verified_at' => now(), + 'status' => $this->status === self::STATUS_PENDING_VERIFICATION ? self::STATUS_ACTIVE : $this->status, + ])->save(); + + return $this; + } + + public function withCurrentToken(?Token $token): static + { + $this->currentToken = $token; + + return $this; + } + + public function currentToken(): ?Token + { + return $this->currentToken; + } + + public function tokenCan(string $scope): bool + { + return $this->currentToken?->can($scope) ?? false; + } + + /** + * The account's role in the organization, or null when it is not a member. + */ + public function roleIn(Organization|string $organization): ?string + { + return Membership::query() + ->where('account_uuid', $this->uuid) + ->where('organization_uuid', $organization instanceof Organization ? $organization->uuid : $organization) + ->value('role'); + } + + /** + * Member of an organization flagged `is_staff` (Fleetbase staff: release review and + * moderation). + */ + public function isStaff(): bool + { + return $this->organizations()->where('is_staff', true)->exists(); + } + + /** + * The account's personal organization (created at registration). + */ + public function personalOrganization(): ?Organization + { + return $this->organizations()->where('is_personal', true)->first(); + } +} diff --git a/app/Models/Category.php b/app/Models/Category.php new file mode 100644 index 0000000..d06bba4 --- /dev/null +++ b/app/Models/Category.php @@ -0,0 +1,38 @@ + 'array', 'sort' => 'integer']; + + protected static function newFactory(): CategoryFactory + { + return CategoryFactory::new(); + } + + public function extensions(): HasMany + { + return $this->hasMany(Extension::class, 'category_uuid', 'uuid'); + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php new file mode 100644 index 0000000..0d8671a --- /dev/null +++ b/app/Models/Event.php @@ -0,0 +1,63 @@ + 'array', 'created_at' => 'datetime']; + + public function subject(): MorphTo + { + return $this->morphTo('subject', 'subject_type', 'subject_uuid', 'uuid'); + } + + public function actor(): MorphTo + { + return $this->morphTo('actor', 'actor_type', 'actor_uuid', 'uuid'); + } + + /** + * Record an event. Never throws; auditing must not break the request. + */ + public static function record(string $kind, ?EloquentModel $subject = null, ?EloquentModel $actor = null, array $payload = [], ?string $ip = null): ?static + { + try { + return static::create([ + 'kind' => $kind, + 'subject_type' => $subject?->getMorphClass(), + 'subject_uuid' => $subject?->getKey(), + 'actor_type' => $actor?->getMorphClass(), + 'actor_uuid' => $actor?->getKey(), + 'payload' => $payload, + 'ip_address' => $ip ?? request()?->ip(), + ]); + } catch (\Throwable $e) { + report($e); + + return null; + } + } +} diff --git a/app/Models/Extension.php b/app/Models/Extension.php new file mode 100644 index 0000000..a678b1b --- /dev/null +++ b/app/Models/Extension.php @@ -0,0 +1,162 @@ + 'array', + 'languages' => 'array', + 'published_at' => 'datetime', + 'meta' => 'array', + ]; + + protected static function newFactory(): ExtensionFactory + { + return ExtensionFactory::new(); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_uuid', 'uuid'); + } + + public function publisher(): BelongsTo + { + return $this->organization(); + } + + public function category(): BelongsTo + { + return $this->belongsTo(Category::class, 'category_uuid', 'uuid'); + } + + public function icon(): BelongsTo + { + return $this->belongsTo(File::class, 'icon_file_uuid', 'uuid'); + } + + public function screenshots(): MorphMany + { + return $this->morphMany(File::class, 'subject', 'subject_type', 'subject_uuid', 'uuid') + ->where('type', File::TYPE_SCREENSHOT) + ->orderBy('created_at'); + } + + public function packages(): HasMany + { + return $this->hasMany(ExtensionPackage::class, 'extension_uuid', 'uuid'); + } + + public function price(): HasOne + { + return $this->hasOne(Price::class, 'extension_uuid', 'uuid'); + } + + public function releases(): HasMany + { + return $this->hasMany(Release::class, 'extension_uuid', 'uuid'); + } + + public function publishedReleases(): HasMany + { + return $this->releases()->where('status', Release::STATUS_PUBLISHED)->orderByDesc('published_at'); + } + + /** + * Newest published stable release (falls back to any channel). + */ + public function latestRelease(): HasOne + { + return $this->hasOne(Release::class, 'extension_uuid', 'uuid') + ->where('status', Release::STATUS_PUBLISHED) + ->ofMany(['published_at' => 'max', 'id' => 'max'], fn (Builder $q) => $q->where('status', Release::STATUS_PUBLISHED)); + } + + public function installs(): HasMany + { + return $this->hasMany(Install::class, 'extension_uuid', 'uuid'); + } + + public function licences(): HasMany + { + return $this->hasMany(Licence::class, 'extension_uuid', 'uuid'); + } + + public function purchases(): HasMany + { + return $this->hasMany(Purchase::class, 'extension_uuid', 'uuid'); + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('status', self::STATUS_PUBLISHED); + } + + public function isPaid(): bool + { + return $this->price !== null && $this->price->model !== Price::MODEL_FREE && (int) $this->price->amount > 0; + } +} diff --git a/app/Models/ExtensionPackage.php b/app/Models/ExtensionPackage.php new file mode 100644 index 0000000..3e19491 --- /dev/null +++ b/app/Models/ExtensionPackage.php @@ -0,0 +1,139 @@ +name = strtolower(trim((string) $package->name)); + + if ($package->isDirty(['name', 'kind', 'extension_uuid'])) { + $package->assertNamespaceAllowed(); + } + }); + } + + public function extension(): BelongsTo + { + return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); + } + + /** + * The scope or vendor: `fleetbase` for `@fleetbase/x` and `fleetbase/x`; null for an + * unscoped npm name. + */ + public static function namespaceOf(string $kind, string $name): ?string + { + $name = strtolower($name); + + if ($kind === self::KIND_NPM) { + return preg_match('#^@([^/]+)/#', $name, $m) ? $m[1] : null; + } + + return str_contains($name, '/') ? strstr($name, '/', true) : null; + } + + public static function isReservedNamespace(?string $namespace): bool + { + return $namespace !== null && in_array($namespace, array_map('strtolower', config('registry.reserved_scopes', [])), true); + } + + /** + * Reserved namespaces may only be owned by an extension of a staff organization. + * + * @throws ReservedPackageNamespaceException + */ + public function assertNamespaceAllowed(): void + { + $namespace = static::namespaceOf((string) $this->kind, (string) $this->name); + if (! static::isReservedNamespace($namespace)) { + return; + } + + $owner = Extension::withTrashed()->find($this->extension_uuid)?->organization()->first(); + if ($owner?->is_staff !== true) { + throw ReservedPackageNamespaceException::for($this->name, $this->kind === self::KIND_NPM ? '@'.$namespace : $namespace); + } + } + + /** + * The package a client asked for, if its listing is servable: not deleted and not + * suspended. Names are matched lowercase, as npm and Composer require them. + */ + public static function resolve(string $kind, string $name): ?static + { + return static::query() + ->where('kind', $kind) + ->where('name', strtolower($name)) + ->whereHas('extension', fn (Builder $q) => $q->where('status', '!=', Extension::STATUS_SUSPENDED)) + ->with('extension.price') + ->first(); + } + + /** + * Release column and relation holding this package's archive. + */ + public function fileRelation(): string + { + return $this->kind === self::KIND_NPM ? 'engineTarball' : 'apiTarball'; + } + + /** + * Published releases that carry this package's archive and a valid SemVer version, + * ascending by precedence. Yanked releases are excluded. + * + * @return Collection + */ + public function servableReleases(): Collection + { + $relation = $this->fileRelation(); + + return $this->extension->releases() + ->published() + ->whereNotNull($relation === 'engineTarball' ? 'engine_tarball_file_uuid' : 'api_tarball_file_uuid') + ->with($relation) + ->get() + ->filter(fn (Release $release) => $release->{$relation} !== null && Semver::valid($release->version)) + ->sort(fn (Release $a, Release $b) => Semver::compare($a->version, $b->version)) + ->values(); + } + + /** + * A release whose archive may be downloaded. Yanked releases stay downloadable so + * existing lockfiles keep installing; they are only hidden from metadata. + */ + public function downloadableRelease(string $version): ?Release + { + return $this->extension->releases() + ->whereIn('status', [Release::STATUS_PUBLISHED, Release::STATUS_YANKED]) + ->where('version', $version) + ->with($this->fileRelation()) + ->first(); + } +} diff --git a/app/Models/File.php b/app/Models/File.php new file mode 100644 index 0000000..f9d4a73 --- /dev/null +++ b/app/Models/File.php @@ -0,0 +1,80 @@ + 'integer', 'meta' => 'array']; + + /** + * Decoded `package.json` (engine tarballs) or `composer.json` (API tarballs) captured + * by `ReleaseStorage` when the archive was stored. + */ + public function manifest(): ?array + { + return match ($this->type) { + self::TYPE_ENGINE_TARBALL => $this->meta['package_json'] ?? null, + self::TYPE_API_TARBALL => $this->meta['composer_json'] ?? null, + default => null, + }; + } + + public function subject(): MorphTo + { + return $this->morphTo('subject', 'subject_type', 'subject_uuid', 'uuid'); + } + + /** + * Public URL for icons and screenshots. Package tarballs are served through the + * package endpoints instead so licence checks apply. + */ + public function getUrlAttribute(): ?string + { + if (! in_array($this->type, [self::TYPE_ICON, self::TYPE_SCREENSHOT], true)) { + return null; + } + + try { + return Storage::disk($this->disk)->url($this->path); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Models/Install.php b/app/Models/Install.php new file mode 100644 index 0000000..d7c990d --- /dev/null +++ b/app/Models/Install.php @@ -0,0 +1,55 @@ + 'datetime', 'finished_at' => 'datetime']; + + protected static function newFactory(): InstallFactory + { + return InstallFactory::new(); + } + + public function instance(): BelongsTo + { + return $this->belongsTo(Instance::class, 'instance_uuid', 'uuid'); + } + + public function extension(): BelongsTo + { + return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); + } + + public function release(): BelongsTo + { + return $this->belongsTo(Release::class, 'release_uuid', 'uuid'); + } +} diff --git a/app/Models/Instance.php b/app/Models/Instance.php new file mode 100644 index 0000000..0be5547 --- /dev/null +++ b/app/Models/Instance.php @@ -0,0 +1,119 @@ + 'datetime', 'meta' => 'array']; + + protected static function newFactory(): InstanceFactory + { + return InstanceFactory::new(); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_uuid', 'uuid'); + } + + public function registeredBy(): BelongsTo + { + return $this->belongsTo(Account::class, 'registered_by_account_uuid', 'uuid'); + } + + public function installs(): HasMany + { + return $this->hasMany(Install::class, 'instance_uuid', 'uuid'); + } + + public function activations(): HasMany + { + return $this->hasMany(LicenceActivation::class, 'instance_uuid', 'uuid'); + } + + /** + * Replace the instance token and return the new plaintext (shown once). + */ + public function issueToken(): string + { + $plain = Token::PREFIX_INSTANCE.Str::random(40); + + $this->forceFill(['token_hash' => Token::hash($plain)])->save(); + + return $plain; + } + + /** + * Resolve a plaintext instance token to its active instance, or null. + */ + public static function findByToken(string $plain): ?static + { + if (! str_starts_with($plain, Token::PREFIX_INSTANCE)) { + return null; + } + + return static::query() + ->where('token_hash', Token::hash($plain)) + ->where('status', self::STATUS_ACTIVE) + ->first(); + } + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE; + } + + public function touchLastSeen(?string $ip = null): void + { + $meta = $this->meta ?? []; + if ($ip !== null) { + $meta['last_seen_ip'] = $ip; + } + + $this->forceFill(['last_seen_at' => now(), 'meta' => $meta])->saveQuietly(); + } +} diff --git a/app/Models/InstanceRegistrationCode.php b/app/Models/InstanceRegistrationCode.php new file mode 100644 index 0000000..1e3ed64 --- /dev/null +++ b/app/Models/InstanceRegistrationCode.php @@ -0,0 +1,118 @@ + 'datetime', 'used_at' => 'datetime']; + + /** Plaintext, only populated on the instance returned by `mint()`. */ + public ?string $plainTextCode = null; + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_uuid', 'uuid'); + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class, 'account_uuid', 'uuid'); + } + + public function instance(): BelongsTo + { + return $this->belongsTo(Instance::class, 'instance_uuid', 'uuid'); + } + + /** + * Mint a code (`XXXX-XXXX-XXXX-XXXX`, 80 bits) for an organization. + */ + public static function mint(Organization $organization, Account $account, ?string $ip = null): static + { + $plain = static::generatePlain(); + + $code = static::create([ + 'organization_uuid' => $organization->uuid, + 'account_uuid' => $account->uuid, + 'code_hash' => static::hash($plain), + 'expires_at' => now()->addMinutes(static::LIFETIME_MINUTES), + 'created_ip' => $ip, + ]); + + $code->plainTextCode = $plain; + + return $code; + } + + public static function generatePlain(): string + { + $chars = ''; + for ($i = 0; $i < 16; $i++) { + $chars .= self::ALPHABET[random_int(0, strlen(self::ALPHABET) - 1)]; + } + + return implode('-', str_split($chars, 4)); + } + + /** + * Codes are compared case-insensitively and without separators or whitespace. + */ + public static function normalize(string $plain): string + { + $chars = strtoupper((string) preg_replace('/[^0-9A-Za-z]/', '', $plain)); + + return implode('-', str_split($chars, 4)); + } + + public static function hash(string $plain): string + { + return hash('sha256', static::normalize($plain)); + } + + public function scopeUsable(Builder $query): Builder + { + return $query->whereNull('used_at')->where('expires_at', '>', now()); + } + + /** + * Atomically mark the code behind `$plain` used and return it, or null when the + * code is unknown, expired or already used. Safe against concurrent redemption. + */ + public static function redeem(string $plain): ?static + { + if (trim($plain) === '') { + return null; + } + + $code = static::query()->usable()->where('code_hash', static::hash($plain))->first(); + if (! $code) { + return null; + } + + $claimed = static::query()->usable()->whereKey($code->getKey())->update(['used_at' => now(), 'updated_at' => now()]); + + return $claimed === 1 ? $code->refresh() : null; + } +} diff --git a/app/Models/Licence.php b/app/Models/Licence.php new file mode 100644 index 0000000..9780e3e --- /dev/null +++ b/app/Models/Licence.php @@ -0,0 +1,93 @@ + 'integer', 'expires_at' => 'datetime', 'meta' => 'array']; + + public function extension(): BelongsTo + { + return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_uuid', 'uuid'); + } + + public function purchase(): BelongsTo + { + return $this->belongsTo(Purchase::class, 'purchase_uuid', 'uuid'); + } + + public function activations(): HasMany + { + return $this->hasMany(LicenceActivation::class, 'licence_uuid', 'uuid'); + } + + public function scopeActive(Builder $query): Builder + { + return $query->where('status', self::STATUS_ACTIVE) + ->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())); + } + + public function isActive(): bool + { + return $this->status === self::STATUS_ACTIVE && ($this->expires_at === null || $this->expires_at->isFuture()); + } + + /** + * Null `max_activations` means unlimited seats (automatic free licences). + */ + public function isUnlimited(): bool + { + return $this->max_activations === null; + } + + public function activeActivations(): HasMany + { + return $this->activations()->whereNull('deactivated_at'); + } + + public function seatsUsed(): int + { + return $this->activeActivations()->count(); + } + + public function hasFreeSeat(): bool + { + return $this->isUnlimited() || $this->seatsUsed() < $this->max_activations; + } +} diff --git a/app/Models/LicenceActivation.php b/app/Models/LicenceActivation.php new file mode 100644 index 0000000..e107194 --- /dev/null +++ b/app/Models/LicenceActivation.php @@ -0,0 +1,34 @@ + 'datetime', 'deactivated_at' => 'datetime']; + + public function licence(): BelongsTo + { + return $this->belongsTo(Licence::class, 'licence_uuid', 'uuid'); + } + + public function instance(): BelongsTo + { + return $this->belongsTo(Instance::class, 'instance_uuid', 'uuid'); + } + + public function release(): BelongsTo + { + return $this->belongsTo(Release::class, 'release_uuid', 'uuid'); + } +} diff --git a/app/Models/Membership.php b/app/Models/Membership.php new file mode 100644 index 0000000..e63b8af --- /dev/null +++ b/app/Models/Membership.php @@ -0,0 +1,54 @@ +belongsTo(Organization::class, 'organization_uuid', 'uuid'); + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class, 'account_uuid', 'uuid'); + } +} diff --git a/app/Models/Model.php b/app/Models/Model.php new file mode 100644 index 0000000..87200c8 --- /dev/null +++ b/app/Models/Model.php @@ -0,0 +1,81 @@ +save(); + + return $this; + } + + public function getResource(): ?string + { + return $this->httpResource ?? $this->resource ?? null; + } + + public function getFilter(): ?string + { + return $this->httpFilter ?? $this->filter ?? null; + } + + /** + * Find a model by UUID or public id. + */ + public static function findById(self|string|null $identifier, array $with = [], array $columns = ['*'], bool $withTrashed = false): ?static + { + if ($identifier instanceof self) { + return $identifier; + } + + if ($identifier === null || $identifier === '') { + return null; + } + + $query = static::query()->with($with)->select($columns); + + if ($withTrashed && in_array(SoftDeletes::class, class_uses_recursive(static::class), true)) { + $query->withTrashed(); + } + + $column = Str::isUuid($identifier) ? static::UUID_COLUMN : static::PUBLIC_ID_COLUMN; + + return $query->where($column, $identifier)->first(); + } + + public static function findByIdOrFail(self|string|null $identifier, array $with = [], array $columns = ['*'], bool $withTrashed = false): static + { + $result = static::findById($identifier, $with, $columns, $withTrashed); + + if ($result === null) { + throw (new ModelNotFoundException)->setModel(static::class, [$identifier]); + } + + return $result; + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 0000000..5f0b80b --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,84 @@ + 'boolean', + 'is_staff' => 'boolean', + 'verified_at' => 'datetime', + 'meta' => 'array', + ]; + + protected static function newFactory(): OrganizationFactory + { + return OrganizationFactory::new(); + } + + public function memberships(): HasMany + { + return $this->hasMany(Membership::class, 'organization_uuid', 'uuid'); + } + + public function accounts(): BelongsToMany + { + return $this->belongsToMany(Account::class, 'memberships', 'organization_uuid', 'account_uuid', 'uuid', 'uuid') + ->using(Membership::class) + ->withPivot('role') + ->withTimestamps(); + } + + public function extensions(): HasMany + { + return $this->hasMany(Extension::class, 'organization_uuid', 'uuid'); + } + + public function instances(): HasMany + { + return $this->hasMany(Instance::class, 'organization_uuid', 'uuid'); + } + + public function licences(): HasMany + { + return $this->hasMany(Licence::class, 'organization_uuid', 'uuid'); + } + + public function isStaff(): bool + { + return $this->is_staff === true; + } + + public function isVerified(): bool + { + return $this->verified_at !== null; + } +} diff --git a/app/Models/Price.php b/app/Models/Price.php new file mode 100644 index 0000000..a8299da --- /dev/null +++ b/app/Models/Price.php @@ -0,0 +1,66 @@ + Money::class, + 'sale_amount' => Money::class, + 'on_sale' => 'boolean', + ]; + + protected static function newFactory(): PriceFactory + { + return PriceFactory::new(); + } + + public function extension(): BelongsTo + { + return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); + } + + public function isFree(): bool + { + return $this->model === self::MODEL_FREE || (int) $this->amount === 0; + } + + /** + * The amount a buyer pays right now, honouring an active sale. + */ + public function getEffectiveAmountAttribute(): int + { + if ($this->on_sale && $this->sale_amount !== null) { + return (int) $this->sale_amount; + } + + return (int) $this->amount; + } +} diff --git a/app/Models/Purchase.php b/app/Models/Purchase.php new file mode 100644 index 0000000..5ba04d9 --- /dev/null +++ b/app/Models/Purchase.php @@ -0,0 +1,61 @@ + 'array', + 'amount_total' => Money::class, + 'fee_amount' => Money::class, + 'net_amount' => Money::class, + 'meta' => 'array', + ]; + + public function extension(): BelongsTo + { + return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); + } + + public function buyer(): BelongsTo + { + return $this->belongsTo(Organization::class, 'buyer_organization_uuid', 'uuid'); + } + + public function licence(): HasOne + { + return $this->hasOne(Licence::class, 'purchase_uuid', 'uuid'); + } +} diff --git a/app/Models/Release.php b/app/Models/Release.php new file mode 100644 index 0000000..328a417 --- /dev/null +++ b/app/Models/Release.php @@ -0,0 +1,209 @@ + 'array', + 'compat' => 'array', + 'size_bytes' => 'integer', + 'published_at' => 'datetime', + 'yanked_at' => 'datetime', + 'meta' => 'array', + ]; + + protected static function newFactory(): ReleaseFactory + { + return ReleaseFactory::new(); + } + + public function extension(): BelongsTo + { + return $this->belongsTo(Extension::class, 'extension_uuid', 'uuid'); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(Account::class, 'created_by_account_uuid', 'uuid'); + } + + public function engineTarball(): BelongsTo + { + return $this->belongsTo(File::class, 'engine_tarball_file_uuid', 'uuid'); + } + + public function apiTarball(): BelongsTo + { + return $this->belongsTo(File::class, 'api_tarball_file_uuid', 'uuid'); + } + + public function engineDist(): BelongsTo + { + return $this->belongsTo(File::class, 'engine_dist_file_uuid', 'uuid'); + } + + public function checks(): HasMany + { + return $this->hasMany(ReleaseCheck::class, 'release_uuid', 'uuid'); + } + + public function reviews(): HasMany + { + return $this->hasMany(Review::class, 'release_uuid', 'uuid')->orderBy('created_at')->orderBy('id'); + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('status', self::STATUS_PUBLISHED); + } + + public function isPublished(): bool + { + return $this->status === self::STATUS_PUBLISHED; + } + + public function isYanked(): bool + { + return $this->status === self::STATUS_YANKED; + } + + public function scopeReleased(Builder $query): Builder + { + return $query->whereIn('status', self::RELEASED_STATUSES); + } + + public function isReplaceable(): bool + { + return in_array($this->status, self::REPLACEABLE_STATUSES, true); + } + + public function metaValue(string $key, mixed $default = null): mixed + { + return data_get($this->meta ?? [], $key, $default); + } + + /** + * Merge keys into `meta` (dot notation allowed) without saving. + */ + public function mergeMeta(array $values): static + { + $meta = $this->meta ?? []; + foreach ($values as $key => $value) { + data_set($meta, $key, $value); + } + $this->meta = $meta; + + return $this; + } + + /** + * The uploaded files by role: `engine`, `api`, `dist`. + * + * @return array + */ + public function archives(): array + { + $this->loadMissing(['engineTarball', 'apiTarball', 'engineDist']); + + return array_filter([ + 'engine' => $this->engineTarball, + 'api' => $this->apiTarball, + 'dist' => $this->engineDist, + ]); + } + + /** + * All automated checks passed (and at least one exists). + */ + public function checksPassed(): bool + { + $checks = $this->relationLoaded('checks') ? $this->checks : $this->checks()->get(); + + return $checks->isNotEmpty() && $checks->every(fn (ReleaseCheck $check) => $check->passed); + } + + /** + * Rule from the data model: publishable when every check passes and the latest + * transition is an approval (by staff, or by the D6 auto-approval). + */ + public function isPublishable(): bool + { + if ($this->status !== self::STATUS_APPROVED || ! $this->checksPassed()) { + return false; + } + + $latestReview = $this->reviews()->reorder()->latest('id')->first(); + + return $latestReview?->action === Review::ACTION_APPROVE; + } +} diff --git a/app/Models/ReleaseCheck.php b/app/Models/ReleaseCheck.php new file mode 100644 index 0000000..0854b86 --- /dev/null +++ b/app/Models/ReleaseCheck.php @@ -0,0 +1,50 @@ + 'boolean', 'output' => 'array']; + + public function release(): BelongsTo + { + return $this->belongsTo(Release::class, 'release_uuid', 'uuid'); + } +} diff --git a/app/Models/Review.php b/app/Models/Review.php new file mode 100644 index 0000000..5401c07 --- /dev/null +++ b/app/Models/Review.php @@ -0,0 +1,67 @@ + 'array']; + + public function release(): BelongsTo + { + return $this->belongsTo(Release::class, 'release_uuid', 'uuid'); + } + + public function reviewer(): BelongsTo + { + return $this->belongsTo(Account::class, 'reviewer_account_uuid', 'uuid'); + } +} diff --git a/app/Models/Token.php b/app/Models/Token.php new file mode 100644 index 0000000..9757744 --- /dev/null +++ b/app/Models/Token.php @@ -0,0 +1,158 @@ + 'array', + 'expires_at' => 'datetime', + 'last_used_at' => 'datetime', + 'revoked_at' => 'datetime', + ]; + + /** Plaintext, only populated on the instance returned by `issue()`. */ + public ?string $plainTextToken = null; + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class, 'account_uuid', 'uuid'); + } + + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_uuid', 'uuid'); + } + + /** + * Create a token for an account and return it with `plainTextToken` set. + */ + public static function issue(Account $account, string $name, array $scopes = [self::SCOPE_ALL], ?\DateTimeInterface $expiresAt = null, ?string $organizationUuid = null, string $prefix = self::PREFIX_PERSONAL): static + { + $plain = $prefix.Str::random(40); + + $token = static::create([ + 'account_uuid' => $account->uuid, + 'organization_uuid' => $organizationUuid, + 'name' => $name, + 'token_hash' => static::hash($plain), + 'prefix' => $prefix, + 'last_four' => substr($plain, -4), + 'scopes' => array_values(array_unique($scopes)), + 'expires_at' => $expiresAt ?? now()->addDays(static::MAX_LIFETIME_DAYS), + ]); + + $token->plainTextToken = $plain; + + return $token; + } + + public static function hash(string $plain): string + { + return hash('sha256', $plain); + } + + /** + * Resolve a plaintext token to its active row, or null. + */ + public static function findActive(string $plain): ?static + { + if ($plain === '') { + return null; + } + + return static::query()->active()->where('token_hash', static::hash($plain))->first(); + } + + public function scopeActive(Builder $query): Builder + { + return $query->whereNull('revoked_at') + ->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now())); + } + + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + public function isRevoked(): bool + { + return $this->revoked_at !== null; + } + + public function can(string $scope): bool + { + $scopes = $this->scopes ?? []; + + return in_array(self::SCOPE_ALL, $scopes, true) || in_array($scope, $scopes, true); + } + + public function revoke(): static + { + if (! $this->isRevoked()) { + $this->forceFill(['revoked_at' => now()])->save(); + } + + return $this; + } + + public function touchUsage(?string $ip): void + { + $this->forceFill(['last_used_at' => now(), 'last_used_ip' => $ip])->saveQuietly(); + } + + /** + * Masked display form, e.g. `flbp_…a1b2`. + */ + public function getMaskedAttribute(): string + { + return $this->prefix.'…'.$this->last_four; + } +} diff --git a/app/Models/VerificationCode.php b/app/Models/VerificationCode.php new file mode 100644 index 0000000..544f18a --- /dev/null +++ b/app/Models/VerificationCode.php @@ -0,0 +1,69 @@ + 'datetime', 'used_at' => 'datetime']; + + public function subject(): MorphTo + { + return $this->morphTo('subject', 'subject_type', 'subject_uuid', 'uuid'); + } + + public static function generateFor(EloquentModel $subject, string $for = self::FOR_EMAIL_VERIFICATION): static + { + // Only one live code per subject and purpose. + static::query()->where('subject_uuid', $subject->getKey())->where('for', $for)->whereNull('used_at')->delete(); + + return static::create([ + 'subject_uuid' => $subject->getKey(), + 'subject_type' => $subject::class, + 'for' => $for, + 'code' => str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT), + 'expires_at' => now()->addMinutes(static::LIFETIME_MINUTES), + ]); + } + + public static function consume(EloquentModel $subject, string $code, string $for = self::FOR_EMAIL_VERIFICATION): bool + { + $record = static::query() + ->where('subject_uuid', $subject->getKey()) + ->where('for', $for) + ->where('code', $code) + ->whereNull('used_at') + ->where('expires_at', '>', now()) + ->first(); + + if (! $record) { + return false; + } + + $record->forceFill(['used_at' => now()])->save(); + + return true; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..91f3a6d --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,95 @@ +app->isProduction()); + + $this->registerAuthGuard(); + $this->registerResponseMacros(); + $this->registerRateLimiters(); + + // Package metadata embeds absolute download URLs; pin them behind proxies. + if ($publicUrl = config('registry.public_url')) { + URL::forceRootUrl($publicUrl); + URL::forceScheme(parse_url($publicUrl, PHP_URL_SCHEME) ?: 'https'); + } + } + + /** + * `auth:token` resolves personal tokens (flbp_…) to accounts; + * `auth:instance` resolves instance tokens (flbi_…) to instances. + */ + protected function registerAuthGuard(): void + { + Auth::viaRequest('registry-token', new TokenGuard(TokenGuard::KIND_PERSONAL)); + Auth::viaRequest('registry-instance-token', new TokenGuard(TokenGuard::KIND_INSTANCE)); + } + + /** + * `response()->error('message', 422)` → { "errors": ["message"], ...extra } + */ + protected function registerResponseMacros(): void + { + Response::macro('error', function ($error, int $statusCode = 400, array $data = []): JsonResponse { + if ($error instanceof MessageBag) { + $error = $error->all(); + } + + return Response::json(array_merge(['errors' => is_array($error) ? array_values($error) : [$error]], $data), $statusCode); + }); + + // Package routes add npm's `error` string (shown by the npm CLI) beside the envelope. + Response::macro('packageError', function (string $message, int $statusCode, array $headers = []): JsonResponse { + return Response::json(['errors' => [$message], 'error' => $message], $statusCode, $headers); + }); + } + + protected function registerRateLimiters(): void + { + RateLimiter::for('auth', fn (Request $request) => [ + Limit::perMinute(10)->by($request->ip()), + Limit::perMinute(5)->by('email:'.strtolower((string) $request->input('email')).':'.$request->ip()), + ]); + + RateLimiter::for('api', fn (Request $request) => Limit::perMinute(120)->by($request->user()?->uuid ?: $request->ip())); + + RateLimiter::for('instances', fn (Request $request) => Limit::perMinute(120)->by($request->user()?->uuid ?: $request->ip())); + + RateLimiter::for('instance-register', fn (Request $request) => Limit::perMinute(10)->by($request->ip())); + + RateLimiter::for('publish', fn (Request $request) => Limit::perMinute(120)->by($request->user()?->uuid ?: $request->ip())); + + // Release uploads carry up to two 50 MB archives and a dist. + RateLimiter::for('upload', fn (Request $request) => Limit::perHour(60)->by($request->user()?->uuid ?: $request->ip())); + + RateLimiter::for('catalog', fn (Request $request) => Limit::perMinute(300)->by($request->ip())); + + // npm and Composer fetch metadata and archives in bursts during an install. + RateLimiter::for('packages', fn (Request $request) => Limit::perMinute(1200)->by($request->bearerToken() ? sha1($request->bearerToken()) : $request->ip())); + } +} diff --git a/app/Releases/Archives/ArchiveInspector.php b/app/Releases/Archives/ArchiveInspector.php new file mode 100644 index 0000000..4b6693f --- /dev/null +++ b/app/Releases/Archives/ArchiveInspector.php @@ -0,0 +1,135 @@ + 'ELF executable', + "\xfe\xed\xfa\xce" => 'Mach-O executable', + "\xfe\xed\xfa\xcf" => 'Mach-O executable', + "\xce\xfa\xed\xfe" => 'Mach-O executable', + "\xcf\xfa\xed\xfe" => 'Mach-O executable', + "\xca\xfe\xba\xbe" => 'Mach-O universal binary or Java class', + 'MZ' => 'Windows (PE) executable', + ]; + + public function __construct(protected ?SecretRules $secrets = null) {} + + public static function isRegularFile(string $type): bool + { + return $type === '0' || $type === '7'; + } + + public function inspect(string $path, string $label, ?string $mirrorDir = null): ArchiveInventory + { + $head = (string) @file_get_contents($path, false, null, 0, 2); + $inventory = new ArchiveInventory($label, $path, (int) @filesize($path), $head === "\x1f\x8b"); + + $maxEntries = (int) config('registry.releases.max_entries', 20000); + $maxUnpacked = (int) config('registry.releases.max_unpacked_bytes', 300 * 1024 * 1024); + $maxFile = (int) config('registry.releases.max_file_bytes', 25 * 1024 * 1024); + $current = null; + + try { + Tarball::walk( + $path, + function (string $name, string $type, int $size, string $raw = '', string $link = '') use ($inventory, $maxEntries, $maxUnpacked, $maxFile, &$current) { + if ($inventory->entryCount() >= $maxEntries || $inventory->unpackedBytes + $size > $maxUnpacked) { + $inventory->truncated = true; + $inventory->unpackedBytes += $size; + + return null; + } + + $read = self::isRegularFile($type) && $size <= $maxFile; + $inventory->entries[] = [ + 'name' => $name, 'raw' => $raw !== '' ? $raw : $name, 'type' => $type, 'size' => $size, + 'link' => $link, 'sha256' => null, 'executable' => null, 'read' => $read, + ]; + $inventory->unpackedBytes += $size; + $current = array_key_last($inventory->entries); + + if ($this->secrets !== null && self::isRegularFile($type) && ! $read) { + array_push($inventory->secrets, ...$this->secrets->scan($name, null)); + } + + return $read; + }, + function (string $name, string $contents) use ($inventory, $mirrorDir, &$current) { + $entry = &$inventory->entries[$current]; + $entry['sha256'] = hash('sha256', $contents); + $entry['executable'] = self::executableKind($contents); + + if (in_array(basename($name), self::KEEP_FILES, true) && strlen($contents) <= self::KEEP_MAX_BYTES) { + $inventory->kept[$name] = $contents; + } + + if ($this->secrets !== null) { + array_push($inventory->secrets, ...$this->secrets->scan($name, $contents)); + } + + if ($mirrorDir !== null && self::isSafePath($entry['raw'])) { + $target = rtrim($mirrorDir, '/').'/'.$name; + @mkdir(dirname($target), 0700, true); + file_put_contents($target, $contents); + } + }, + $maxFile, + ); + } catch (\Throwable $e) { + $inventory->error = $e->getMessage(); + } + + return $inventory; + } + + /** + * A relative path with no `..` segment, drive letter or backslash. + */ + public static function isSafePath(string $raw): bool + { + if ($raw === '' || str_starts_with($raw, '/') || str_contains($raw, '\\') || preg_match('/^[A-Za-z]:/', $raw) === 1) { + return false; + } + + return ! in_array('..', explode('/', $raw), true); + } + + public static function executableKind(string $contents): ?string + { + foreach (self::EXECUTABLE_MAGIC as $magic => $kind) { + if (str_starts_with($contents, $magic)) { + // "MZ" alone is too short to trust; require the PE header offset to point at "PE\0\0". + if ($magic === 'MZ') { + $offset = strlen($contents) >= 64 ? unpack('V', substr($contents, 60, 4))[1] : null; + if ($offset === null || substr($contents, $offset, 4) !== "PE\0\0") { + continue; + } + } + + return $kind; + } + } + + return null; + } +} diff --git a/app/Releases/Archives/ArchiveInventory.php b/app/Releases/Archives/ArchiveInventory.php new file mode 100644 index 0000000..3196484 --- /dev/null +++ b/app/Releases/Archives/ArchiveInventory.php @@ -0,0 +1,61 @@ + */ + public array $entries = []; + + public int $unpackedBytes = 0; + + /** Stopped early: too many entries or too many unpacked bytes. */ + public bool $truncated = false; + + /** Set when the file is not a readable tar / tar.gz archive. */ + public ?string $error = null; + + /** @var list */ + public array $secrets = []; + + /** @var array JSON files kept by name (see ArchiveInspector::KEEP_FILES) */ + public array $kept = []; + + public function __construct( + public readonly string $label, + public readonly string $path, + public readonly int $compressedBytes, + public readonly bool $gzipped, + ) {} + + public function entryCount(): int + { + return count($this->entries); + } + + /** + * Regular file entries. + * + * @return list + */ + public function files(): array + { + return array_values(array_filter($this->entries, fn (array $entry) => ArchiveInspector::isRegularFile($entry['type']))); + } + + public function file(string $name): ?array + { + foreach ($this->entries as $entry) { + if ($entry['name'] === $name && ArchiveInspector::isRegularFile($entry['type'])) { + return $entry; + } + } + + return null; + } +} diff --git a/app/Releases/Archives/ReleaseArchives.php b/app/Releases/Archives/ReleaseArchives.php new file mode 100644 index 0000000..ad95f3a --- /dev/null +++ b/app/Releases/Archives/ReleaseArchives.php @@ -0,0 +1,89 @@ +/