diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bcdb71b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +.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 +public/build +public/hot +bootstrap/ssr +playwright-report +test-results +storage/e2e 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..a03fe3f --- /dev/null +++ b/.env.docker @@ -0,0 +1,13 @@ +# 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 +REGISTRY_ANALYTICS_CACHE_SECONDS=60 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..67314e8 --- /dev/null +++ b/.env.example @@ -0,0 +1,113 @@ +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 + +# Publisher website cookie sessions (the `web` guard). The database store uses the `sessions` +# table; SESSION_LIFETIME (minutes) is also the sliding lifetime of the flbs_ session token. +SESSION_DRIVER=database +SESSION_LIFETIME=120 +# true behind HTTPS (staging, production) +SESSION_SECURE_COOKIE=false +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}" + +# Sign in with GitHub (an OAuth app). Leave the id and secret empty to hide the button. +# The callback defaults to ${APP_URL}/auth/github/callback. +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GITHUB_REDIRECT_URI= + +# Stripe (Connect, Checkout, webhooks). Commerce routes answer 503 until STRIPE_SECRET is set. +STRIPE_KEY= +STRIPE_SECRET= +# Comma-separated signing secrets: the platform endpoint's and the Connect endpoint's. +STRIPE_WEBHOOK_SECRET= +REGISTRY_FACILITATOR_FEE_BPS=1000 +REGISTRY_LICENCE_MAX_ACTIVATIONS=5 +REGISTRY_STRIPE_TAX=true +REGISTRY_STRIPE_TAX_CODE= +REGISTRY_COMMERCE_NOTIFY= + +# 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 +REGISTRY_ANALYTICS_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..61af796 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,214 @@ +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 + + website: + name: Website (lint, Vitest, build) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Lint (ESLint) + run: npm run lint + + - name: Vitest + run: npm test + + - name: Build (client and SSR) + run: npm run build + + - name: Upload build + uses: actions/upload-artifact@v4 + with: + name: website-build + path: | + public/build + bootstrap/ssr + retention-days: 3 + + website-e2e: + name: Website smoke test (Playwright) + runs-on: ubuntu-latest + needs: [tests, website] + 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, gd, zip, pdo_sqlite, sqlite3, phar, zlib + coverage: none + tools: composer:v2 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + + - name: Install dependencies + run: | + composer install --no-interaction --prefer-dist --no-progress + npm ci --no-audit --no-fund + npx playwright install --with-deps chromium + + # The artifact keeps paths from the repository root: public/build and bootstrap/ssr. With + # the SSR bundle present, scripts/e2e-server.sh also runs the renderer. + - name: Download the website build + uses: actions/download-artifact@v4 + with: + name: website-build + path: . + + - name: Run the smoke test + run: CI=true npx playwright test + + - name: Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report + test-results + storage/logs + retention-days: 7 + + 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..e5987c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,29 @@ -.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* +/bootstrap/ssr +/playwright-report +/test-results +/storage/e2e 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..a81e104 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,74 @@ +# 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 + +# ---- Website assets --------------------------------------------------------- +# Vite builds the Inertia (React) client into public/build and the server-side renderer into +# bootstrap/ssr (one self-contained file; the runtime runs it with Node, no node_modules). +FROM node:22-bookworm-slim AS assets +WORKDIR /app +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm \ + npm ci --no-audit --no-fund +COPY vite.config.js ./ +COPY resources ./resources +RUN npm run build + +# ---- 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 nodejs + +# Inertia SSR as an s6 service beside nginx and php-fpm (docker/s6/inertia-ssr/run). +COPY --chmod=755 docker/s6/inertia-ssr /etc/s6-overlay/s6-rc.d/inertia-ssr +RUN touch /etc/s6-overlay/user-bundles.d/user/contents.d/inertia-ssr + +WORKDIR /var/www/html +COPY --chown=www-data:www-data . . +COPY --from=vendor --chown=www-data:www-data /app/vendor ./vendor +COPY --from=assets --chown=www-data:www-data /app/public/build ./public/build +COPY --from=assets --chown=www-data:www-data /app/bootstrap/ssr ./bootstrap/ssr + +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..7927c68 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,787 @@ # 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 | +| Website backend (Workstream F): cookie sessions, organizations and members, listing media and pricing, staff downloads and review items | done | +| Website (Workstream F): Inertia (React) catalog, account, Studio and review console, light and dark | done | +| Commerce (Phase 4, K to M): Stripe Connect Express, prices mirrored to Stripe, Checkout from instances and the website, webhooks, payouts and billing APIs and pages, staff finance and refunds | done | +| Publisher analytics: listing and organization stats APIs with CSV, Studio Insights | done | +| CLI v2 (G), staff finance view | 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 website is Inertia v3 with +React 19, Tailwind v4 and Vite, served by the same Laravel app. 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 - ``` +```bash +composer install +cp .env.example .env && php artisan key:generate +php artisan migrate --seed +php artisan serve +``` -2. **Set Up the Environment**: +Full stack with Docker: - ``` - sh install-plugins.sh - ``` +```bash +docker compose up --build +``` -3. **Build and Run with Docker Compose**: +| Service | URL | +| --- | --- | +| API | http://localhost:8000 | +| MinIO console | http://localhost:9001 (minio / miniosecret) | +| Mailpit (verification emails) | http://localhost:8025 | +| MySQL | localhost:3307 (registry / registry) | - ``` - docker-compose up --build - ``` +The website needs its assets. Build them once, or run Vite beside `artisan serve` for hot +reload (Node 22). `docker compose` mounts the checkout, so build on the host there too: -### Configuration +```bash +npm install +npm run build # public/build (client) and bootstrap/ssr (SSR bundle) +php artisan inertia:start-ssr # optional: server-side rendering on 127.0.0.1:13714 +npm run dev # or: Vite dev server with hot reload +``` -Coming soon. +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`). -## Usage +Run the tests: -### Publishing a Package +```bash +composer test # Pest +npm test # Vitest (components and page logic) +npm run lint # ESLint +npm run test:e2e # Playwright smoke test (run npm run build first) +``` + +The Playwright test boots its own server (`scripts/e2e-server.sh`: a fresh SQLite database in +`storage/e2e`, `registry:seed-website`, a synchronous queue) on port 8123. + +`php artisan registry:seed-website [--json]` (never in production) adds what the website's +smoke test needs on top of that: `staff@registry.test` in a staff organization and +`publisher@registry.test` owning `acme`, both with password `password123`, a submitted release +in the review queue and a draft listing. + +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. + +## Website + +The website lives in this app: Laravel routes in `routes/web.php` render Inertia pages from +`app/Http/Controllers/Web`, and every path that is not `/v1`, `/npm`, `/composer` or `/up` is +a website page (unknown paths render the website's 404). + +- **Reads.** Page controllers call the `/v1` API controllers in process + (`WebController::api`), so page props have exactly the API's JSON shapes and share the + catalog cache. +- **Writes.** Pages call `/v1` from the browser with the session cookie and the `XSRF-TOKEN` + header (`resources/js/lib/api.js`), then reload their props. +- **Sessions.** Signing in (`POST /v1/auth/session`) puts an `flbs_` token in the Laravel + session; the `web` guard resolves it. Website routes use `auth:web`, which redirects to + `/sign-in`. +- **Console version.** The "works with my console" checks read `?console=` or the + `registry_console` cookie (unencrypted, so the page can read it too), set by `POST /console`. +- **Pages.** `resources/js/pages` (Discover, Extension, Publisher, Auth, Account, Studio, + Review), shared components in `resources/js/components`, the layout in + `resources/js/layouts/SiteLayout.jsx`, the design tokens in `resources/css/registry.css` + (`data-theme` on ``: light, dark, or the system setting). +- **SSR.** `npm run build` also builds `bootstrap/ssr/ssr.js`, one self-contained file run + with Node. The Docker image runs it as an s6 service (`docker/s6/inertia-ssr`) beside nginx + and php-fpm, so first page loads arrive rendered; `INERTIA_SSR_ENABLED=false` turns it off. + Whenever the renderer is not running, pages render in the browser instead. + +| Path | Page | +| --- | --- | +| `/`, `/extensions/{slug}`, `/publishers/{slug}` | Discover, extension, publisher | +| `/sign-in`, `/register`, `/verify`, `/forgot-password` | Account access | +| `/auth/github`, `/auth/github/callback` | Sign in with GitHub (redirects; see below) | +| `/account/profile`, `/account/organizations/{slug?}`, `/account/tokens` | Account | +| `/account/billing/{slug?}[?handoff=]` | Billing: purchases, receipts, licences and their instances; Finalizing after a website checkout | +| `/studio`, `/studio/new`, `/studio/{slug}` (listing), `/studio/{slug}/releases[/{id}]`, `/studio/{slug}/review`, `/studio/{slug}/history` | Publisher Studio | +| `/studio/insights/{organization?}[?extension=&from=&to=&group=]` | Insights: installs, uninstalls, active instances, purchases and net revenue against the previous period, a trend chart, active installs by version, recent events, CSV (see Publisher analytics) | +| `/studio/payouts/{slug?}` | Payouts: Stripe onboarding and account management (Connect.js), balance, schedule, sales and CSV | +| `/review`, `/review/publish`, `/review/releases/{id}`, `/review/finance` | Staff review console (finance: fee revenue, refunds, disputes, per-publisher totals, refunds) | + +## 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/password/forgot` | email. Always 202 with the same body; an existing, unsuspended account gets a 6-digit reset code by email (30 minutes) | +| `POST /v1/auth/password/reset` | email, code, password, password_confirmation (registration's password rule). Sets the password, revokes every token and website session of the account, marks the email verified. 422 on `code` when it is wrong, used or expired | +| `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/auth/session` | The website's session: `{authenticated, account}`. Always 200; sets the session and `XSRF-TOKEN` cookies | +| `POST /v1/auth/session` | email + password. Signs the website in with a cookie session (same errors as `login`); 400 with a Bearer header | +| `DELETE /v1/auth/session` | Signs the website out: revokes the session token and invalidates the session | +| `GET /v1/account` | The caller's account with organizations, roles and `is_staff` | +| `PATCH /v1/account` | name, username, timezone | +| `POST /v1/account/password` | current_password, password, password_confirmation. Also ends the account's other website sessions | +| `DELETE /v1/account/github` | Disconnects GitHub; returns the account. 422 when GitHub is not connected, or (`code: password_required`) when the account has no password to sign in with | +| `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 | +| `GET /v1/organizations` | The caller's organizations with role and member count | +| `POST /v1/organizations` | name, optional slug, website, bio. The caller becomes owner | +| `GET /v1/organizations/{slug}` | Profile and members (members only) | +| `PATCH /v1/organizations/{slug}` | name, website, bio, avatar_url (owner or admin; the slug never changes) | +| `POST /v1/organizations/{slug}/members` | email + role. The email must belong to a registry account; personal organizations have one member | +| `PATCH /v1/organizations/{slug}/members/{account}` | role. Only owners grant or change the owner role | +| `DELETE /v1/organizations/{slug}/members/{account}` | Owner or admin, or a member leaving. The last owner cannot be removed or demoted | +| `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 | + +**Website sessions.** Every account, organization, publisher and admin route takes either a +Bearer `flbp_` token or the website's session cookie (`auth:token,web`). Signing in through +`POST /v1/auth/session` issues an `flbs_` token row that never leaves the server; the session +holds its id, it expires with `SESSION_LIFETIME` and slides forward while used, and it is +revoked on sign-out. Requests without a Bearer header that carry the session or `XSRF-TOKEN` +cookie (and `/v1/auth/session` itself) run through Laravel's session and CSRF middleware, so +POST, PUT, PATCH and DELETE need `X-XSRF-TOKEN` (419 otherwise). Bearer clients, the catalog and +the instance API never get cookies. Set `SESSION_SECURE_COOKIE=true` behind HTTPS. + +**Codes.** Verification and password-reset codes are six digits, one live code per account and +purpose (a verification code never resets a password), valid 30 minutes and dead after five +wrong guesses; the `auth` rate limit (10 a minute per IP, 5 per email and IP) covers every +route that takes one. + +**Sign in with GitHub.** Set `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` (a GitHub OAuth app +whose callback is `GITHUB_REDIRECT_URI`, by default `{APP_URL}/auth/github/callback`); the +website shows "Continue with GitHub" only then (`auth.providers.github`). `GET /auth/github` +asks for `read:user user:email`. The callback signs in the account linked to that GitHub user, +else the account whose email is the GitHub user's verified primary email (and links it), else +creates an account (personal organization, the GitHub login as username when free, email +verified, no password), then signs the website in like `POST /v1/auth/session`. Signed in +already, the callback connects GitHub to the current account instead (refused when another +account has that GitHub user). Suspended accounts, a cancelled or expired GitHub round trip and +a GitHub user without a verified email go back to `/sign-in` with a message. + +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=&console=&compatible=&sort=&page=&limit=` | Paged listings with publisher, category, latest release, install count, compat, price, package names. `console=` adds `compatibility: {console, compatible, newest_compatible}` to each item (does the latest release work, and the newest published release that does); with `compatible=1` only listings that have such a release | +| `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 | + +Catalog releases (`latest_release`, each `versions` entry, `/releases`) and the instance +`updates` releases carry `dist`: `null`, or `{size, sha256, integrity, built_against}` for +a release with a prebuilt engine dist, where `built_against` holds the +`@fleetbase/ember-ui` and `@fleetbase/ember-core` versions it was built with. The archive +is `GET /npm/{engine}/-/dist/{version}`, behind the licence gate. +| `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) + +Package routes sit outside `/v1` because the clients treat them as registry roots. Errors +carry npm's `error` string beside the usual `errors` array. -Update composer.json +| 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 | +| `GET /npm/{name}/-/dist/{version}` | The release's prebuilt engine dist as uploaded (`fleetbase-build-engine-release` output, tar or tar.gz), which a console installs as a runtime engine without a rebuild. Not an npm protocol route. `X-Checksum-Sha256`, ETag, 304; 404 when the release has no dist. Yanked versions stay downloadable. Catalog releases carry its `dist: {size, sha256, integrity, built_against}` | +| `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, prebuilt dist, +p2 and Composer 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 (a revoked instance counts as none), 403 with one. Paid responses +are `Cache-Control: private`. + +Downloads check the organization's licence only, not a seat on the calling instance. +Seats are claimed when the instance reports the install (`POST /v1/instances/me/installs` +with `installing`, which returns 403 when the licence has no free seat), and a personal +`install` token of the same organization could fetch the package anyway, so a seat check +at download time would add no enforcement. It would also break downloads from developer +machines and CI, which have no instance. + +**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, `readiness` and release summaries (`submitted_at`, `decided_at`: last approved or rejected) | +| `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/packages/availability` | Query `organization` (slug; default as for create), `npm` and/or `composer`. For each name given, `{name, available, reason}` under `npm`/`composer`, by the rules create applies (format, reserved `@fleetbase`/`fleetbase`, the organization's namespaces, names already registered; a name one of the caller's listings holds is not available for a new one) | +| `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 | +| `POST`/`DELETE /v1/publisher/extensions/{slug}/icon` | Multipart `icon` (PNG, JPG or WebP, up to 1 MB) replaces the icon; DELETE removes it | +| `POST /v1/publisher/extensions/{slug}/screenshots` | Multipart `screenshot` (PNG, JPG or WebP, up to 5 MB), optional `caption`; at most 8 | +| `PATCH /v1/publisher/extensions/{slug}/screenshots` | `order`: every screenshot id once, cover first. `DELETE …/screenshots/{id}` removes one | +| `PUT /v1/publisher/extensions/{slug}/price` | `model` (`free`, `one_time`), `amount` (minor units), `currency`, optional `sale_amount` (below `amount`) and `on_sale`. Paid prices need the organization's Stripe account (see Commerce). Saving mirrors the price to Stripe first (a refused price is not saved, 502); a price change sends the next release to review | +| `GET /v1/publisher/extensions/{slug}/releases` | Every release with its checks | +| `GET /v1/publisher/extensions/{slug}/stats?from=&to=&group=` | The listing's analytics (see Publisher analytics); `stats.csv` with the same query is the series as CSV | +| `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 | +| `PATCH /v1/publisher/releases/{id}` | `notes` (nullable, up to 20,000 characters, as on upload). Any status but `yanked` (422). No review; a published release's notes show in the catalog at once | +| `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): + +| 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`. Each release has `previous_version`: the extension's latest published (not yanked) release before it, null for a first release | +| `GET /v1/admin/releases/{id}` | A release with checks, policy reasons and timeline, `previous_version`, and `previous` (`id`, `version`, `published_at`, `compat`, `manifest`, `size_bytes` of that release, or null). The claim, approve, reject and publish responses have the same shape | +| `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). From submitted, in_review or approved (request changes on an approval before it is published); otherwise 409 | +| `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 | +| `POST /v1/admin/extensions/{slug}/unsuspend` | Optional `reason`. Restores the status saved at suspension | +| `GET /v1/admin/releases/{id}/files/{engine,api,dist}` | Downloads an archive for review; recorded as `release.downloaded` | +| `GET /v1/admin/review-items` | The review requirements R1 to R6 (`registry.review.items`); approve and reject accept only these codes | +| `GET /v1/admin/facilitator-fees` | The fee in effect (`current_rate_bps`), the configured default, and every scheduled fee, newest first | +| `GET /v1/admin/licences?organization=&extension=&status=&kind=` | Licences (100 newest) with organization, purchase and seats in use | +| `POST /v1/admin/licences/{id}/revoke` | Required `reason`. New installs and updates stop; running instances keep their seat | +| `POST /v1/admin/licences/{id}/reinstate` | Optional `reason`. For a won dispute or a mistaken revoke | +| `PATCH /v1/admin/licences/{id}` | `max_activations`: seats, at least the seats in use | +| `POST /v1/admin/facilitator-fees` | `rate_bps` (0 to 5000; 1000 = 10%), optional `effective_from` (now or later, never earlier) and `note`. Adds a row; rows are never edited | +| `GET /v1/admin/finance`, `/finance/purchases`, `/finance/monthly.csv`, `POST /v1/admin/purchases/{id}/refund` | Staff finance and refunds; see Commerce | + +### 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`, `previous_token`. Redeems the code (single use, 15 minutes) and returns `{token, token_type, rotated, instance}`; the token is never shown again. 201 for a new instance, 200 when an existing one was rotated (see re-registration below) | +| `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}` | + +Re-registration: + +- Registering an instance that is already registered rotates it instead of creating a second + one: the same `instances` row (same `inst_` id) gets the new name, host and versions and a + new token, the old token stops working, and its installs and licence seats stay where they + are. The response is 200 with `rotated: true`. +- The instance is matched by `previous_token` (its current token, which the console and + `flb instance register` send) when it belongs to an active instance of the code's + organization; else by `host`, when an active instance of that organization has the same host + (scheme, case and a trailing slash ignored; the most recently seen one when there are + several). A console that lost its token is therefore still rotated from the same host. +- A new host, an empty host, or a host registered only in another organization creates a new + instance (201). +- A `previous_token` of an instance in another organization moves the console: a new instance + is created in the code's organization, the old one is revoked with its seats released, and + its open installs move to the new instance, which claims seats in its own organization on its + next install report. +- The `instance.registered` or `instance.reregistered` event records `matched_by` (`token` or + `host`) and, for a move, the `replaced` instance. + +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. +- Each row records its lifecycle for analytics: `installed_at` when it succeeds, then + `replaced_at` when a newer install on the instance takes over (that row gets + `previous_release_uuid`: an update) or `uninstalled_at` when the instance removes it. +- `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. + +## Commerce (Stripe) + +Paid extensions are sold through Stripe with Connect Express accounts for publishers and hosted +Checkout for buyers. Every commerce route answers 503 until `STRIPE_SECRET` is set. + +**Setup.** Set `STRIPE_KEY` (publishable, for Connect.js on the website) and `STRIPE_SECRET`. +Create two webhook endpoints in the Stripe Dashboard, both pointing at +`https:///v1/webhooks/stripe`: + +- *Your account*: `checkout.session.completed`, `checkout.session.async_payment_succeeded`, + `checkout.session.expired`, `charge.refunded`, `charge.dispute.created`. +- *Connected accounts*: `account.updated`. + +Put both signing secrets in `STRIPE_WEBHOOK_SECRET`, comma-separated. After `registry:import`, +run `php artisan registry:stripe-sync` once: it reads every imported Connect account's state and +mirrors every paid price that has no Stripe price yet (checkout also does this lazily). + +| Setting | Default | Meaning | +| --- | --- | --- | +| `REGISTRY_FACILITATOR_FEE_BPS` | `1000` | Fee before the first `facilitator_fees` row takes effect | +| `REGISTRY_LICENCE_MAX_ACTIVATIONS` | `5` | Seats (instances) on a paid licence (D4) | +| `REGISTRY_STRIPE_TAX` | `true` | Stripe Tax on Checkout, with the publisher's account liable | +| `REGISTRY_STRIPE_TAX_CODE`, `REGISTRY_STRIPE_TAX_BEHAVIOR` | none, `exclusive` | Product tax code; whether listing prices exclude tax | +| `REGISTRY_CHECKOUT_EXPIRES_MINUTES` | `60` | How long a Checkout Session stays open (30 to 1440) | +| `REGISTRY_CONNECT_COUNTRY` | none | Country for new Connect accounts; empty lets the publisher choose | +| `REGISTRY_COMMERCE_NOTIFY` | none | Comma-separated emails told about new disputes | + +**How money moves.** A Checkout Session is a destination charge: the buyer pays the platform, +`application_fee_amount` (the facilitator fee in effect when the session starts, on the price +before tax) stays with Fleetbase, and the rest is transferred to the publisher's account. With +Stripe Tax on, the charge is made `on_behalf_of` the publisher and the publisher's account is +liable for tax, so the tax collected reaches the account that files it. Platform-side Stripe +fees and losses fall on Fleetbase (`controller.fees.payer` and `losses.payments` are +`application`, as the console's accounts are today). + +**Prices.** One Stripe product per listing on the platform account, the regular price, and the +sale price as a second Stripe price; `on_sale` decides which one Checkout charges. Stripe prices +cannot change, so a new amount or currency creates a new price and archives the old one. + +**Licences and seats.** A purchase gives the buyer organization one paid licence with 5 seats. +A seat is one Fleetbase instance registered to that organization: installing on an instance +takes a seat, uninstalling frees it, and every company (tenant) on that instance is covered by +the one install. Buying again adds another licence with 5 more seats; installs use the seats +of all the organization's active licences. Another registry organization buys its own licence. +Staff can change seats, revoke and reinstate licences (Admin API). + +### Organization routes (personal token or website session) + +| Method and path | Who | Purpose | +| --- | --- | --- | +| `GET /v1/organizations/{slug}/stripe[?refresh=1]` | members (refresh: owners, admins) | `connected`, `payout_status` (`pending`, `restricted`, `enabled`, `rejected`), `can_receive_payments`, `requirements_due`, country, default currency | +| `POST /v1/organizations/{slug}/stripe/account` | owners, admins | Create the Express account (once; 201, then 200) | +| `POST /v1/organizations/{slug}/stripe/account-session` | owners, admins | `client_secret` and `publishable_key` for Connect.js: onboarding, account management, notification banner, payouts, tax settings and registrations | +| `GET /v1/organizations/{slug}/payouts` | owners, admins, billing | Connect status, `balance` (`available`, `pending`, `in_transit` per currency), payout `schedule`, the last 10 payouts | +| `GET /v1/organizations/{slug}/payouts/transactions?from=&to=&status=&extension=&page=` | owners, admins, billing | Sales of the organization's extensions: `gross`, `fee`, `net`, `tax`, `refunded`, `status`, `customer`; `totals` per currency, leaving out refunded sales | +| `GET /v1/organizations/{slug}/payouts/transactions.csv` | owners, admins, billing | The same sales (same filters) as CSV | +| `GET /v1/organizations/{slug}/billing/purchases` | owners, admins, billing | What the organization bought: amount, tax, who bought it, status, Stripe receipt URL, licence | +| `GET /v1/organizations/{slug}/billing/licences` | members | Licences with seat use and the instances activated on each | +| `POST /v1/purchases/checkout` | owners, admins, billing of the buyer | Website checkout: `extension`, `organization`. Returns a hand-off (below); Stripe returns the buyer to `/account/billing/{slug}?handoff=` | +| `GET /v1/purchases/{handoff}` | members of the buyer | The hand-off | + +### Staff finance and refunds (admin middleware: `review` scope, `is_staff` organization) + +The finance view reads the registry's own `purchases`, not Stripe, so it works without Stripe +and matches what the webhooks recorded. A purchase counts in the range its sale date falls in, +with its refunds and dispute, so a November refund of an October sale changes October. Amounts +are minor units per currency. Every bucket carries: + +| Field | Meaning | +| --- | --- | +| `sales`, `gross` | Purchases and `amount_total` (tax included) | +| `refunds`, `refunded` | Purchases with a refund, and `meta.amount_refunded` (all of `gross` when `refunded` without it) | +| `fee`, `fee_refunded`, `fee_revenue` | `fee_amount`; the fee returned on refunds, in proportion to the amount refunded (`refund_application_fee`); fee less fee returned | +| `publisher_net` | `net_amount` less the publisher's share of refunds (the reversed transfer) | +| `disputes`, `disputed` | Purchases with `meta.dispute`, and the disputed amount. Not subtracted: the outcome is decided in Stripe | + +| Method and path | Purpose | +| --- | --- | +| `GET /v1/admin/finance?from=&to=` | `totals` per currency, `months` per month and currency, `publishers` per organization and currency (highest fee revenue first). Default range: the last 12 months including this one | +| `GET /v1/admin/finance/purchases?from=&to=&status=&publisher=&extension=&page=` | Purchases newest first: publisher, customer, gross, fee, net, `refunded`, `fee_refunded`, `refundable` (what is left; 0 while disputed), `dispute`, Stripe ids | +| `GET /v1/admin/finance/monthly.csv?from=&to=[&by=publisher]` | One row per month and currency (and publisher), the fields above in major units | +| `POST /v1/admin/purchases/{id}/refund` | `amount` (minor units, defaults to what is left), `reason` (`requested_by_customer`, `duplicate`, `fraudulent`), `note`. Creates a Stripe refund on the payment intent with `reverse_transfer` and `refund_application_fee`, so the publisher and Fleetbase each give back their share; records `purchase.refund_requested`. Answers 202: the purchase turns `refunded` and its licence `revoked` when `charge.refunded` arrives. 409 when already refunded or disputed (answer disputes in the Stripe Dashboard), 422 above what is left, 502 when Stripe refuses | + +Refund from the Finance page (`/review/finance`) rather than the Stripe Dashboard: a Dashboard +refund does not reverse the transfer or return the fee unless both boxes are ticked. + +### Webhooks + +`POST /v1/webhooks/stripe` checks the `Stripe-Signature` header against each secret (400 when +none matches or the timestamp is more than 5 minutes off) and stores each event id once in +`events.external_id`, in the same transaction as its effects: a redelivery answers +`{"status": "duplicate"}` and a failure answers 500 with nothing written, so Stripe's retry is +safe. + +| Event | Effect | +| --- | --- | +| `checkout.session.completed` (paid), `checkout.session.async_payment_succeeded` | Purchase (`amount_total`, `fee_amount` from the PaymentIntent's application fee, `net_amount` = total − fee, tax and subtotal in `meta`, receipt URL from the charge), a `paid` licence with 5 seats, the hand-off `complete`, a receipt email to the buyer | +| `checkout.session.expired` | The hand-off `expired` | +| `charge.refunded` | Full refund: purchase `refunded`, licence `revoked` (new installs and updates stop; running instances keep their seat). Partial refund: `meta.amount_refunded` only | +| `charge.dispute.created` | Purchase `disputed` with the dispute in `meta`, licence `revoked` (`revoked_reason: disputed`; staff reinstate it if the dispute is won), email to `REGISTRY_COMMERCE_NOTIFY` | +| `account.updated` | The organization's `payout_status` and `meta.stripe` | + +### Instance checkout (contract for registry-bridge) + +A console buys a paid extension for its instance's organization in four steps. All calls use the +instance token (`Authorization: Bearer flbi_…`). + +1. **Start.** `POST /v1/instances/me/purchases/checkout` + + ```json + { + "extension": "route-optimizer", + "return_url": "https://console.example.com/extensions/purchased", + "cancel_url": "https://console.example.com/extensions", + "email": "ops@example.com" + } + ``` + + `extension` is a slug, `ext_` id or package name. `return_url` (http or https) is where Stripe + sends the operator after paying; `cancel_url` defaults to it. Both must be on the host the + instance registered (`instances.host`) or its domain: an instance registered as `api.acme.com` + may use `api.acme.com`, `acme.com` or `console.acme.com`; `localhost` and IP addresses match + only themselves (any port). `email` prefills Checkout and is optional. Answers `201`: + + ```json + { + "handoff": "chk_x1y2z3a4b5", + "status": "open", + "source": "instance", + "extension": { "id": "ext_…", "slug": "route-optimizer", "name": "Route Optimizer" }, + "organization": { "id": "org_…", "slug": "globex", "name": "Globex" }, + "amount": 4900, + "currency": "USD", + "url": "https://checkout.stripe.com/c/pay/cs_…", + "expires_at": "2026-10-24T13:00:00.000000Z", + "completed_at": null, + "purchase": null, + "created_at": "2026-10-24T12:00:00.000000Z" + } + ``` + + `amount` is the price before tax (the sale price while a sale is on). Refusals use the usual + `{"errors": [...]}` envelope: 404 unknown extension; 409 when the organization publishes the + extension; 422 when a return URL is off the instance's host, the extension is not published, + is free, does not match the instance's hosting kind, or the publisher cannot take payments yet; + 503 when the registry has no Stripe configuration; 502 when Stripe refuses. An organization + that already holds a licence may buy again: each purchase is another licence with 5 more seats. + +2. **Redirect** the operator's browser to `url` (a full-page redirect; Checkout cannot be framed). + +3. **Return.** Stripe sends the operator to `return_url` with `handoff=` appended + (`…/purchased?handoff=chk_x1y2z3a4b5`), or to `cancel_url` with `handoff` and `cancelled=1`. + The return proves nothing about payment. + +4. **Poll** `GET /v1/instances/me/purchases/{handoff}` (any checkout of the instance's + organization) every 2 seconds, showing *Finalizing*, until `status` is no longer `open`: + + - `complete`: `purchase` is set, with `status`, `amount_total` (with tax), `amount_tax`, + `receipt_url` and `licence` (`kind: paid`, `max_activations`). Install as usual; the install + claims a seat. + - `expired`: the session ran out (or `expires_at` passed) without payment. Offer to start again. + + The webhook usually lands within seconds. If it has not after about a minute, tell the + operator the purchase will appear once Stripe confirms it; the licence arrives by webhook even + if the console tab is closed, and `GET /v1/instances/me` lists it. + +## Publisher analytics + +Studio Insights and the stats APIs report, for a date range against the period of the same +length just before it: installs, updates, uninstalls, active instances, purchases and revenue, +a series per day, week (Monday to Sunday) or month, active installs per version (one listing) +or a row per listing (an organization), and the latest 25 events. Days are UTC. + +| Method and path | Who | Returns | +| --- | --- | --- | +| `GET /v1/publisher/extensions/{slug}/stats?from=&to=&group=day\|week\|month` | owners, admins, publishers (`publish` scope) | `extension`, `range`, `previous`, `as_of`, `totals`, `previous_totals`, `series`, `versions`, `events: {data, total}` | +| `GET /v1/organizations/{slug}/stats?from=&to=&group=` | owners, admins, publishers, billing | `organization`, the same report across every listing (deleted listings included while they have figures in the range), `extensions` (a row per listing) instead of `versions` | +| `GET …/stats.csv` (both) | as above | The series as CSV: `period, installs, updates, uninstalls, purchases`, then `gross_`, `fee_`, `net_` per currency (major units) | + +`from` and `to` are `YYYY-MM-DD`, inclusive, at most 731 days apart; without them, the last +30 days. Without `group`: days up to 92 days, weeks up to a year, else months. Reports are +cached for `REGISTRY_ANALYTICS_CACHE_SECONDS` (60); `as_of` says when one was built. + +What counts: + +- **Installs** are installs that succeeded (`installed_at`) on an instance that did not have the + listing; **updates** replaced the instance's previous install (`previous_release_uuid`, + including a reinstall of the same version); **uninstalls** removed one that had been installed + (`uninstalled_at`). Failed attempts count nowhere. +- **Active instances** are installed rows at the end of the range, the same count as the + catalog's `installs_count` when the range reaches today; for a past range, rows installed by + then and not yet uninstalled or replaced. `installs_count` stays derived from `installs`; there + is no counter column. +- **Revenue** per currency, minor units, by the date of the sale, as staff Finance counts it: + `gross`, `refunded`, `fee` (Fleetbase's fee after refunds) and `net` (the publisher's share after + refunds), so a refund changes the range its sale fell in. Refund events list on the date Stripe + reported them. + +**Why no rollup table.** The report reads `installs` and `purchases` directly: two grouped +range scans for the series, one count for active instances, one for versions, and four +`LIMIT 25` reads for events, each on an index: `installs (extension_uuid, installed_at, +previous_release_uuid)`, `(extension_uuid, uninstalled_at, installed_at)`, `(extension_uuid, +status, release_uuid)` and `purchases (extension_uuid, created_at)`. The two lifecycle indexes +carry the other column their query reads, so the series never touches the table, and active +instances for a past range are walked back from today's count instead of scanning history. +Measured on SQLite with 230,000 installs on one listing (about 280 a day for two years, +196,000 still active), on a heavily loaded machine, so read these as upper bounds: + +| Report | Time | +| --- | --- | +| One listing, 30 days by day | 0.23 s | +| One listing, a year by week | 0.7 s | +| One listing, two years by month | 0.9 s | +| An organization of 50 listings, 30 days to two years | 0.4 to 0.8 s | + +Most of that is counting the active installs, which the catalog's `installs_count` already does +for every listing it shows. The registry expects tens of listings with installs in the hundreds, +so a daily rollup table and its scheduled command would be one more thing to keep correct +(late reports, refunds, deleted listings) with nothing to gain yet. Add one (per listing, day +and version, rebuilt nightly for the last few days) when a listing's report runs past a few +hundred milliseconds in production. + +## 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) | -We welcome contributions to the Fleetbase Extensions Registry! Please read our [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines on how to make a contribution. +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 + Http/Controllers/Web/ website pages (Inertia), reading through the /v1 controllers + 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, HandleInertiaRequests (shared props) + 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) +resources/js/ website: pages/, components/, layouts/, lib/ (Inertia + React) +resources/css/ Tailwind entry and the design tokens (registry.css) +e2e/, playwright.config.js Playwright smoke test; scripts/e2e-server.sh boots its server +tests/Feature/{Auth,Catalog,Packages,Instance,Import,Releases,Website} 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..c1d9321 --- /dev/null +++ b/app/Actions/RegisterAccount.php @@ -0,0 +1,99 @@ +uniqueUsername($input['email']) : null); + + $account = Account::create([ + 'name' => $input['name'], + 'email' => Str::lower($input['email']), + 'username' => $username, + 'password' => $input['password'] ?? null, + 'avatar_url' => $input['avatar_url'] ?? null, + 'status' => $verified ? Account::STATUS_ACTIVE : Account::STATUS_PENDING_VERIFICATION, + 'email_verified_at' => $verified ? now() : null, + 'last_login_ip' => $ip, + ]); + + $organization = Organization::create([ + 'slug' => $this->uniqueSlug($username ?? $input['slug'] ?? Str::before($input['email'], '@')), + '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; + }); + + if (! $verified) { + $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/Analytics/ExtensionStats.php b/app/Analytics/ExtensionStats.php new file mode 100644 index 0000000..cb9cd35 --- /dev/null +++ b/app/Analytics/ExtensionStats.php @@ -0,0 +1,460 @@ +|null */ + protected ?array $activeNow = null; + + /** Recent events a report lists. */ + public const EVENTS = 25; + + public const REFUND_EVENTS = ['purchase.refunded', 'purchase.partially_refunded']; + + /** + * @param Collection $extensions + */ + public function __construct(protected Collection $extensions) {} + + /** + * The report, cached for `registry.analytics_cache_seconds` per listings, range and shape; + * `as_of` says when it was built. + */ + public function cachedReport(StatsRange $range, bool $versions = false, bool $perExtension = false): array + { + $key = 'analytics:'.sha1(json_encode([$this->uuids(), $range->from->toDateString(), $range->to->toDateString(), $range->group, $versions, $perExtension])); + + return Cache::remember($key, (int) config('registry.analytics_cache_seconds', 60), fn () => $this->report($range, $versions, $perExtension)); + } + + /** + * @return list + */ + protected function uuids(): array + { + return $this->extensions->pluck('uuid')->all(); + } + + /** + * The report: totals for the range and the period before it, the series, active installs + * per version (`$versions`), per-listing rows (`$perExtension`) and recent events. + */ + public function report(StatsRange $range, bool $versions = false, bool $perExtension = false): array + { + $previous = $range->previous(); + $current = $this->figures($range); + $before = $this->figures($previous); + + $report = [ + 'range' => $range->toArray() + ['group' => $range->group], + 'previous' => $previous->toArray(), + 'as_of' => now()->toIso8601String(), + 'totals' => $current['totals'], + 'previous_totals' => $before['totals'], + 'series' => $this->series($range, $current), + ]; + + if ($versions) { + $report['versions'] = $this->versions(); + } + + if ($perExtension) { + $report['extensions'] = $this->perExtension($current); + } + + $report['events'] = [ + 'data' => $this->events($range), + 'total' => $current['totals']['installs'] + $current['totals']['updates'] + $current['totals']['uninstalls'] + + $current['totals']['purchases'] + $this->refundEvents($range)->count(), + ]; + + return $report; + } + + /** + * Counts per extension and day, and the totals over the range. + * + * @return array{totals: array, days: array>, extensions: array} + */ + protected function figures(StatsRange $range): array + { + $uuids = $this->uuids(); + $days = []; + $extensions = []; + $blank = ['installs' => 0, 'updates' => 0, 'uninstalls' => 0, 'purchases' => 0, 'revenue' => []]; + + $installed = Install::query() + ->whereIn('extension_uuid', $uuids) + ->whereBetween('installed_at', [$range->from, $range->to]) + ->groupBy('extension_uuid', DB::raw('DATE(installed_at)')) + ->selectRaw('extension_uuid, DATE(installed_at) as day') + ->selectRaw('SUM(CASE WHEN previous_release_uuid IS NULL THEN 1 ELSE 0 END) as installs') + ->selectRaw('SUM(CASE WHEN previous_release_uuid IS NULL THEN 0 ELSE 1 END) as updates') + ->toBase() + ->get(); + + foreach ($installed as $row) { + $days[$row->day][$row->extension_uuid] ??= $blank; + $days[$row->day][$row->extension_uuid]['installs'] += (int) $row->installs; + $days[$row->day][$row->extension_uuid]['updates'] += (int) $row->updates; + } + + $uninstalled = Install::query() + ->whereIn('extension_uuid', $uuids) + ->whereBetween('uninstalled_at', [$range->from, $range->to]) + ->whereNotNull('installed_at') + ->groupBy('extension_uuid', DB::raw('DATE(uninstalled_at)')) + ->selectRaw('extension_uuid, DATE(uninstalled_at) as day, COUNT(*) as uninstalls') + ->toBase() + ->get(); + + foreach ($uninstalled as $row) { + $days[$row->day][$row->extension_uuid] ??= $blank; + $days[$row->day][$row->extension_uuid]['uninstalls'] += (int) $row->uninstalls; + } + + // Sales are few next to installs; refunds live on each purchase, so add them up here. + Purchase::query() + ->whereIn('extension_uuid', $uuids) + ->whereBetween('created_at', [$range->from, $range->to]) + ->select(['id', 'uuid', 'extension_uuid', 'amount_total', 'fee_amount', 'net_amount', 'currency', 'status', 'meta', 'created_at']) + ->lazyById(500) + ->each(function (Purchase $purchase) use (&$days, $blank) { + $day = $purchase->created_at->toDateString(); + $days[$day][$purchase->extension_uuid] ??= $blank; + $days[$day][$purchase->extension_uuid]['purchases']++; + $days[$day][$purchase->extension_uuid]['revenue'] = static::addRevenue($days[$day][$purchase->extension_uuid]['revenue'], $purchase); + }); + + $active = $this->active($range); + $totals = $blank + ['active_instances' => array_sum($active)]; + + foreach ($days as $byExtension) { + foreach ($byExtension as $uuid => $counts) { + $extensions[$uuid] = static::merge($extensions[$uuid] ?? $blank, $counts); + $totals = static::merge($totals, $counts); + } + } + foreach ($this->uuids() as $uuid) { + $extensions[$uuid] = ($extensions[$uuid] ?? $blank) + ['active_instances' => $active[$uuid] ?? 0]; + } + + $totals['revenue'] = static::revenueList($totals['revenue']); + ksort($days); + + return ['totals' => $totals, 'days' => $days, 'extensions' => $extensions]; + } + + /** + * Active installs per extension at the end of the range: installed rows (the catalog's count) + * when the range reaches today. For an earlier end, the same count walked back: less what was + * installed since, plus what was uninstalled since (an update replaces one row with another, + * so it changes nothing). That reads only the rows after the end of the range. + * + * @return array + */ + protected function active(StatsRange $range): array + { + $now = $this->activeNow(); + if ($range->isCurrent()) { + return $now; + } + + $since = [$range->to->copy()->addSecond(), now()]; + $installed = $this->countBy(Install::query()->whereBetween('installed_at', $since)->whereNull('previous_release_uuid')); + $uninstalled = $this->countBy(Install::query()->whereBetween('uninstalled_at', $since)->whereNotNull('installed_at')); + + return collect($this->uuids()) + ->mapWithKeys(fn (string $uuid) => [$uuid => max(0, ($now[$uuid] ?? 0) - ($installed[$uuid] ?? 0) + ($uninstalled[$uuid] ?? 0))]) + ->all(); + } + + /** + * @return array + */ + protected function activeNow(): array + { + return $this->activeNow ??= $this->countBy(Install::query()->active()); + } + + /** + * @return array + */ + protected function countBy(Builder $query): array + { + return $query->whereIn('extension_uuid', $this->uuids()) + ->groupBy('extension_uuid') + ->selectRaw('extension_uuid, COUNT(*) as aggregate') + ->toBase() + ->pluck('aggregate', 'extension_uuid') + ->map(fn ($count) => (int) $count) + ->all(); + } + + /** + * One row per bucket, oldest first, zeros included. + * + * @return list + */ + protected function series(StatsRange $range, array $figures): array + { + $buckets = array_fill_keys($range->buckets(), ['installs' => 0, 'updates' => 0, 'uninstalls' => 0, 'purchases' => 0, 'revenue' => []]); + + foreach ($figures['days'] as $day => $byExtension) { + $bucket = $range->bucket($day); + foreach ($byExtension as $counts) { + $buckets[$bucket] = static::merge($buckets[$bucket], $counts); + } + } + + return collect($buckets) + ->map(fn (array $counts, string $date) => ['date' => $date] + array_merge($counts, ['revenue' => static::revenueList($counts['revenue'])])) + ->values() + ->all(); + } + + /** + * Active installs per release, newest version first, with each one's share. + * + * @return list + */ + protected function versions(): array + { + $counts = Install::query() + ->active() + ->whereIn('extension_uuid', $this->uuids()) + ->groupBy('release_uuid') + ->selectRaw('release_uuid, COUNT(*) as instances') + ->toBase() + ->get(); + + $total = max(1, (int) $counts->sum('instances')); + $releases = Release::withTrashed()->whereIn('uuid', $counts->pluck('release_uuid')->filter())->get()->keyBy('uuid'); + + return $counts + ->map(fn ($row) => [ + 'release' => $releases->get($row->release_uuid)?->public_id, + 'version' => $releases->get($row->release_uuid)?->version, + 'instances' => (int) $row->instances, + 'share' => round(((int) $row->instances) / $total, 4), + ]) + ->sort(fn (array $a, array $b) => match (true) { + $a['version'] === null => 1, + $b['version'] === null => -1, + default => Semver::compare($b['version'], $a['version']), + }) + ->values() + ->all(); + } + + /** + * Figures per listing, most active instances first. + * + * @return list + */ + protected function perExtension(array $figures): array + { + return $this->extensions + // A deleted listing shows only while it still has figures in the range. + ->reject(fn (Extension $extension) => $extension->trashed() && static::isEmpty($figures['extensions'][$extension->uuid])) + ->map(fn (Extension $extension) => [ + 'extension' => static::extensionReference($extension), + ] + array_merge($figures['extensions'][$extension->uuid], [ + 'revenue' => static::revenueList($figures['extensions'][$extension->uuid]['revenue']), + ])) + ->sortBy([['active_instances', 'desc'], ['installs', 'desc'], [fn ($row) => $row['extension']['name'], 'asc']]) + ->values() + ->all(); + } + + /** + * The latest installs, updates, uninstalls, purchases and refunds in the range, newest first, + * with the organization behind each. + * + * @return list + */ + protected function events(StatsRange $range): array + { + $uuids = $this->uuids(); + $between = [$range->from, $range->to]; + $instance = fn ($q) => $q->withTrashed()->with(['organization' => fn ($o) => $o->withTrashed()]); + $releases = fn ($q) => $q->withTrashed(); + $events = collect(); + + $installed = Install::query() + ->whereIn('extension_uuid', $uuids) + ->whereBetween('installed_at', $between) + ->with(['instance' => $instance, 'release' => $releases, 'previousRelease' => $releases]) + ->orderByDesc('installed_at')->orderByDesc('id') + ->limit(self::EVENTS) + ->get(); + + foreach ($installed as $install) { + $events->push($this->event($install->previous_release_uuid ? 'update' : 'install', $install->installed_at, $install->extension_uuid, $install->instance?->organization, [ + 'version' => $install->release?->version, + 'from_version' => $install->previousRelease?->version, + ])); + } + + $uninstalled = Install::query() + ->whereIn('extension_uuid', $uuids) + ->whereBetween('uninstalled_at', $between) + ->whereNotNull('installed_at') + ->with(['instance' => $instance, 'release' => $releases]) + ->orderByDesc('uninstalled_at')->orderByDesc('id') + ->limit(self::EVENTS) + ->get(); + + foreach ($uninstalled as $install) { + $events->push($this->event('uninstall', $install->uninstalled_at, $install->extension_uuid, $install->instance?->organization, [ + 'version' => $install->release?->version, + ])); + } + + $purchases = Purchase::query() + ->whereIn('extension_uuid', $uuids) + ->whereBetween('created_at', $between) + ->with(['buyer' => fn ($q) => $q->withTrashed()]) + ->orderByDesc('created_at')->orderByDesc('id') + ->limit(self::EVENTS) + ->get(); + + foreach ($purchases as $purchase) { + $events->push($this->event('purchase', $purchase->created_at, $purchase->extension_uuid, $purchase->buyer, [ + 'amount' => (int) $purchase->amount_total, + 'currency' => $purchase->currency, + ])); + } + + $refunds = $this->refundEvents($range)->orderByDesc('created_at')->limit(self::EVENTS)->get(); + $refunded = Purchase::withTrashed()->whereIn('uuid', $refunds->pluck('subject_uuid'))->with(['buyer' => fn ($q) => $q->withTrashed()])->get()->keyBy('uuid'); + + foreach ($refunds as $refund) { + $purchase = $refunded->get($refund->subject_uuid); + $events->push($this->event('refund', $refund->created_at, $purchase?->extension_uuid, $purchase?->buyer, [ + 'amount' => (int) ($refund->payload['amount_refunded'] ?? 0), + 'currency' => $purchase?->currency, + 'partial' => $refund->kind === 'purchase.partially_refunded', + ])); + } + + return $events->sortByDesc('at')->take(self::EVENTS)->values()->all(); + } + + /** + * Stripe refund notices (`charge.refunded`) on the listings' purchases in the range. + */ + protected function refundEvents(StatsRange $range): Builder + { + return Event::query() + ->whereIn('kind', self::REFUND_EVENTS) + ->where('subject_type', (new Purchase)->getMorphClass()) + ->whereIn('subject_uuid', Purchase::withTrashed()->select('uuid')->whereIn('extension_uuid', $this->uuids())) + ->whereBetween('created_at', [$range->from, $range->to]); + } + + protected function event(string $type, ?Carbon $at, ?string $extensionUuid, ?Organization $organization, array $details = []): array + { + $extension = $this->extensions->firstWhere('uuid', $extensionUuid); + + return [ + 'type' => $type, + 'at' => $at?->toIso8601String(), + 'extension' => $extension ? static::extensionReference($extension) : null, + 'organization' => $organization ? ['id' => $organization->public_id, 'slug' => $organization->slug, 'name' => $organization->name] : null, + ] + array_merge(['version' => null, 'from_version' => null, 'amount' => null, 'currency' => null], $details); + } + + /** + * @return array{id: string, slug: string, name: string, status: string} + */ + public static function extensionReference(Extension $extension): array + { + return ['id' => $extension->public_id, 'slug' => $extension->slug, 'name' => $extension->name, 'status' => $extension->status]; + } + + /** + * Add a purchase to revenue keyed by currency: gross, refunded, Fleetbase's fee (after + * refunds) and the publisher's net (after refunds). + * + * @param array $revenue + * @return array + */ + protected static function addRevenue(array $revenue, Purchase $purchase): array + { + $figures = FinanceReport::figures($purchase); + $line = $revenue[$purchase->currency] ?? ['currency' => $purchase->currency, 'purchases' => 0, 'gross' => 0, 'refunded' => 0, 'fee' => 0, 'net' => 0]; + + $line['purchases']++; + $line['gross'] += $figures['gross']; + $line['refunded'] += $figures['refunded']; + $line['fee'] += $figures['fee_revenue']; + $line['net'] += $figures['publisher_net']; + $revenue[$purchase->currency] = $line; + + return $revenue; + } + + /** + * Counts added together; revenue merged per currency. + */ + protected static function merge(array $into, array $counts): array + { + foreach (['installs', 'updates', 'uninstalls', 'purchases'] as $field) { + $into[$field] += $counts[$field]; + } + + foreach ($counts['revenue'] as $currency => $line) { + $mine = $into['revenue'][$currency] ?? array_map(fn ($value) => is_int($value) ? 0 : $value, $line); + foreach (['purchases', 'gross', 'refunded', 'fee', 'net'] as $field) { + $mine[$field] += $line[$field]; + } + $into['revenue'][$currency] = $mine; + } + + return $into; + } + + protected static function isEmpty(array $counts): bool + { + return $counts['installs'] + $counts['updates'] + $counts['uninstalls'] + $counts['purchases'] + $counts['active_instances'] === 0; + } + + /** + * Revenue keyed by currency as a list, highest net first. + * + * @return list + */ + protected static function revenueList(array $revenue): array + { + return collect($revenue)->sortBy([['net', 'desc'], ['currency', 'asc']])->values()->all(); + } +} diff --git a/app/Analytics/StatsCsv.php b/app/Analytics/StatsCsv.php new file mode 100644 index 0000000..752621a --- /dev/null +++ b/app/Analytics/StatsCsv.php @@ -0,0 +1,51 @@ +pluck('currency')->sort()->values()->all(); + + return response()->streamDownload(function () use ($report, $currencies) { + $out = fopen('php://output', 'w'); + $header = ['period', 'installs', 'updates', 'uninstalls', 'purchases']; + foreach ($currencies as $currency) { + array_push($header, "gross_{$currency}", "fee_{$currency}", "net_{$currency}"); + } + fputcsv($out, $header); + + foreach ($report['series'] as $row) { + $line = [$row['date'], $row['installs'], $row['updates'], $row['uninstalls'], $row['purchases']]; + $revenue = collect($row['revenue'])->keyBy('currency'); + foreach ($currencies as $currency) { + $money = $revenue->get($currency, ['gross' => 0, 'fee' => 0, 'net' => 0]); + array_push($line, static::decimal($money['gross']), static::decimal($money['fee']), static::decimal($money['net'])); + } + fputcsv($out, $line); + } + + fclose($out); + }, $name, ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + /** + * `{prefix}-{from}-to-{to}-{group}.csv` + */ + public static function filename(string $prefix, StatsRange $range): string + { + return "{$prefix}-stats-{$range->from->toDateString()}-to-{$range->to->toDateString()}-{$range->group}.csv"; + } + + protected static function decimal(int $minor): string + { + return number_format($minor / 100, 2, '.', ''); + } +} diff --git a/app/Analytics/StatsRange.php b/app/Analytics/StatsRange.php new file mode 100644 index 0000000..b5b8d87 --- /dev/null +++ b/app/Analytics/StatsRange.php @@ -0,0 +1,129 @@ +validate([ + 'from' => ['nullable', 'date_format:Y-m-d'], + 'to' => ['nullable', 'date_format:Y-m-d'], + 'group' => ['nullable', 'string', 'in:'.implode(',', self::GROUPS)], + ]); + + $to = $request->query('to') ? Carbon::parse($request->query('to'))->endOfDay() : now()->endOfDay(); + $from = $request->query('from') ? Carbon::parse($request->query('from'))->startOfDay() : $to->copy()->subDays(self::DEFAULT_DAYS - 1)->startOfDay(); + + if ($from->greaterThan($to)) { + throw ValidationException::withMessages(['from' => 'The start of the range must be on or before its end.']); + } + + $range = new self($from, $to, 'day'); + if ($range->days() > self::MAX_DAYS) { + throw ValidationException::withMessages(['from' => 'A report covers at most '.self::MAX_DAYS.' days.']); + } + + return new self($from, $to, $request->query('group') ?: self::defaultGroup($range->days())); + } + + public static function defaultGroup(int $days): string + { + return match (true) { + $days <= 92 => 'day', + $days <= 366 => 'week', + default => 'month', + }; + } + + public function days(): int + { + return (int) $this->from->copy()->startOfDay()->diffInDays($this->to->copy()->startOfDay()) + 1; + } + + /** + * The period of the same number of days that ends the day before this one starts. + */ + public function previous(): static + { + $to = $this->from->copy()->subDay()->endOfDay(); + + return new static($to->copy()->subDays($this->days() - 1)->startOfDay(), $to, $this->group); + } + + /** + * Whether the range reaches today, so "active now" is its closing figure. + */ + public function isCurrent(): bool + { + return $this->to->greaterThanOrEqualTo(now()); + } + + /** + * The first day of the bucket a day (`Y-m-d`) falls in. + */ + public function bucket(string $day): string + { + $date = Carbon::parse($day); + + return match ($this->group) { + 'week' => $date->startOfWeek(Carbon::MONDAY)->toDateString(), + 'month' => $date->startOfMonth()->toDateString(), + default => $date->toDateString(), + }; + } + + /** + * Every bucket in the range, oldest first. The first and last may be partial weeks or months. + * + * @return list + */ + public function buckets(): array + { + $buckets = []; + $cursor = Carbon::parse($this->bucket($this->from->toDateString())); + $last = $this->to->toDateString(); + + while ($cursor->toDateString() <= $last) { + $buckets[] = $cursor->toDateString(); + match ($this->group) { + 'week' => $cursor->addWeek(), + 'month' => $cursor->addMonthNoOverflow(), + default => $cursor->addDay(), + }; + } + + return $buckets; + } + + /** + * @return array{from: string, to: string, days: int} + */ + public function toArray(): array + { + return ['from' => $this->from->toDateString(), 'to' => $this->to->toDateString(), 'days' => $this->days()]; + } +} diff --git a/app/Auth/SessionGuard.php b/app/Auth/SessionGuard.php new file mode 100644 index 0000000..a249723 --- /dev/null +++ b/app/Auth/SessionGuard.php @@ -0,0 +1,107 @@ +account; + if (! $account || ! $account->isActive()) { + return null; + } + + $this->touch($token, $request); + $request->attributes->set('token', $token); + + return $account->withCurrentToken($token); + } + + /** + * The active session token behind the request's session, or null. + */ + public static function token(Request $request): ?Token + { + if (! $request->hasSession()) { + return null; + } + + $uuid = $request->session()->get(self::SESSION_KEY); + if (! is_string($uuid) || $uuid === '') { + return null; + } + + return Token::query()->active()->where('uuid', $uuid)->where('prefix', Token::PREFIX_SESSION)->first(); + } + + /** + * Sign the account in on the request's session: revoke the session's previous token, + * issue a `flbs_` token (plaintext discarded), regenerate the session id and store the + * token's uuid. Used by POST /v1/auth/session and Sign in with GitHub. + */ + public static function signIn(Request $request, Account $account, string $via = 'session'): Token + { + // Signing in over an existing session replaces its token. + static::token($request)?->revoke(); + + $token = Token::issue( + $account, + 'web session '.now()->toDateString(), + [Token::SCOPE_ALL], + now()->addMinutes(static::lifetimeMinutes()), + null, + Token::PREFIX_SESSION, + ); + $token->plainTextToken = null; + + $request->session()->regenerate(); + $request->session()->put(self::SESSION_KEY, $token->uuid); + + $account->forceFill(['last_login_at' => now(), 'last_login_ip' => $request->ip()])->save(); + Event::record('account.login', $account, $account, ['token' => $token->public_id, 'via' => $via], $request->ip()); + + return $token; + } + + public static function lifetimeMinutes(): int + { + return max(1, (int) config('session.lifetime', 120)); + } + + /** + * Stamp last-used data and slide the expiry when less than half the lifetime remains. + */ + protected function touch(Token $token, Request $request): void + { + $lifetime = static::lifetimeMinutes(); + $attributes = ['last_used_at' => now(), 'last_used_ip' => $request->ip()]; + + if ($token->expires_at !== null && now()->diffInSeconds($token->expires_at) < $lifetime * 30) { + $attributes['expires_at'] = now()->addMinutes($lifetime); + } + + $token->forceFill($attributes)->saveQuietly(); + } +} 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 @@ +loadMissing(['price', 'organization']); + $this->assertPurchasable($extension, $organization, $buyer['instance'] ?? null); + + $price = $extension->price; + $publisher = $extension->organization; + $amount = $price->effective_amount; + $rate = FacilitatorFee::rateAt(now()); + $fee = FacilitatorFee::feeFor($amount, $rate); + $expiresAt = now()->addMinutes(max(30, min(1440, (int) config('registry.commerce.checkout_expires_minutes', 60)))); + + $checkout = Checkout::create([ + 'extension_uuid' => $extension->uuid, + 'organization_uuid' => $organization->uuid, + 'account_uuid' => ($buyer['account'] ?? null)?->uuid, + 'instance_uuid' => ($buyer['instance'] ?? null)?->uuid, + 'source' => $source, + 'status' => Checkout::STATUS_OPEN, + 'amount' => $amount, + 'currency' => $price->currency, + 'fee_amount' => $fee, + 'fee_rate_bps' => $rate, + 'price_snapshot' => [ + 'model' => $price->model, + 'amount' => (int) $price->amount, + 'sale_amount' => $price->hasSaleAmount() ? (int) $price->sale_amount : null, + 'on_sale' => $price->isOnSale(), + 'currency' => $price->currency, + 'stripe_price_id' => $price->checkoutStripePriceId(), + ], + 'expires_at' => $expiresAt, + ]); + + $metadata = array_filter([ + 'handoff' => $checkout->public_id, + 'source' => $source, + 'extension' => $extension->public_id, + 'extension_uuid' => $extension->uuid, + 'organization' => $organization->public_id, + 'organization_uuid' => $organization->uuid, + 'account' => ($buyer['account'] ?? null)?->public_id, + 'instance' => ($buyer['instance'] ?? null)?->public_id, + 'fee_rate_bps' => (string) $rate, + ]); + + $tax = (bool) config('registry.commerce.tax.enabled', true); + $params = [ + 'mode' => 'payment', + 'line_items' => [['price' => $price->checkoutStripePriceId(), 'quantity' => 1]], + 'success_url' => static::withQuery($successUrl, ['handoff' => $checkout->public_id]), + 'cancel_url' => static::withQuery($cancelUrl, ['handoff' => $checkout->public_id, 'cancelled' => 1]), + 'client_reference_id' => $checkout->public_id, + 'expires_at' => $expiresAt->getTimestamp(), + 'metadata' => $metadata, + 'payment_intent_data' => array_filter([ + 'application_fee_amount' => $fee, + 'transfer_data' => ['destination' => $publisher->stripe_connect_id], + 'on_behalf_of' => $tax ? $publisher->stripe_connect_id : null, + 'description' => "{$extension->name} licence", + 'metadata' => $metadata, + ]), + ]; + + if (filled($buyer['email'] ?? null)) { + $params['customer_email'] = $buyer['email']; + } + + if ($tax) { + $params['automatic_tax'] = ['enabled' => true, 'liability' => ['type' => 'account', 'account' => $publisher->stripe_connect_id]]; + $params['tax_id_collection'] = ['enabled' => true]; + } + + try { + $session = Stripe::client()->checkout->sessions->create($params, ['idempotency_key' => 'checkout:'.$checkout->uuid]); + } catch (\Throwable $e) { + $checkout->delete(); + + throw $e; + } + + $checkout->forceFill([ + 'stripe_checkout_session_id' => $session->id, + 'checkout_url' => $session->url, + ])->save(); + + return $checkout; + } + + /** + * Refuse a checkout that cannot or need not happen, with the reason the buyer sees. + */ + protected function assertPurchasable(Extension $extension, Organization $organization, ?Instance $instance): void + { + if ($extension->status !== Extension::STATUS_PUBLISHED) { + throw new UnprocessableEntityHttpException('This extension is not published.'); + } + + if (! $extension->isPaid()) { + throw new UnprocessableEntityHttpException("{$extension->name} is free: install it without buying."); + } + + if ($instance && $extension->hosting !== Extension::HOSTING_BOTH && $extension->hosting !== $instance->kind) { + throw new UnprocessableEntityHttpException("This extension is only available for {$extension->hosting} instances."); + } + + if ($extension->organization_uuid === $organization->uuid) { + throw new ConflictHttpException("{$organization->name} publishes {$extension->name} and already has access to it."); + } + + // Imported listings and accounts (registry:import) have no Stripe state yet: read it now. + $publisher = $extension->organization; + if ($publisher?->hasStripeAccount() && $publisher->stripeState() === []) { + app(ConnectAccounts::class)->refresh($publisher); + } + if ($publisher?->canReceivePayments() && ! $extension->price->checkoutStripePriceId()) { + app(PriceCatalog::class)->sync($extension->price, $extension)->save(); + } + + if (! $publisher?->canReceivePayments() || ! $extension->price->checkoutStripePriceId()) { + throw new UnprocessableEntityHttpException("{$extension->name} cannot be bought right now: its publisher has not finished setting up payouts."); + } + } + + /** + * Add query parameters to a URL, keeping its own. + */ + public static function withQuery(string $url, array $query): string + { + $fragment = ''; + if (($hash = strpos($url, '#')) !== false) { + $fragment = substr($url, $hash); + $url = substr($url, 0, $hash); + } + + return $url.(str_contains($url, '?') ? '&' : '?').Arr::query($query).$fragment; + } +} diff --git a/app/Commerce/ConnectAccounts.php b/app/Commerce/ConnectAccounts.php new file mode 100644 index 0000000..691d41b --- /dev/null +++ b/app/Commerce/ConnectAccounts.php @@ -0,0 +1,156 @@ +hasStripeAccount()) { + return $organization; + } + + $params = array_filter([ + 'controller' => [ + 'stripe_dashboard' => ['type' => 'express'], + 'fees' => ['payer' => 'application'], + 'losses' => ['payments' => 'application'], + ], + 'country' => config('registry.commerce.connect_country'), + 'email' => $by->email, + 'business_profile' => array_filter(['name' => $organization->name, 'url' => $organization->website]), + 'capabilities' => [ + 'card_payments' => ['requested' => true], + 'transfers' => ['requested' => true], + ], + 'metadata' => ['organization' => $organization->public_id, 'organization_uuid' => $organization->uuid], + ], fn ($value) => $value !== null && $value !== []); + + $account = Stripe::client()->accounts->create($params, ['idempotency_key' => 'connect-account:'.$organization->uuid]); + + $organization->forceFill(['stripe_connect_id' => $account->id])->save(); + Event::record('organization.stripe_account_created', $organization, $by, ['account' => $account->id]); + + return $this->sync($organization, $account); + } + + /** + * A short-lived client secret for Connect.js on the website. + */ + public function session(Organization $organization): AccountSession + { + $components = []; + foreach (self::COMPONENTS as $component) { + $components[$component] = ['enabled' => true]; + } + foreach (['account_onboarding', 'account_management', 'notification_banner'] as $component) { + $components[$component]['features'] = ['external_account_collection' => true]; + } + + return Stripe::client()->accountSessions->create([ + 'account' => $organization->stripe_connect_id, + 'components' => $components, + ]); + } + + /** + * Read the account from Stripe and store its state (the website calls this when onboarding + * exits, so the page does not wait for the webhook). + */ + public function refresh(Organization $organization): Organization + { + return $this->sync($organization, Stripe::client()->accounts->retrieve($organization->stripe_connect_id)); + } + + /** + * Store what the account says: `payout_status` and the details behind it in `meta.stripe`. + */ + public function sync(Organization $organization, StripeAccount $account): Organization + { + $requirements = $account['requirements'] ?? null; + $from = $organization->payout_status; + $status = static::payoutStatus($account); + + $organization->forceFill([ + 'payout_status' => $status, + 'meta' => array_merge((array) $organization->meta, [ + 'stripe' => [ + 'charges_enabled' => (bool) ($account['charges_enabled'] ?? false), + 'payouts_enabled' => (bool) ($account['payouts_enabled'] ?? false), + 'details_submitted' => (bool) ($account['details_submitted'] ?? false), + 'transfers' => $account['capabilities']['transfers'] ?? null, + 'card_payments' => $account['capabilities']['card_payments'] ?? null, + 'disabled_reason' => $requirements['disabled_reason'] ?? null, + 'currently_due' => array_values((array) ($requirements['currently_due'] ?? [])), + 'past_due' => array_values((array) ($requirements['past_due'] ?? [])), + 'current_deadline' => $requirements['current_deadline'] ?? null, + 'country' => $account['country'] ?? null, + 'default_currency' => $account['default_currency'] ?? null, + 'synced_at' => now()->toIso8601String(), + ], + ]), + ])->save(); + + if ($from !== $status) { + Event::record('organization.payout_status_changed', $organization, null, ['from' => $from, 'to' => $status, 'account' => $account->id]); + } + + return $organization; + } + + /** + * `enabled` once charges and payouts work, `rejected` when Stripe rejected the account, + * `pending` until the publisher submits their details, else `restricted` (Stripe needs more). + */ + public static function payoutStatus(StripeAccount $account): string + { + $disabled = (string) ($account['requirements']['disabled_reason'] ?? ''); + + return match (true) { + str_starts_with($disabled, 'rejected') => Organization::PAYOUT_REJECTED, + ($account['charges_enabled'] ?? false) && ($account['payouts_enabled'] ?? false) => Organization::PAYOUT_ENABLED, + ! ($account['details_submitted'] ?? false) => Organization::PAYOUT_PENDING, + default => Organization::PAYOUT_RESTRICTED, + }; + } + + /** + * What the website and API show about an organization's connected account. + */ + public static function summary(Organization $organization): array + { + $state = $organization->stripeState(); + + return [ + 'connected' => $organization->hasStripeAccount(), + 'payout_status' => $organization->payout_status, + 'can_receive_payments' => $organization->canReceivePayments(), + 'charges_enabled' => (bool) ($state['charges_enabled'] ?? false), + 'payouts_enabled' => (bool) ($state['payouts_enabled'] ?? false), + 'details_submitted' => (bool) ($state['details_submitted'] ?? false), + 'requirements_due' => array_values(array_unique(array_merge($state['past_due'] ?? [], $state['currently_due'] ?? []))), + 'current_deadline' => $state['current_deadline'] ?? null, + 'country' => $state['country'] ?? null, + 'default_currency' => isset($state['default_currency']) ? strtoupper($state['default_currency']) : null, + 'synced_at' => $state['synced_at'] ?? null, + ]; + } +} diff --git a/app/Commerce/FinanceReport.php b/app/Commerce/FinanceReport.php new file mode 100644 index 0000000..7b4d84f --- /dev/null +++ b/app/Commerce/FinanceReport.php @@ -0,0 +1,167 @@ +endOfDay() : now()->endOfDay(); + $start = $from ? Carbon::parse($from)->startOfDay() : $end->copy()->subMonthsNoOverflow(11)->startOfMonth(); + + return new static($start, $end); + } + + public function purchases(): Builder + { + return Purchase::query()->whereBetween('created_at', [$this->from, $this->to]); + } + + /** + * Totals per currency, per month and currency, and per publisher and currency (highest fee + * revenue first). + * + * @return array{from: string, to: string, totals: list, months: list, publishers: list} + */ + public function summary(): array + { + $buckets = $this->aggregate(); + + return [ + 'from' => $this->from->toDateString(), + 'to' => $this->to->toDateString(), + 'totals' => $buckets['totals'], + 'months' => $buckets['months'], + 'publishers' => $buckets['publishers'], + ]; + } + + /** + * Rows per month, publisher and currency: oldest month first, highest fee revenue first within it. + * + * @return list + */ + public function monthlyByPublisher(): array + { + return $this->aggregate()['month_publishers']; + } + + /** + * @return array{totals: list, months: list, publishers: list, month_publishers: list} + */ + protected function aggregate(): array + { + $totals = $months = $publishers = $monthPublishers = []; + + $this->purchases() + ->with(['extension' => fn ($q) => $q->withTrashed()->with(['organization' => fn ($o) => $o->withTrashed()])]) + ->lazyById(500) + ->each(function (Purchase $purchase) use (&$totals, &$months, &$publishers, &$monthPublishers) { + $figures = static::figures($purchase); + $currency = $purchase->currency; + $month = $purchase->created_at->format('Y-m'); + $organization = $purchase->extension?->organization; + $publisher = [ + 'organization' => $organization ? ['id' => $organization->public_id, 'slug' => $organization->slug, 'name' => $organization->name] : null, + 'currency' => $currency, + ]; + $key = ($organization?->uuid ?? '').'|'.$currency; + + $totals[$currency] = static::add($totals[$currency] ?? ['currency' => $currency] + static::zero(), $figures); + $months["{$month}|{$currency}"] = static::add($months["{$month}|{$currency}"] ?? ['month' => $month, 'currency' => $currency] + static::zero(), $figures); + $publishers[$key] = static::add($publishers[$key] ?? $publisher + static::zero(), $figures); + $monthPublishers[$month][$key] = static::add($monthPublishers[$month][$key] ?? ['month' => $month] + $publisher + static::zero(), $figures); + }); + + ksort($totals); + ksort($months); + ksort($monthPublishers); + + return [ + 'totals' => array_values($totals), + 'months' => array_values($months), + 'publishers' => static::ranked($publishers), + 'month_publishers' => array_merge([], ...array_map(fn (array $rows) => static::ranked($rows), array_values($monthPublishers))), + ]; + } + + /** + * One purchase's figures. A refund returns the application fee in proportion to the amount + * refunded (as `refund_application_fee` does) and takes the rest back from the publisher + * (`reverse_transfer`). A dispute is counted, not subtracted: its outcome is decided in Stripe. + * + * @return array + */ + public static function figures(Purchase $purchase): array + { + $gross = (int) $purchase->amount_total; + $fee = (int) $purchase->fee_amount; + $net = (int) $purchase->net_amount; + $refunded = min($gross, (int) ($purchase->meta['amount_refunded'] ?? 0)); + if ($purchase->status === Purchase::STATUS_REFUNDED && $refunded === 0) { + $refunded = $gross; + } + $feeRefunded = $gross > 0 ? min($fee, (int) round($fee * $refunded / $gross)) : 0; + $dispute = $purchase->meta['dispute'] ?? null; + + return [ + 'sales' => 1, + 'gross' => $gross, + 'refunds' => $refunded > 0 ? 1 : 0, + 'refunded' => $refunded, + 'fee' => $fee, + 'fee_refunded' => $feeRefunded, + 'fee_revenue' => $fee - $feeRefunded, + 'publisher_net' => $net - ($refunded - $feeRefunded), + 'disputes' => is_array($dispute) ? 1 : 0, + 'disputed' => is_array($dispute) ? (int) ($dispute['amount'] ?? $gross) : 0, + ]; + } + + /** + * @return array + */ + protected static function zero(): array + { + return array_fill_keys(self::FIELDS, 0); + } + + /** + * Highest fee revenue first, then highest gross. + */ + protected static function ranked(array $rows): array + { + usort($rows, fn (array $a, array $b) => [$b['fee_revenue'], $b['gross']] <=> [$a['fee_revenue'], $a['gross']]); + + return $rows; + } + + protected static function add(array $bucket, array $figures): array + { + foreach (self::FIELDS as $field) { + $bucket[$field] += $figures[$field]; + } + + return $bucket; + } +} diff --git a/app/Commerce/PriceCatalog.php b/app/Commerce/PriceCatalog.php new file mode 100644 index 0000000..56f8c96 --- /dev/null +++ b/app/Commerce/PriceCatalog.php @@ -0,0 +1,93 @@ +isFree()) { + $this->archive($price->stripe_price_id); + $this->archive($price->stripe_sale_price_id); + $price->forceFill(['stripe_price_id' => null, 'stripe_sale_price_id' => null]); + + return $price; + } + + $product = $this->product($price, $extension); + $currencyChanged = ! $price->exists || $price->isDirty('currency'); + + if (! $price->stripe_price_id || $currencyChanged || $price->isDirty('amount')) { + $created = $stripe->prices->create($this->priceParams($price, $extension, $product, (int) $price->amount, 'regular')); + $this->archive($price->stripe_price_id); + $price->forceFill(['stripe_price_id' => $created->id]); + } + + if ($price->hasSaleAmount()) { + if (! $price->stripe_sale_price_id || $currencyChanged || $price->isDirty('sale_amount')) { + $created = $stripe->prices->create($this->priceParams($price, $extension, $product, (int) $price->sale_amount, 'sale')); + $this->archive($price->stripe_sale_price_id); + $price->forceFill(['stripe_sale_price_id' => $created->id]); + } + } elseif ($price->stripe_sale_price_id) { + $this->archive($price->stripe_sale_price_id); + $price->forceFill(['stripe_sale_price_id' => null]); + } + + return $price; + } + + /** + * The listing's product, created on first sync and renamed to follow the listing. + */ + protected function product(Price $price, Extension $extension): string + { + $stripe = Stripe::client(); + $params = [ + 'name' => $extension->name, + 'metadata' => ['extension' => $extension->public_id, 'extension_uuid' => $extension->uuid], + ]; + + if ($price->stripe_product_id) { + $stripe->products->update($price->stripe_product_id, $params + ['active' => true]); + + return $price->stripe_product_id; + } + + $product = $stripe->products->create(array_filter($params + [ + 'tax_code' => config('registry.commerce.tax.code'), + ])); + $price->forceFill(['stripe_product_id' => $product->id]); + + return $product->id; + } + + protected function priceParams(Price $price, Extension $extension, string $product, int $amount, string $kind): array + { + return [ + 'product' => $product, + 'unit_amount' => $amount, + 'currency' => strtolower($price->currency), + 'tax_behavior' => config('registry.commerce.tax.behavior', 'exclusive'), + 'metadata' => ['extension' => $extension->public_id, 'kind' => $kind], + ]; + } + + protected function archive(?string $id): void + { + if ($id) { + Stripe::client()->prices->update($id, ['active' => false]); + } + } +} diff --git a/app/Commerce/Refunds.php b/app/Commerce/Refunds.php new file mode 100644 index 0000000..61f119f --- /dev/null +++ b/app/Commerce/Refunds.php @@ -0,0 +1,88 @@ +stripe_payment_intent_id || in_array($purchase->status, [Purchase::STATUS_REFUNDED, Purchase::STATUS_DISPUTED], true)) { + return 0; + } + + return max(0, (int) $purchase->amount_total - (int) ($purchase->meta['amount_refunded'] ?? 0)); + } + + /** + * Refund `$amount` (minor units; null refunds what is left) and record who asked and why. + */ + public function refund(Purchase $purchase, Account $staff, ?int $amount = null, ?string $reason = null, ?string $note = null): Refund + { + if (! $purchase->stripe_payment_intent_id) { + throw new UnprocessableEntityHttpException('This purchase has no Stripe payment to refund.'); + } + if ($purchase->status === Purchase::STATUS_REFUNDED) { + throw new ConflictHttpException('This purchase is already refunded.'); + } + if ($purchase->status === Purchase::STATUS_DISPUTED) { + throw new ConflictHttpException('This purchase is disputed. Answer the dispute in the Stripe Dashboard; Stripe does not refund a disputed payment.'); + } + + $left = static::refundable($purchase); + if ($left === 0) { + throw new ConflictHttpException('Nothing is left to refund on this purchase.'); + } + $amount ??= $left; + if ($amount > $left) { + throw ValidationException::withMessages(['amount' => 'At most '.number_format($left / 100, 2).' '.$purchase->currency.' can be refunded.']); + } + + $params = array_filter([ + 'payment_intent' => $purchase->stripe_payment_intent_id, + 'amount' => $amount, + 'reason' => $reason, + 'reverse_transfer' => true, + 'refund_application_fee' => true, + 'metadata' => array_filter([ + 'purchase' => $purchase->public_id, + 'refunded_by' => $staff->public_id, + 'note' => $note, + ]), + ], fn ($value) => $value !== null); + + // A repeated click with the same purchase state and request gets the same refund back. + $key = 'refund:'.$purchase->uuid.':'.(int) ($purchase->meta['amount_refunded'] ?? 0).':'.sha1(json_encode($params)); + $refund = Stripe::client()->refunds->create($params, ['idempotency_key' => $key]); + + Event::record('purchase.refund_requested', $purchase, $staff, array_filter([ + 'refund' => $refund->id, + 'amount' => (int) $refund->amount, + 'currency' => strtoupper((string) $refund->currency), + 'status' => $refund->status, + 'reason' => $reason, + 'note' => $note, + ], fn ($value) => $value !== null)); + + return $refund; + } +} diff --git a/app/Commerce/Stripe.php b/app/Commerce/Stripe.php new file mode 100644 index 0000000..1459e55 --- /dev/null +++ b/app/Commerce/Stripe.php @@ -0,0 +1,49 @@ +getMessage() + : 'Payments are unavailable right now. Try again in a few minutes.'; + } + + /** + * Request options that act on a connected account. + */ + public static function onAccount(string $account): array + { + return ['stripe_account' => $account]; + } +} diff --git a/app/Commerce/StripeWebhooks.php b/app/Commerce/StripeWebhooks.php new file mode 100644 index 0000000..916b382 --- /dev/null +++ b/app/Commerce/StripeWebhooks.php @@ -0,0 +1,281 @@ +type, self::HANDLED, true)) { + return self::IGNORED; + } + + if (Event::query()->where('external_id', $event->id)->exists()) { + return self::DUPLICATE; + } + + $this->mail = []; + + try { + DB::transaction(function () use ($event) { + Event::create([ + 'kind' => 'stripe.'.$event->type, + 'external_id' => $event->id, + 'payload' => array_filter([ + 'object' => $event->data->object->id ?? null, + 'account' => $event->account ?? null, + 'livemode' => (bool) $event->livemode, + ], fn ($value) => $value !== null), + ]); + + $object = $event->data->object; + match ($event->type) { + 'checkout.session.completed', 'checkout.session.async_payment_succeeded' => $this->completed($object), + 'checkout.session.expired' => $this->expired($object), + 'charge.refunded' => $this->refunded($object), + 'charge.dispute.created' => $this->disputed($object), + 'account.updated' => $this->accountUpdated($object), + }; + }); + } catch (UniqueConstraintViolationException) { + // Delivered twice at once: the other delivery stored the event. + return self::DUPLICATE; + } + + foreach ($this->mail as [$to, $mailable]) { + rescue(fn () => Mail::to($to)->send($mailable)); + } + + return self::PROCESSED; + } + + /** + * A paid session creates the purchase and a paid licence, and completes the hand-off. + * Sessions paid later (bank debits) wait for `async_payment_succeeded`. + */ + protected function completed(Session $session): void + { + if (! in_array($session->payment_status, ['paid', 'no_payment_required'], true)) { + return; + } + + if (Purchase::query()->where('stripe_checkout_session_id', $session->id)->exists()) { + return; + } + + $checkout = Checkout::query()->where('stripe_checkout_session_id', $session->id)->lockForUpdate()->first() + ?? Checkout::findById($session->metadata['handoff'] ?? null); + $extension = Extension::find($checkout?->extension_uuid ?? ($session->metadata['extension_uuid'] ?? null)); + $organization = Organization::find($checkout?->organization_uuid ?? ($session->metadata['organization_uuid'] ?? null)); + + if (! $extension || ! $organization) { + Log::warning('Stripe checkout session without a registry checkout', ['session' => $session->id]); + + return; + } + + $intent = $session->payment_intent + ? Stripe::client()->paymentIntents->retrieve(is_string($session->payment_intent) ? $session->payment_intent : $session->payment_intent->id, ['expand' => ['latest_charge']]) + : null; + $charge = $intent?->latest_charge instanceof Charge ? $intent->latest_charge : null; + + $total = (int) $session->amount_total; + $fee = (int) ($intent?->application_fee_amount ?? $checkout?->fee_amount ?? 0); + $email = $session->customer_details['email'] ?? $session->customer_email ?? null; + + $purchase = Purchase::create([ + 'extension_uuid' => $extension->uuid, + 'buyer_organization_uuid' => $organization->uuid, + 'buyer_account_uuid' => $checkout?->account_uuid, + 'price_snapshot' => $checkout?->price_snapshot, + 'amount_total' => $total, + 'fee_amount' => $fee, + 'net_amount' => $total - $fee, + 'currency' => strtoupper((string) $session->currency), + 'stripe_checkout_session_id' => $session->id, + 'stripe_payment_intent_id' => $intent?->id, + 'status' => Purchase::STATUS_PAID, + 'receipt_url' => $charge?->receipt_url, + 'meta' => array_filter([ + 'handoff' => $checkout?->public_id, + 'source' => $checkout?->source, + 'instance_uuid' => $checkout?->instance_uuid, + 'amount_subtotal' => (int) $session->amount_subtotal, + 'amount_tax' => (int) ($session->total_details['amount_tax'] ?? 0), + 'fee_rate_bps' => $checkout?->fee_rate_bps ?? (int) ($session->metadata['fee_rate_bps'] ?? 0), + 'customer_email' => $email, + 'stripe_charge_id' => $charge?->id, + 'stripe_account' => $intent?->transfer_data['destination'] ?? null, + ], fn ($value) => $value !== null), + ]); + + $licence = Licence::create([ + 'extension_uuid' => $extension->uuid, + 'organization_uuid' => $organization->uuid, + 'purchase_uuid' => $purchase->uuid, + 'kind' => Licence::KIND_PAID, + 'max_activations' => (int) config('registry.commerce.max_activations', 5), + 'status' => Licence::STATUS_ACTIVE, + ]); + + $checkout?->forceFill([ + 'status' => Checkout::STATUS_COMPLETE, + 'purchase_uuid' => $purchase->uuid, + 'completed_at' => now(), + ])->save(); + + Event::record('purchase.completed', $purchase, $checkout?->account, [ + 'extension' => $extension->public_id, + 'organization' => $organization->public_id, + 'licence' => $licence->public_id, + 'amount_total' => $total, + 'fee_amount' => $fee, + ]); + + if ($email) { + $this->mail[] = [$email, new PurchaseReceiptMail($purchase)]; + } + } + + protected function expired(Session $session): void + { + Checkout::query() + ->where('stripe_checkout_session_id', $session->id) + ->where('status', Checkout::STATUS_OPEN) + ->update(['status' => Checkout::STATUS_EXPIRED, 'updated_at' => now()]); + } + + /** + * A full refund marks the purchase refunded and revokes its licence (new installs and updates + * stop; running instances keep working). A partial refund is recorded on the purchase only. + */ + protected function refunded(Charge $charge): void + { + $purchase = $this->purchaseFor($charge->payment_intent); + if (! $purchase) { + return; + } + + $meta = array_merge((array) $purchase->meta, ['amount_refunded' => (int) $charge->amount_refunded]); + + if (! $charge->refunded) { + $purchase->forceFill(['meta' => $meta])->save(); + Event::record('purchase.partially_refunded', $purchase, null, ['amount_refunded' => (int) $charge->amount_refunded]); + + return; + } + + $purchase->forceFill([ + 'status' => Purchase::STATUS_REFUNDED, + 'meta' => $meta + ['refunded_at' => now()->toIso8601String()], + ])->save(); + + $this->revoke($purchase, 'refunded'); + + Event::record('purchase.refunded', $purchase, null, ['amount_refunded' => (int) $charge->amount_refunded]); + } + + /** + * A dispute flags the purchase and revokes its licence at once (disputes are a common way to + * try to keep an extension for free), and tells staff. Staff reinstate the licence if the + * dispute is won (POST /v1/admin/licences/{id}/reinstate). + */ + protected function disputed(Dispute $dispute): void + { + $purchase = $this->purchaseFor($dispute->payment_intent); + if (! $purchase) { + return; + } + + $purchase->forceFill([ + 'status' => $purchase->status === Purchase::STATUS_REFUNDED ? $purchase->status : Purchase::STATUS_DISPUTED, + 'meta' => array_merge((array) $purchase->meta, [ + 'dispute' => [ + 'id' => $dispute->id, + 'reason' => $dispute->reason, + 'status' => $dispute->status, + 'amount' => (int) $dispute->amount, + 'opened_at' => now()->toIso8601String(), + ], + ]), + ])->save(); + + $this->revoke($purchase, 'disputed'); + Event::record('purchase.disputed', $purchase, null, ['dispute' => $dispute->id, 'reason' => $dispute->reason]); + + foreach ((array) config('registry.commerce.notify', []) as $to) { + $this->mail[] = [$to, new DisputeOpenedMail($purchase)]; + } + } + + /** + * Revoke the purchase's active licence: new installs and updates stop; running instances keep + * their seat. + */ + protected function revoke(Purchase $purchase, string $reason): void + { + $purchase->licence()->where('status', Licence::STATUS_ACTIVE)->get()->each(function (Licence $licence) use ($reason) { + $licence->forceFill([ + 'status' => Licence::STATUS_REVOKED, + 'meta' => array_merge((array) $licence->meta, ['revoked_reason' => $reason, 'revoked_at' => now()->toIso8601String()]), + ])->save(); + }); + } + + protected function accountUpdated(StripeAccount $account): void + { + $organization = Organization::query()->where('stripe_connect_id', $account->id)->first(); + + if ($organization) { + app(ConnectAccounts::class)->sync($organization, $account); + } + } + + protected function purchaseFor(mixed $paymentIntent): ?Purchase + { + $id = is_string($paymentIntent) ? $paymentIntent : ($paymentIntent->id ?? null); + + return $id ? Purchase::query()->where('stripe_payment_intent_id', $id)->lockForUpdate()->first() : null; + } +} diff --git a/app/Console/Commands/ImportRegistry.php b/app/Console/Commands/ImportRegistry.php new file mode 100644 index 0000000..c4a58b3 --- /dev/null +++ b/app/Console/Commands/ImportRegistry.php @@ -0,0 +1,105 @@ +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/Console/Commands/SeedWebsite.php b/app/Console/Commands/SeedWebsite.php new file mode 100644 index 0000000..b71699b --- /dev/null +++ b/app/Console/Commands/SeedWebsite.php @@ -0,0 +1,279 @@ +laravel->isProduction()) { + $this->error('registry:seed-website never runs in production.'); + + return self::FAILURE; + } + + if ($this->callSilently('registry:seed', ['--seed-release' => true]) !== self::SUCCESS) { + $this->error('registry:seed --seed-release failed.'); + + return self::FAILURE; + } + + $catalog = $this->enrichCatalogListings(); + + $staff = $this->account(self::STAFF_EMAIL, 'Registry Staff', 'registry-staff-reviewer'); + $staffOrganization = $this->organization('registry-staff', 'Fleetbase Registry Staff', $staff, Membership::ROLE_MEMBER); + $staffOrganization->forceFill(['is_staff' => true])->save(); + + $publisher = $this->account(self::PUBLISHER_EMAIL, 'Acme Publisher', 'acme-publisher'); + $this->organization('acme-publisher', 'Acme Publisher', $publisher, Membership::ROLE_OWNER, personal: true); + $acme = $this->organization('acme', 'Acme Logistics', $publisher, Membership::ROLE_OWNER); + $acme->forceFill(['website' => 'https://acme.example.com', 'bio' => 'Demo publisher seeded by registry:seed-website.', 'verified_at' => $acme->verified_at ?? now()])->save(); + + $queuedListing = $this->listing($acme, $publisher, self::QUEUED_SLUG, 'Acme Fleet Insights', ready: true); + $draftListing = $this->listing($acme, $publisher, self::DRAFT_SLUG, 'Acme Route Planner', ready: false); + $queued = $this->queuedRelease($pipeline, $queuedListing, $publisher); + + $result = [ + 'staff' => ['email' => self::STAFF_EMAIL, 'password' => self::PASSWORD], + 'publisher' => ['email' => self::PUBLISHER_EMAIL, 'password' => self::PASSWORD], + 'extensions' => array_merge($catalog, [$queuedListing->slug, $draftListing->slug]), + 'queued_release' => $queued !== null && in_array($queued->status, Release::QUEUE_STATUSES, true) ? $queued->public_id : null, + ]; + + if ($this->option('json')) { + $this->line(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + return self::SUCCESS; + } + + $this->info('Seeded the publisher website demo.'); + $this->table(['Account', 'Email', 'Password'], [ + ['staff', self::STAFF_EMAIL, self::PASSWORD], + ['publisher (acme)', self::PUBLISHER_EMAIL, self::PASSWORD], + ]); + $this->line('Extensions: '.implode(', ', $result['extensions'])); + if ($result['queued_release']) { + $this->line("Queued release: {$result['queued_release']} ({$queuedListing->slug} {$queued->version}, {$queued->status})"); + } else { + $this->warn('The demo release is no longer in the review queue (status '.($queued?->status ?? 'missing').'). Run on a fresh database to queue it again.'); + } + + return self::SUCCESS; + } + + /** + * Fill the catalog copy of the two `registry:seed` listings. + * + * @return list their slugs + */ + protected function enrichCatalogListings(): array + { + $listings = [ + SeedRegistry::PUBLISHER.'-hello' => [ + 'category' => 'fleet-management', + 'subtitle' => 'A free demo extension that says hello from the console.', + 'tags' => ['demo', 'free', 'getting-started'], + ], + SeedRegistry::PUBLISHER.'-pro' => [ + 'category' => 'analytics-and-reporting', + 'subtitle' => 'A paid demo extension with reports for fleet managers.', + 'tags' => ['demo', 'paid', 'reports'], + ], + ]; + + foreach ($listings as $slug => $fields) { + Extension::query()->where('slug', $slug)->firstOrFail()->forceFill([ + 'category_uuid' => $this->categoryUuid($fields['category']), + 'subtitle' => $fields['subtitle'], + 'description' => 'Demo listing seeded by registry:seed-website. It installs a small engine and API package so the install flow can be exercised end to end.', + 'promotional_text' => '

Seeded for the publisher website end-to-end tests.

  • Installs on cloud and self-hosted instances
  • No configuration needed
', + 'tags' => $fields['tags'], + 'copyright' => '© Registry Demo', + 'website_url' => 'https://example.com', + 'support_url' => 'https://example.com/support', + 'privacy_url' => 'https://example.com/privacy', + ])->save(); + } + + return array_keys($listings); + } + + protected function listing(Organization $organization, Account $creator, string $slug, string $name, bool $ready): Extension + { + $extension = Extension::withTrashed()->firstOrNew(['slug' => $slug]); + $extension->forceFill(array_merge([ + 'organization_uuid' => $organization->uuid, + 'created_by_account_uuid' => $creator->uuid, + 'name' => $name, + 'hosting' => Extension::HOSTING_BOTH, + 'status' => $extension->exists && $extension->status !== Extension::STATUS_DRAFT ? $extension->status : Extension::STATUS_DRAFT, + 'deleted_at' => null, + ], $ready ? [ + 'category_uuid' => $this->categoryUuid('analytics-and-reporting'), + 'subtitle' => 'Dashboards for fuel, utilisation and driver behaviour.', + 'description' => 'Acme Fleet Insights adds dashboards and scheduled reports to the Fleetbase console.', + 'promotional_text' => '

See where your fleet really spends its time.

', + 'tags' => ['analytics', 'reports'], + 'languages' => ['en'], + 'copyright' => '© Acme Logistics', + 'website_url' => 'https://acme.example.com/insights', + 'support_url' => 'https://acme.example.com/support', + 'privacy_url' => 'https://acme.example.com/privacy', + 'fa_icon' => 'chart-line', + ] : [ + 'subtitle' => 'Plan multi-stop routes (draft).', + 'tags' => ['routing'], + ]))->save(); + + Price::query()->firstOrCreate(['extension_uuid' => $extension->uuid], ['model' => Price::MODEL_FREE, 'amount' => 0, 'currency' => 'USD']); + + foreach ($this->packageNames($organization, $slug) as $kind => $name) { + ExtensionPackage::query()->firstOrCreate(['kind' => $kind, 'name' => $name], ['extension_uuid' => $extension->uuid]); + } + + return $extension; + } + + /** + * The listing's 1.0.0 release, uploaded and submitted through the pipeline (first + * release, so it waits for staff). A release staff already decided on is left alone, + * except a rejected one, which is uploaded and submitted again. + */ + protected function queuedRelease(ReleasePipeline $pipeline, Extension $extension, Account $publisher): ?Release + { + $version = '1.0.0'; + $existing = Release::query()->where('extension_uuid', $extension->uuid)->where('version', $version)->first(); + if ($existing !== null && ! $existing->isReplaceable()) { + return $existing; + } + + ['npm' => $npm, 'composer' => $composer] = $this->packageNames($extension->organization()->firstOrFail(), $extension->slug); + $manifest = [ + 'name' => $extension->name, + 'version' => $version, + 'engine' => $npm, + 'api' => $composer, + 'compat' => ['console' => '>=0.7.0', 'api' => '^1.6'], + ]; + + $uploads = [ + 'engine' => $this->upload(Tarball::create([ + 'package/package.json' => json_encode(['name' => $npm, 'version' => $version, 'main' => 'index.js', 'keywords' => ['fleetbase-extension']]), + 'package/index.js' => "module.exports = { name: 'acme-fleet-insights' };\n", + ]), 'engine.tgz'), + 'api' => $this->upload(Tarball::create([ + 'package/composer.json' => json_encode(['name' => $composer, 'type' => 'library', 'autoload' => ['psr-4' => ['Acme\\FleetInsights\\' => 'src/']]]), + 'package/src/FleetInsights.php' => " 'sync']); + try { + $release = $pipeline->upload($extension, $publisher, $manifest, Release::CHANNEL_STABLE, 'Seeded by registry:seed-website.', $uploads, true); + } finally { + config(['queue.default' => $connection]); + foreach ($uploads as $upload) { + @unlink($upload->getRealPath()); + } + } + + return $release->fresh(); + } + + /** + * @return array{npm: string, composer: string} + */ + protected function packageNames(Organization $organization, string $slug): array + { + return ['npm' => '@'.$organization->slug.'/'.$slug.'-engine', 'composer' => $organization->slug.'/'.$slug.'-api']; + } + + protected function upload(string $contents, string $name): UploadedFile + { + $path = tempnam(sys_get_temp_dir(), 'seed'); + file_put_contents($path, $contents); + + return new UploadedFile($path, $name, 'application/gzip', null, true); + } + + protected function categoryUuid(string $slug): ?string + { + return Category::query()->where('slug', $slug)->value('uuid') ?? Category::query()->orderBy('sort')->value('uuid'); + } + + protected function account(string $email, string $name, string $username): Account + { + $account = Account::withTrashed()->firstOrNew(['email' => $email]); + $account->fill([ + 'name' => $name, + 'username' => $account->username ?? $username, + 'status' => Account::STATUS_ACTIVE, + 'email_verified_at' => $account->email_verified_at ?? now(), + 'timezone' => $account->timezone ?? 'UTC', + ]); + $account->password = self::PASSWORD; + $account->deleted_at = null; + $account->save(); + + return $account; + } + + protected function organization(string $slug, string $name, Account $member, string $role, bool $personal = false): Organization + { + $organization = Organization::withTrashed()->firstOrNew(['slug' => $slug]); + $organization->fill(['name' => $organization->name ?? $name, 'is_personal' => $personal]); + $organization->deleted_at = null; + $organization->save(); + + if ($member->roleIn($organization) === null) { + $member->organizations()->attach($organization->uuid, ['role' => $role]); + } + + return $organization; + } +} diff --git a/app/Console/Commands/SyncStripe.php b/app/Console/Commands/SyncStripe.php new file mode 100644 index 0000000..27c5060 --- /dev/null +++ b/app/Console/Commands/SyncStripe.php @@ -0,0 +1,68 @@ +error('Set STRIPE_SECRET first.'); + + return self::FAILURE; + } + + $failed = 0; + + Organization::query()->whereNotNull('stripe_connect_id')->each(function (Organization $organization) use ($accounts, &$failed) { + if ($this->option('dry-run')) { + $this->line("account {$organization->slug} {$organization->stripe_connect_id}"); + + return; + } + try { + $accounts->refresh($organization); + $this->line("account {$organization->slug} {$organization->payout_status}"); + } catch (\Throwable $e) { + $failed++; + $this->warn("account {$organization->slug} failed: {$e->getMessage()}"); + } + }); + + Price::query()->with('extension')->where('model', '!=', Price::MODEL_FREE)->where('amount', '>', 0)->whereNull('stripe_price_id')->each(function (Price $price) use ($catalog, &$failed) { + if (! $price->extension) { + return; + } + if ($this->option('dry-run')) { + $this->line("price {$price->extension->slug} {$price->amount} {$price->currency}"); + + return; + } + try { + $catalog->sync($price, $price->extension)->save(); + $this->line("price {$price->extension->slug} {$price->stripe_price_id}"); + } catch (\Throwable $e) { + $failed++; + $this->warn("price {$price->extension->slug} failed: {$e->getMessage()}"); + } + }); + + return $failed === 0 ? self::SUCCESS : self::FAILURE; + } +} 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..b2c932b --- /dev/null +++ b/app/Http/Controllers/Account/AccountController.php @@ -0,0 +1,69 @@ +user()->load('organizations')); + } + + /** + * PATCH /v1/account — name, username, timezone. The personal organization keeps its slug. + */ + public function update(UpdateAccountRequest $request): AccountResource + { + /** @var Account $account */ + $account = $request->user(); + $attributes = $request->validated(); + + $account->fill($attributes)->save(); + Event::record('account.updated', $account, $account, ['fields' => array_keys($attributes)], $request->ip()); + + return new AccountResource($account->load('organizations')); + } + + /** + * POST /v1/account/password — current_password, password, password_confirmation. Revokes + * the account's other website sessions; personal tokens stay valid. + */ + public function password(ChangePasswordRequest $request): JsonResponse + { + /** @var Account $account */ + $account = $request->user(); + + if (! $account->password || ! Hash::check($request->input('current_password'), $account->password)) { + throw ValidationException::withMessages(['current_password' => 'The current password is incorrect.']); + } + + $account->forceFill(['password' => $request->input('password')])->save(); + + $current = $account->currentToken(); + $account->tokens() + ->active() + ->where('prefix', Token::PREFIX_SESSION) + ->when($current, fn ($q) => $q->whereKeyNot($current->getKey())) + ->get() + ->each->revoke(); + + Event::record('account.password_changed', $account, $account, [], $request->ip()); + + return response()->json(['message' => 'Password changed.']); + } +} diff --git a/app/Http/Controllers/Account/GithubController.php b/app/Http/Controllers/Account/GithubController.php new file mode 100644 index 0000000..c410df4 --- /dev/null +++ b/app/Http/Controllers/Account/GithubController.php @@ -0,0 +1,41 @@ +user(); + + if ($account->github_id === null) { + return response()->error('GitHub isn’t connected to this account.', 422); + } + + if (! $account->password) { + return response()->error('Set a password before disconnecting GitHub, or you won’t be able to sign in. Use Forgot password on the sign-in page to set one.', 422, ['code' => 'password_required']); + } + + $login = $account->github_login; + $account->forceFill(['github_id' => null, 'github_login' => null])->save(); + Event::record('account.github_disconnected', $account, $account, ['github_login' => $login], $request->ip()); + + return new AccountResource($account->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..5085493 --- /dev/null +++ b/app/Http/Controllers/Account/TokenController.php @@ -0,0 +1,98 @@ +user(); + + $tokens = $account->tokens() + ->with('organization') + ->whereNull('revoked_at') + ->where('prefix', '!=', Token::PREFIX_SESSION) + ->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('prefix', '!=', Token::PREFIX_SESSION) + ->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/FacilitatorFeeController.php b/app/Http/Controllers/Admin/FacilitatorFeeController.php new file mode 100644 index 0000000..ee4c580 --- /dev/null +++ b/app/Http/Controllers/Admin/FacilitatorFeeController.php @@ -0,0 +1,65 @@ +with('createdBy')->orderByDesc('effective_from')->orderByDesc('id')->get(); + + return response()->json([ + 'current_rate_bps' => FacilitatorFee::rateAt(now()), + 'default_rate_bps' => (int) config('registry.commerce.facilitator_fee_bps', 1000), + 'data' => $fees->map(fn (FacilitatorFee $fee) => $this->present($fee))->values(), + ]); + } + + /** + * POST /v1/admin/facilitator-fees — `{rate_bps, effective_from?, note?}`; 1000 = 10%. + */ + public function store(Request $request): JsonResponse + { + $request->validate([ + 'rate_bps' => ['required', 'integer', 'min:0', 'max:5000'], + 'effective_from' => ['nullable', 'date', 'after_or_equal:'.now()->subMinute()->toIso8601String()], + 'note' => ['nullable', 'string', 'max:255'], + ], ['effective_from.after_or_equal' => 'A fee change cannot take effect in the past.']); + + $fee = FacilitatorFee::create([ + 'rate_bps' => (int) $request->input('rate_bps'), + 'effective_from' => $request->input('effective_from') ?? now(), + 'note' => $request->input('note'), + 'created_by_account_uuid' => $request->user()->uuid, + ]); + + Event::record('facilitator_fee.scheduled', $fee, $request->user(), ['rate_bps' => $fee->rate_bps, 'effective_from' => $fee->effective_from->toIso8601String()], $request->ip()); + + return response()->json($this->present($fee->load('createdBy')), 201); + } + + protected function present(FacilitatorFee $fee): array + { + return [ + 'id' => $fee->uuid, + 'rate_bps' => $fee->rate_bps, + 'effective_from' => $fee->effective_from, + 'note' => $fee->note, + 'created_by' => $fee->createdBy ? ['id' => $fee->createdBy->public_id, 'name' => $fee->createdBy->name] : null, + 'created_at' => $fee->created_at, + ]; + } +} diff --git a/app/Http/Controllers/Admin/FinanceController.php b/app/Http/Controllers/Admin/FinanceController.php new file mode 100644 index 0000000..3878ec8 --- /dev/null +++ b/app/Http/Controllers/Admin/FinanceController.php @@ -0,0 +1,141 @@ +json($this->report($request)->summary()); + } + + /** + * GET /v1/admin/finance/purchases[?from=&to=&status=&publisher=&extension=&page=] — newest first. + */ + public function purchases(Request $request): JsonResponse + { + $request->validate([ + 'status' => ['nullable', 'string', Rule::in([Purchase::STATUS_PAID, Purchase::STATUS_REFUNDED, Purchase::STATUS_DISPUTED])], + 'publisher' => ['nullable', 'string', 'max:191'], + 'extension' => ['nullable', 'string', 'max:191'], + ]); + + $page = $this->report($request)->purchases() + ->when($request->input('status'), fn (Builder $q, $status) => $q->where('status', $status)) + ->when($request->input('publisher'), fn (Builder $q, $publisher) => $q->whereHas('extension', fn (Builder $e) => $e->withTrashed() + ->whereHas('organization', fn (Builder $o) => $o->withTrashed()->where(fn (Builder $w) => $w->where('slug', $publisher)->orWhere('public_id', $publisher))))) + ->when($request->input('extension'), fn (Builder $q, $extension) => $q->whereHas('extension', fn (Builder $e) => $e->withTrashed() + ->where(fn (Builder $w) => $w->where('slug', $extension)->orWhere('public_id', $extension)))) + ->with(['buyer' => fn ($q) => $q->withTrashed(), 'extension' => fn ($q) => $q->withTrashed()->with(['organization' => fn ($o) => $o->withTrashed()])]) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->paginate(min(100, max(1, (int) $request->input('per_page', self::PER_PAGE)))); + + return response()->json([ + 'data' => FinancePurchaseResource::collection($page->getCollection())->resolve($request), + 'meta' => [ + 'current_page' => $page->currentPage(), + 'last_page' => $page->lastPage(), + 'per_page' => $page->perPage(), + 'total' => $page->total(), + ], + ]); + } + + /** + * GET /v1/admin/finance/monthly.csv[?from=&to=&by=publisher] — one row per month and currency + * (and publisher with `by=publisher`), amounts in major units. + */ + public function export(Request $request): StreamedResponse + { + $request->validate(['by' => ['nullable', 'string', 'in:publisher']]); + $report = $this->report($request); + $byPublisher = $request->input('by') === 'publisher'; + $rows = $byPublisher ? $report->monthlyByPublisher() : $report->summary()['months']; + $name = 'fleetbase-finance-'.$report->from->format('Y-m').'-to-'.$report->to->format('Y-m').($byPublisher ? '-by-publisher' : '').'.csv'; + + return response()->streamDownload(function () use ($rows, $byPublisher) { + $out = fopen('php://output', 'w'); + fputcsv($out, array_merge(['month'], $byPublisher ? ['publisher', 'publisher_id'] : [], ['currency'], FinanceReport::FIELDS)); + foreach ($rows as $row) { + fputcsv($out, array_merge( + [$row['month']], + $byPublisher ? [$row['organization']['name'] ?? '', $row['organization']['id'] ?? ''] : [], + [$row['currency']], + array_map(fn (string $field) => in_array($field, ['sales', 'refunds', 'disputes'], true) ? $row[$field] : static::decimal($row[$field]), FinanceReport::FIELDS), + )); + } + fclose($out); + }, $name, ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + /** + * POST /v1/admin/purchases/{purchase}/refund — `{amount?, reason?, note?}`. `amount` is in minor + * units and defaults to what is left. Answers 202 with Stripe's refund: the purchase turns + * `refunded` (and its licence revoked) when Stripe confirms with `charge.refunded`. + */ + public function refund(Request $request, Refunds $refunds, string $purchase): JsonResponse + { + $request->validate([ + 'amount' => ['nullable', 'integer', 'min:1'], + 'reason' => ['nullable', 'string', Rule::in(Refunds::REASONS)], + 'note' => ['nullable', 'string', 'max:500'], + ]); + + $model = Purchase::findByIdOrFail($purchase, ['buyer', 'extension.organization']); + $refund = $refunds->refund( + $model, + $request->user(), + $request->filled('amount') ? (int) $request->input('amount') : null, + $request->input('reason'), + $request->filled('note') ? trim((string) $request->input('note')) : null, + ); + + return response()->json([ + 'refund' => [ + 'id' => $refund->id, + 'amount' => (int) $refund->amount, + 'currency' => strtoupper((string) $refund->currency), + 'status' => $refund->status, + 'reason' => $refund->reason, + ], + 'purchase' => (new FinancePurchaseResource($model))->resolve($request), + ], 202); + } + + protected function report(Request $request): FinanceReport + { + $request->validate([ + 'from' => ['nullable', 'date'], + 'to' => array_merge(['nullable', 'date'], $request->filled('from') ? ['after_or_equal:from'] : []), + ]); + + return FinanceReport::between($request->input('from'), $request->input('to')); + } + + protected static function decimal(int $minor): string + { + return number_format($minor / 100, 2, '.', ''); + } +} diff --git a/app/Http/Controllers/Admin/LicenceController.php b/app/Http/Controllers/Admin/LicenceController.php new file mode 100644 index 0000000..0233eb4 --- /dev/null +++ b/app/Http/Controllers/Admin/LicenceController.php @@ -0,0 +1,128 @@ +validate([ + 'status' => ['nullable', 'in:'.Licence::STATUS_ACTIVE.','.Licence::STATUS_REVOKED], + 'kind' => ['nullable', 'in:'.Licence::KIND_FREE.','.Licence::KIND_PAID], + ]); + + $licences = Licence::query() + ->with(['extension', 'organization', 'purchase']) + ->withCount('activeActivations') + ->when($request->input('organization'), fn (Builder $q, $id) => $q->whereIn('organization_uuid', Organization::query()->where('slug', $id)->orWhere('public_id', $id)->select('uuid'))) + ->when($request->input('extension'), fn (Builder $q, $id) => $q->whereIn('extension_uuid', Extension::query()->where('slug', $id)->orWhere('public_id', $id)->select('uuid'))) + ->when($request->input('status'), fn (Builder $q, $status) => $q->where('status', $status)) + ->when($request->input('kind'), fn (Builder $q, $kind) => $q->where('kind', $kind)) + ->orderByDesc('created_at') + ->limit(100) + ->get(); + + return response()->json(LicenceResource::collection($licences)->resolve($request)); + } + + /** + * POST /v1/admin/licences/{id}/revoke — `reason` required. + */ + public function revoke(Request $request, string $licence): JsonResponse + { + $request->validate(['reason' => ['required', 'string', 'max:500']]); + $model = $this->find($licence); + + if ($model->status === Licence::STATUS_REVOKED) { + throw new ConflictHttpException('This licence is already revoked.'); + } + + $model->forceFill([ + 'status' => Licence::STATUS_REVOKED, + 'meta' => array_merge((array) $model->meta, [ + 'revoked_reason' => 'staff', + 'revoked_note' => $request->input('reason'), + 'revoked_at' => now()->toIso8601String(), + ]), + ])->save(); + + Event::record('licence.revoked', $model, $request->user(), ['reason' => $request->input('reason')], $request->ip()); + + return $this->present($request, $model); + } + + /** + * POST /v1/admin/licences/{id}/reinstate — optional `reason`. + */ + public function reinstate(Request $request, string $licence): JsonResponse + { + $request->validate(['reason' => ['nullable', 'string', 'max:500']]); + $model = $this->find($licence); + + if ($model->status === Licence::STATUS_ACTIVE) { + throw new ConflictHttpException('This licence is already active.'); + } + + $from = $model->meta['revoked_reason'] ?? null; + $model->forceFill([ + 'status' => Licence::STATUS_ACTIVE, + 'meta' => array_merge((array) $model->meta, [ + 'revoked_reason' => null, + 'reinstated_at' => now()->toIso8601String(), + 'reinstated_note' => $request->input('reason'), + ]), + ])->save(); + + Event::record('licence.reinstated', $model, $request->user(), ['was' => $from, 'reason' => $request->input('reason')], $request->ip()); + + return $this->present($request, $model); + } + + /** + * PATCH /v1/admin/licences/{id} — `max_activations` (seats; at least the seats in use). + */ + public function update(Request $request, string $licence): JsonResponse + { + $model = $this->find($licence); + $request->validate(['max_activations' => ['required', 'integer', 'min:'.max(1, $model->seatsUsed()), 'max:1000']]); + + $from = $model->max_activations; + $model->forceFill(['max_activations' => (int) $request->input('max_activations')])->save(); + + Event::record('licence.seats_changed', $model, $request->user(), ['from' => $from, 'to' => $model->max_activations], $request->ip()); + + return $this->present($request, $model); + } + + protected function find(string $id): Licence + { + return Licence::findById($id) ?? throw new NotFoundHttpException('Licence not found.'); + } + + protected function present(Request $request, Licence $licence): JsonResponse + { + $licence->load(['extension', 'organization', 'purchase'])->loadCount('activeActivations'); + + return response()->json((new LicenceResource($licence))->resolve($request)); + } +} diff --git a/app/Http/Controllers/Admin/ReviewController.php b/app/Http/Controllers/Admin/ReviewController.php new file mode 100644 index 0000000..e1327e3 --- /dev/null +++ b/app/Http/Controllers/Admin/ReviewController.php @@ -0,0 +1,171 @@ +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(array_merge(PublisherController::RELEASE_LISTING, ['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(); + PreviousReleases::attach($releases->getCollection()); + + return AdminReleaseResource::collection($releases); + } + + /** + * GET /v1/admin/review-items — the review requirement codes for approve and reject items. + */ + public function reviewItems(): JsonResponse + { + return response()->json(array_map(fn (array $item) => ['code' => $item['code'], 'label' => $item['label']], config('registry.review.items', []))); + } + + /** + * GET /v1/admin/releases/{id} — checks, policy reasons, timeline, the listing preview and + * `previous_version` / `previous` (the live release it updates, for what changed). + */ + public function show(string $release): AdminReleaseResource + { + return AdminReleaseResource::detailed(PublisherController::releaseDetail(Release::findByIdOrFail($release))); + } + + /** + * POST /v1/admin/releases/{id}/claim — submitted → in_review. + */ + public function claim(Request $request, string $release): AdminReleaseResource + { + $release = $this->pipeline->claim(Release::findByIdOrFail($release), $request->user()); + + return AdminReleaseResource::detailed(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): AdminReleaseResource + { + $release = $this->pipeline->approve( + Release::findByIdOrFail($release), + $request->user(), + $request->validated('reason'), + $request->items(), + $request->boolean('publish', true), + ); + + return AdminReleaseResource::detailed(PublisherController::releaseDetail($release)); + } + + /** + * POST /v1/admin/releases/{id}/reject — required reason and items. Allowed while the + * release is submitted, in review or approved but not yet published (request changes on an + * approval); anything else is a 409. + */ + public function reject(ReviewDecisionRequest $request, string $release): AdminReleaseResource + { + $release = $this->pipeline->reject(Release::findByIdOrFail($release), $request->user(), $request->validated('reason'), $request->items()); + + return AdminReleaseResource::detailed(PublisherController::releaseDetail($release)); + } + + /** + * POST /v1/admin/releases/{id}/publish — an approved release. + */ + public function publish(Request $request, string $release): AdminReleaseResource + { + $release = $this->pipeline->publish(Release::findByIdOrFail($release), $request->user()); + + return AdminReleaseResource::detailed(PublisherController::releaseDetail($release)); + } + + /** + * GET /v1/admin/releases/{id}/files/{kind} — the stored `engine`, `api` or `dist` archive + * as a download, for staff to inspect. 404 when the release has no such file. + */ + public function download(Request $request, string $release, string $kind, ReleaseStorage $storage): Response + { + $release = Release::findByIdOrFail($release); + $file = File::find($release->{ReleasePipeline::FILE_COLUMNS[$kind]}); + + if ($file === null) { + throw new NotFoundHttpException("This release has no {$kind} archive."); + } + + Event::record('release.downloaded', $release, $request->user(), ['kind' => $kind, 'file' => $file->public_id], $request->ip()); + + return $storage->download($file, $request, ['Cache-Control' => 'private, no-store']); + } + + /** + * 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(PublisherExtensionController::DETAIL)); + } + + /** + * POST /v1/admin/extensions/{slug}/unsuspend — optional reason. Restores the status the + * listing had when it was suspended. + */ + public function unsuspend(Request $request, string $slug): PublisherExtensionResource + { + $request->validate(['reason' => ['nullable', 'string', 'max:5000']]); + + $extension = Extension::query()->where(fn (Builder $q) => $q->where('slug', $slug)->orWhere('public_id', $slug))->firstOrFail(); + $extension = $this->pipeline->unsuspend($extension, $request->user(), $request->input('reason')); + + return new PublisherExtensionResource($extension->load(PublisherExtensionController::DETAIL)); + } +} diff --git a/app/Http/Controllers/Auth/AuthController.php b/app/Http/Controllers/Auth/AuthController.php new file mode 100644 index 0000000..eafe7a4 --- /dev/null +++ b/app/Http/Controllers/Auth/AuthController.php @@ -0,0 +1,125 @@ +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 = $this->attempt($request); + if ($account instanceof JsonResponse) { + return $account; + } + + $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/Auth/ChecksCredentials.php b/app/Http/Controllers/Auth/ChecksCredentials.php new file mode 100644 index 0000000..de95fea --- /dev/null +++ b/app/Http/Controllers/Auth/ChecksCredentials.php @@ -0,0 +1,43 @@ +` for inactive accounts. + */ +trait ChecksCredentials +{ + /** + * The account behind `email` and `password`, or the error response to return. + * The password check uses Hash::check, the same as core-api's Auth::checkPassword. + */ + protected function attempt(Request $request): Account|JsonResponse + { + $email = Str::lower((string) $request->input('email')); + $account = Account::where('email', $email)->first(); + + if (! $account || ! $account->password || ! Hash::check((string) $request->input('password'), $account->password)) { + Event::record('account.login_failed', $account, null, ['email' => $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]); + } + + return $account; + } +} diff --git a/app/Http/Controllers/Auth/GithubSignInController.php b/app/Http/Controllers/Auth/GithubSignInController.php new file mode 100644 index 0000000..2c56517 --- /dev/null +++ b/app/Http/Controllers/Auth/GithubSignInController.php @@ -0,0 +1,207 @@ +fail($request, 'Sign in with GitHub isn’t set up on this registry.'); + } + + $intended = $request->query('intended'); + if (is_string($intended) && str_starts_with($intended, '/') && ! str_starts_with($intended, '//') && ! str_contains($intended, '\\')) { + $request->session()->put('url.intended', url($intended)); + } + + return Socialite::driver('github')->scopes(self::SCOPES)->redirect(); + } + + /** + * GET /auth/github/callback + */ + public function callback(Request $request, RegisterAccount $registerAccount): RedirectResponse + { + if (! static::configured()) { + return $this->fail($request, 'Sign in with GitHub isn’t set up on this registry.'); + } + + if ($request->filled('error')) { + return $this->fail($request, $request->query('error') === 'access_denied' + ? 'GitHub sign-in was cancelled.' + : 'GitHub didn’t sign you in. Try again.'); + } + + try { + $github = Socialite::driver('github')->user(); + } catch (InvalidStateException) { + return $this->fail($request, 'That GitHub sign-in expired or started in another tab. Try again.'); + } catch (Throwable $e) { + report($e); + + return $this->fail($request, 'GitHub couldn’t be reached. Try again.'); + } + + /** @var Account|null $current */ + $current = Auth::guard('web')->user(); + + return $current + ? $this->connect($request, $current, $github) + : $this->signIn($request, $github, $registerAccount); + } + + /** + * Signed in already: link the GitHub account to this one, unless another account has it. + */ + protected function connect(Request $request, Account $account, SocialiteUser $github): RedirectResponse + { + $githubId = (string) $github->getId(); + $owner = Account::withTrashed()->where('github_id', $githubId)->first(); + + if ($owner && ! $owner->is($account)) { + Event::record('account.github_conflict', $account, $account, ['github_login' => $github->getNickname()], $request->ip()); + + return $this->fail($request, 'That GitHub account (@'.$github->getNickname().') is connected to a different registry account. Disconnect it there first.'); + } + + $this->link($request, $account, $github); + + return redirect('/account/profile')->with('flash', ['type' => 'ok', 'message' => 'GitHub connected as @'.$github->getNickname().'.']); + } + + protected function signIn(Request $request, SocialiteUser $github, RegisterAccount $registerAccount): RedirectResponse + { + $githubId = (string) $github->getId(); + // Socialite's GitHub provider reads the primary email from /user/emails and keeps it + // only when GitHub has verified it, so `getEmail()` is a verified address or null. + $email = $github->getEmail() ? Str::lower($github->getEmail()) : null; + + $account = Account::withTrashed()->where('github_id', $githubId)->first(); + + if (! $account && $email) { + $account = Account::withTrashed()->where('email', $email)->first(); + + if ($account?->github_id) { + return $this->fail($request, 'The registry account for '.$email.' is connected to a different GitHub account. Sign in with your email and password.'); + } + } + + if ($account?->trashed()) { + return $this->fail($request, 'That registry account has been deleted.'); + } + + if (! $account) { + if (! $email) { + return $this->fail($request, 'Your GitHub account has no verified primary email. Verify one on GitHub, or create an account with your email.'); + } + + $account = $registerAccount([ + 'name' => $github->getName() ?: $github->getNickname(), + 'email' => $email, + 'username' => $this->freeUsername($github->getNickname()), + 'generate_username' => false, + 'slug' => $github->getNickname(), + 'email_verified' => true, + 'avatar_url' => $github->getAvatar(), + ], $request->ip()); + } + + if ($account->status === Account::STATUS_SUSPENDED) { + Event::record('account.login_failed', $account, null, ['via' => 'github', 'reason' => 'suspended'], $request->ip()); + + return $this->fail($request, 'This account is suspended.'); + } + + $this->link($request, $account, $github); + + // Matched by a GitHub-verified email: that proves the owner reads it. + if (! $account->hasVerifiedEmail()) { + $account->markEmailAsVerified(); + } + + if (! $account->isActive()) { + return $this->fail($request, 'This account is '.$account->status.'.'); + } + + SessionGuard::signIn($request, $account, 'github'); + + return redirect()->intended('/studio'); + } + + protected function link(Request $request, Account $account, SocialiteUser $github): void + { + $linking = $account->github_id === null; + + $account->forceFill([ + 'github_id' => (string) $github->getId(), + 'github_login' => $github->getNickname(), + 'avatar_url' => $account->avatar_url ?: $github->getAvatar(), + ])->save(); + + if ($linking) { + Event::record('account.github_connected', $account, $account, ['github_login' => $github->getNickname()], $request->ip()); + } + } + + /** + * The GitHub login as a registry username when it fits the username rules and nobody + * (no account, no organization slug) has it; otherwise none. + */ + protected function freeUsername(?string $login): ?string + { + $username = Str::lower((string) $login); + + if (strlen($username) < 3 || strlen($username) > 40 || ! preg_match('/^[a-z0-9][a-z0-9-]*$/', $username)) { + return null; + } + + $taken = Account::withTrashed()->where('username', $username)->exists() + || Organization::withTrashed()->where('slug', $username)->exists(); + + return $taken ? null : $username; + } + + /** + * Back to sign in with the message, or to the profile when someone is signed in. + */ + protected function fail(Request $request, string $message): RedirectResponse + { + $to = Auth::guard('web')->check() ? '/account/profile' : '/sign-in'; + + return redirect($to)->with('flash', ['type' => 'error', 'message' => $message]); + } +} diff --git a/app/Http/Controllers/Auth/PasswordResetController.php b/app/Http/Controllers/Auth/PasswordResetController.php new file mode 100644 index 0000000..18853ca --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordResetController.php @@ -0,0 +1,73 @@ +validate(['email' => ['required', 'string', 'email:rfc']]); + + $account = Account::where('email', Str::lower($request->input('email')))->first(); + + if ($account && $account->status !== Account::STATUS_SUSPENDED) { + $code = VerificationCode::generateFor($account, VerificationCode::FOR_PASSWORD_RESET); + Mail::to($account->email)->send(new PasswordResetCodeMail($account, $code)); + Event::record('account.password_reset_requested', $account, null, [], $request->ip()); + } + + return response()->json(['message' => 'If an account uses that email, we sent it a code to reset the password.'], 202); + } + + /** + * POST /v1/auth/password/reset — email, code, password, password_confirmation. Sets the + * password, uses up the code, revokes every token and website session of the account, and + * marks the email verified (the code proved the owner reads it). A wrong, used or expired + * code (or an unknown or suspended account) is 422 on `code`. + */ + public function reset(ResetPasswordRequest $request): JsonResponse + { + $account = Account::where('email', Str::lower($request->input('email')))->first(); + + if (! $account || $account->status === Account::STATUS_SUSPENDED || ! VerificationCode::consume($account, $request->input('code'), VerificationCode::FOR_PASSWORD_RESET)) { + throw ValidationException::withMessages(['code' => 'That code is wrong or has expired. Check it, or send a new one.']); + } + + DB::transaction(function () use ($account, $request) { + $account->forceFill(['password' => $request->input('password')])->save(); + + if (! $account->hasVerifiedEmail()) { + $account->markEmailAsVerified(); + } + + // Personal tokens and website (flbs_) sessions alike: whoever knew the old password + // is signed out everywhere. + $account->tokens()->active()->get()->each->revoke(); + }); + + Event::record('account.password_reset', $account, $account, [], $request->ip()); + + return response()->json(['message' => 'Password changed. Sign in with the new one.']); + } +} diff --git a/app/Http/Controllers/Auth/SessionController.php b/app/Http/Controllers/Auth/SessionController.php new file mode 100644 index 0000000..54e1def --- /dev/null +++ b/app/Http/Controllers/Auth/SessionController.php @@ -0,0 +1,84 @@ +user(); + + return response()->json([ + 'authenticated' => $account !== null, + 'account' => $account ? (new AccountResource($account->load('organizations')))->resolve($request) : null, + ]); + } + + /** + * POST /v1/auth/session — email and password. Same checks and error codes as + * `auth/login`; on success the session id is regenerated and bound to a new session token. + */ + public function store(Request $request): JsonResponse + { + $request->validate([ + 'email' => ['required', 'string', 'email:rfc'], + 'password' => ['required', 'string'], + ]); + + if (! $request->hasSession()) { + return response()->error('Sign in with a cookie session, or use /v1/auth/login for a bearer token.', 400); + } + + $account = $this->attempt($request); + if ($account instanceof JsonResponse) { + return $account; + } + + SessionGuard::signIn($request, $account); + + return response()->json([ + 'account' => (new AccountResource($account->load('organizations')))->resolve($request), + ]); + } + + /** + * DELETE /v1/auth/session — revokes the session token, invalidates the session and + * issues a new CSRF token. 200 even when the session was not signed in. + */ + public function destroy(Request $request): JsonResponse + { + if ($request->hasSession()) { + $token = SessionGuard::token($request); + if ($token !== null) { + $token->revoke(); + Event::record('account.logout', $token->account, $token->account, ['token' => $token->public_id, 'via' => 'session'], $request->ip()); + } + + $request->session()->invalidate(); + $request->session()->regenerateToken(); + } + + return response()->json(['authenticated' => false, 'message' => 'Signed out.']); + } +} diff --git a/app/Http/Controllers/Catalog/CatalogController.php b/app/Http/Controllers/Catalog/CatalogController.php new file mode 100644 index 0000000..71e861b --- /dev/null +++ b/app/Http/Controllers/Catalog/CatalogController.php @@ -0,0 +1,173 @@ +` each item carries `compatibility`: whether its latest release + * works with that console and the newest release that does; `compatible=1` keeps only + * listings with such a release. + */ + public function extensions(Request $request): JsonResponse + { + return $this->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(); + + $console = $request->input('console'); + if (is_string($console) && Semver::valid($console)) { + $newest = Compat::newestCompatible($console, $paginator->getCollection()->pluck('uuid')); + $paginator->getCollection()->each(fn (Extension $extension) => $extension->setAttribute('compatibility', [ + 'console' => $console, + 'compatible' => $extension->latestRelease !== null && Compat::satisfies($console, data_get($extension->latestRelease->compat, 'console')), + 'newest_compatible' => $newest[$extension->uuid] ?? null, + ])); + } + + 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.engineDist']) + ->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') + ->with('engineDist') + ->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.engineDist']) + ->withCount(['installs' => fn ($q) => $q->active()]); + } + + /** + * 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 = Extension::findByIdentifier($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; + + $replaced = $status === Install::STATUS_INSTALLED + ? Install::query() + ->where('instance_uuid', $instance->uuid) + ->where('extension_uuid', $extension->uuid) + ->where('status', Install::STATUS_INSTALLED) + ->when($install->exists, fn ($q) => $q->whereKeyNot($install->getKey())) + ->orderByDesc('id') + ->get(['uuid', 'release_uuid']) + : collect(); + + $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(), + 'installed_at' => $status === Install::STATUS_INSTALLED ? now() : null, + 'previous_release_uuid' => $replaced->first()?->release_uuid, + ])->save(); + + if ($replaced->isNotEmpty()) { + Install::query()->whereKey($replaced->modelKeys())->update([ + 'status' => Install::STATUS_UNINSTALLED, + 'finished_at' => now(), + 'replaced_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 = Extension::findByIdentifier($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(), + 'uninstalled_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, + ]); + } + + /** + * 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..b0fd6ad --- /dev/null +++ b/app/Http/Controllers/Instance/InstanceController.php @@ -0,0 +1,208 @@ +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); + } + + $previous = $request->filled('previous_token') ? Instance::findByToken((string) $request->input('previous_token')) : null; + $matched = $previous !== null && $previous->organization_uuid === $organization->uuid ? 'token' : null; + if ($previous === null && ($previous = Instance::findByHost($organization->uuid, $request->input('host')))) { + $matched = 'host'; + } + $rotated = $matched !== null; + + [$instance, $plain] = DB::transaction(function () use ($request, $code, $organization, $previous, $rotated) { + $attributes = [ + '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(), + ]; + + if ($rotated) { + $instance = tap($previous)->update($attributes); + } else { + $instance = Instance::create($attributes + [ + 'organization_uuid' => $organization->uuid, + 'registered_by_account_uuid' => $code->account_uuid, + ]); + + if ($previous) { + $this->replace($previous, $instance); + } + } + + $plain = $instance->issueToken(); + $code->forceFill(['instance_uuid' => $instance->uuid])->save(); + + return [$instance, $plain]; + }); + + Event::record($rotated ? 'instance.reregistered' : 'instance.registered', $instance, $code->account, array_filter([ + 'code' => $code->uuid, + 'matched_by' => $matched, + 'replaced' => $previous && ! $rotated ? $previous->public_id : null, + ]), $request->ip()); + + return response()->json([ + 'token' => $plain, + 'token_type' => 'Bearer', + 'rotated' => $rotated, + 'instance' => (new InstanceResource($instance->load('organization')))->resolve($request), + ], $rotated ? 200 : 201); + } + + /** + * GET /v1/instances/me — the instance, its organization and the organization's + * licences with seat usage, whether this instance holds a seat, and the receipt of a + * bought licence (so a console can link it after a re-registration or a cache clear). + */ + public function me(Request $request): JsonResponse + { + /** @var Instance $instance */ + $instance = $request->user(); + + $licences = Licence::query() + ->where('organization_uuid', $instance->organization_uuid) + ->with(['extension', 'purchase']) + ->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.engineDist']) + ->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) + ->with('engineDist') + ->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(), + ], + ]); + } + + /** + * Retire an instance that re-registered into another organization: revoke it, release the + * seats it held on its old organization's licences and move its open installs to the new + * instance, which claims seats in its own organization on its next install report. + */ + protected function replace(Instance $previous, Instance $instance): void + { + $previous->forceFill(['status' => Instance::STATUS_REVOKED])->save(); + + LicenceActivation::query() + ->where('instance_uuid', $previous->uuid) + ->whereNull('deactivated_at') + ->update(['deactivated_at' => now(), 'updated_at' => now()]); + + Install::query() + ->where('instance_uuid', $previous->uuid) + ->whereIn('status', [Install::STATUS_INSTALLING, Install::STATUS_INSTALLED]) + ->update(['instance_uuid' => $instance->uuid, 'updated_at' => now()]); + } +} diff --git a/app/Http/Controllers/Instance/PurchaseController.php b/app/Http/Controllers/Instance/PurchaseController.php new file mode 100644 index 0000000..c8dde59 --- /dev/null +++ b/app/Http/Controllers/Instance/PurchaseController.php @@ -0,0 +1,82 @@ +user(); + + $extension = Extension::findByIdentifier($request->input('extension')); + if (! $extension) { + return response()->error('Extension not found.', 404); + } + + foreach (array_filter([$request->input('return_url'), $request->input('cancel_url')]) as $url) { + if (! $instance->ownsUrl($url)) { + return response()->error($instance->hostname() + ? "Return URLs must be on this instance's host ({$instance->hostname()}) or its domain." + : 'This instance has no registered host. Register it again with its URL to buy extensions.', 422); + } + } + + $checkout = $sessions->start( + $extension, + $instance->organization, + Checkout::SOURCE_INSTANCE, + $request->input('return_url'), + $request->input('cancel_url') ?? $request->input('return_url'), + ['instance' => $instance, 'email' => $request->input('email')], + ); + + Event::record('checkout.started', $checkout, $instance, ['extension' => $extension->public_id, 'source' => Checkout::SOURCE_INSTANCE], $request->ip()); + + return (new CheckoutResource($checkout->load(['extension', 'organization'])))->response()->setStatusCode(201); + } + + /** + * GET /v1/instances/me/purchases/{handoff} — the hand-off's status; `complete` carries the + * purchase and its licence. Any checkout of the instance's organization. + */ + public function show(Request $request, string $handoff): CheckoutResource + { + /** @var Instance $instance */ + $instance = $request->user(); + + $checkout = Checkout::query() + ->where('public_id', $handoff) + ->where('organization_uuid', $instance->organization_uuid) + ->with(['extension', 'organization', 'purchase.extension', 'purchase.licence']) + ->first(); + + if (! $checkout) { + throw new NotFoundHttpException('Checkout not found.'); + } + + return new CheckoutResource($checkout); + } +} diff --git a/app/Http/Controllers/Organization/ActsForOrganization.php b/app/Http/Controllers/Organization/ActsForOrganization.php new file mode 100644 index 0000000..cc1758a --- /dev/null +++ b/app/Http/Controllers/Organization/ActsForOrganization.php @@ -0,0 +1,64 @@ +user(); + } + + /** + * One of the caller's organizations by slug, public id or uuid, with the caller's + * membership as `pivot`. Organizations the caller is not in answer 404. + */ + protected function findOrganization(Request $request, string $identifier, array $with = []): Organization + { + $account = $this->account($request); + + $organization = $account->organizations() + ->with($with) + ->where(fn (Builder $q) => $q->where('organizations.slug', $identifier) + ->orWhere('organizations.public_id', $identifier) + ->orWhere('organizations.uuid', $identifier)) + ->first(); + + if (! $organization instanceof Organization) { + throw new NotFoundHttpException('Organization not found.'); + } + + $pinned = $account->currentToken()?->organization_uuid; + if ($pinned && $pinned !== $organization->uuid) { + throw new AccessDeniedHttpException('This token is limited to another organization.'); + } + + return $organization; + } + + /** + * @param list $roles + */ + protected function assertRole(Organization $organization, array $roles, string $message): void + { + if (! in_array($organization->pivot?->role, $roles, true)) { + throw new AccessDeniedHttpException($message); + } + } +} diff --git a/app/Http/Controllers/Organization/BillingController.php b/app/Http/Controllers/Organization/BillingController.php new file mode 100644 index 0000000..6fdc20e --- /dev/null +++ b/app/Http/Controllers/Organization/BillingController.php @@ -0,0 +1,66 @@ +billingOrganization($request, $slug); + + $purchases = Purchase::query() + ->where('buyer_organization_uuid', $organization->uuid) + ->with(['extension', 'buyer', 'buyerAccount', 'licence']) + ->orderByDesc('created_at') + ->orderByDesc('id') + ->limit(500) + ->get(); + + return response()->json(PurchaseResource::collection($purchases)->resolve($request)); + } + + /** + * GET /v1/organizations/{slug}/billing/licences — paid and free licences with seat use and + * the instances activated on each. Any member. + */ + public function licences(Request $request, string $slug): JsonResponse + { + $organization = $this->findOrganization($request, $slug); + + $licences = Licence::query() + ->where('organization_uuid', $organization->uuid) + ->with(['extension', 'purchase', 'activeActivations' => fn ($q) => $q->with(['instance', 'release'])->orderBy('activated_at')]) + ->withCount('activeActivations') + ->orderByRaw('case when kind = ? then 0 else 1 end', [Licence::KIND_PAID]) + ->orderByDesc('created_at') + ->get(); + + return response()->json(LicenceResource::collection($licences)->resolve($request)); + } + + protected function billingOrganization(Request $request, string $slug): Organization + { + $organization = $this->findOrganization($request, $slug); + $this->assertRole($organization, self::BILLING_ROLES, 'Only organization owners, admins and billing members can see billing.'); + + return $organization; + } +} diff --git a/app/Http/Controllers/Organization/OrganizationController.php b/app/Http/Controllers/Organization/OrganizationController.php new file mode 100644 index 0000000..d7d8b07 --- /dev/null +++ b/app/Http/Controllers/Organization/OrganizationController.php @@ -0,0 +1,282 @@ +account($request); + $pinned = $account->currentToken()?->organization_uuid; + + $organizations = $account->organizations() + ->withCount('memberships') + ->when($pinned, fn (Builder $q) => $q->where('organizations.uuid', $pinned)) + ->orderByDesc('is_personal') + ->orderBy('name') + ->get(); + + return response()->json(OrganizationResource::collection($organizations)->resolve($request)); + } + + /** + * POST /v1/organizations — name, optional slug, website, bio. The caller becomes its owner. + */ + public function store(CreateOrganizationRequest $request): JsonResponse + { + $account = $this->account($request); + if ($account->currentToken()?->organization_uuid) { + throw new AccessDeniedHttpException('This token is limited to another organization.'); + } + + $organization = DB::transaction(function () use ($request, $account) { + $organization = Organization::create([ + 'slug' => $request->input('slug') ?? $this->uniqueSlug($request->input('name')), + 'name' => $request->input('name'), + 'website' => $request->input('website'), + 'bio' => $request->input('bio'), + 'is_personal' => false, + ]); + $account->organizations()->attach($organization->uuid, ['role' => Membership::ROLE_OWNER]); + + return $organization; + }); + + Event::record('organization.created', $organization, $account, ['slug' => $organization->slug], $request->ip()); + + return response()->json($this->detail($request, $organization->slug)->resolve($request), 201); + } + + /** + * GET /v1/organizations/{slug} — the profile, the caller's role and the member list. + */ + public function show(Request $request, string $slug): OrganizationResource + { + return $this->detail($request, $slug); + } + + /** + * PATCH /v1/organizations/{slug} — name, website, bio, avatar_url. Owners and admins; + * the slug never changes. + */ + public function update(UpdateOrganizationRequest $request, string $slug): OrganizationResource + { + $organization = $this->findOrganization($request, $slug); + $this->assertManages($organization); + + $attributes = collect($request->validated())->only(['name', 'website', 'bio', 'avatar_url'])->all(); + $organization->fill($attributes)->save(); + Event::record('organization.updated', $organization, $this->account($request), ['fields' => array_keys($attributes)], $request->ip()); + + return $this->detail($request, $organization->slug); + } + + /** + * POST /v1/organizations/{slug}/members — email, role. Owners and admins; the email must + * belong to a registry account. Personal organizations have no other members. + */ + public function addMember(MemberRequest $request, string $slug): JsonResponse + { + $organization = $this->findOrganization($request, $slug); + $this->assertManages($organization); + + if ($organization->is_personal) { + throw new UnprocessableEntityHttpException('Personal organizations cannot have other members. Create an organization to work with others.'); + } + + $role = $request->input('role'); + $this->assertMayAssign($organization, $role); + + $member = Account::query()->where('email', Str::lower($request->input('email')))->first(); + if ($member === null) { + throw new UnprocessableEntityHttpException('No registry account uses that email. Ask them to register first.'); + } + + if ($member->roleIn($organization) !== null) { + throw new ConflictHttpException("{$member->email} is already a member."); + } + + $membership = Membership::create(['organization_uuid' => $organization->uuid, 'account_uuid' => $member->uuid, 'role' => $role]); + Event::record('organization.member_added', $organization, $this->account($request), ['account' => $member->public_id, 'role' => $role], $request->ip()); + + return response()->json((new MemberResource($membership->load('account')))->resolve($request), 201); + } + + /** + * PATCH /v1/organizations/{slug}/members/{account} — role. Owners and admins; only + * owners grant or change the owner role, and the last owner cannot be demoted. + */ + public function updateMember(MemberRequest $request, string $slug, string $member): MemberResource + { + $organization = $this->findOrganization($request, $slug); + $this->assertManages($organization); + + $membership = $this->findMembership($organization, $member); + $role = $request->input('role'); + + $from = $membership->role; + + if ($from !== $role) { + $this->assertMayAssign($organization, $role); + if ($from === Membership::ROLE_OWNER) { + $this->assertMayAssign($organization, Membership::ROLE_OWNER); + } + + DB::transaction(function () use ($organization, $membership, $role, $from) { + if ($from === Membership::ROLE_OWNER) { + $this->assertNotLastOwner($organization, 'demoted'); + } + $membership->forceFill(['role' => $role])->save(); + }); + + Event::record('organization.member_role_changed', $organization, $this->account($request), [ + 'account' => $membership->account->public_id, + 'from' => $from, + 'to' => $role, + ], $request->ip()); + } + + return new MemberResource($membership->load('account')); + } + + /** + * DELETE /v1/organizations/{slug}/members/{account} — owners and admins, or members + * leaving. Removing another owner takes an owner; the last owner cannot be removed. + */ + public function removeMember(Request $request, string $slug, string $member): JsonResponse + { + $organization = $this->findOrganization($request, $slug); + $membership = $this->findMembership($organization, $member); + $account = $this->account($request); + $leaving = $membership->account_uuid === $account->uuid; + + if (! $leaving) { + $this->assertManages($organization); + if ($membership->role === Membership::ROLE_OWNER) { + $this->assertMayAssign($organization, Membership::ROLE_OWNER); + } + } + + DB::transaction(function () use ($organization, $membership) { + if ($membership->role === Membership::ROLE_OWNER) { + $this->assertNotLastOwner($organization, 'removed'); + } + $membership->delete(); + }); + + Event::record($leaving ? 'organization.member_left' : 'organization.member_removed', $organization, $account, [ + 'account' => $membership->account->public_id, + 'role' => $membership->role, + ], $request->ip()); + + return response()->json(['removed' => true, 'id' => $membership->account->public_id]); + } + + protected function detail(Request $request, string $identifier): OrganizationResource + { + $organization = $this->findOrganization($request, $identifier, ['memberships' => fn ($q) => $q->with('account')->orderBy('created_at')]); + $organization->loadCount('memberships'); + + // Owners first, then by role and name. + $order = array_flip(Membership::ROLES); + $organization->setRelation('memberships', $organization->memberships->sortBy([ + fn ($a, $b) => ($order[$a->role] ?? 99) <=> ($order[$b->role] ?? 99), + fn ($a, $b) => strcasecmp((string) $a->account?->name, (string) $b->account?->name), + ])->values()); + + return new OrganizationResource($organization); + } + + protected function findMembership(Organization $organization, string $identifier): Membership + { + $membership = Membership::query() + ->with('account') + ->where('organization_uuid', $organization->uuid) + ->whereHas('account', fn (Builder $q) => $q->where('public_id', $identifier) + ->orWhere('uuid', $identifier) + ->orWhere('username', $identifier)) + ->first(); + + if ($membership === null) { + throw new NotFoundHttpException('Member not found.'); + } + + return $membership; + } + + protected function assertManages(Organization $organization): void + { + if (! in_array($organization->pivot?->role, self::MANAGING_ROLES, true)) { + throw new AccessDeniedHttpException('Only organization owners and admins can do this.'); + } + } + + protected function assertMayAssign(Organization $organization, string $role): void + { + if ($role === Membership::ROLE_OWNER && $organization->pivot?->role !== Membership::ROLE_OWNER) { + throw new AccessDeniedHttpException('Only owners can grant or change the owner role.'); + } + } + + protected function assertNotLastOwner(Organization $organization, string $action): void + { + $owners = Membership::query() + ->where('organization_uuid', $organization->uuid) + ->where('role', Membership::ROLE_OWNER) + ->lockForUpdate() + ->count(); + + if ($owners <= 1) { + throw new UnprocessableEntityHttpException("The last owner cannot be {$action}. Make another member an owner first."); + } + } + + protected function uniqueSlug(string $name): string + { + $base = Utils::slugify($name); + $reserved = config('registry.reserved_scopes', []); + $candidate = $base; + + while (in_array($candidate, $reserved, true) || Organization::withTrashed()->where('slug', $candidate)->exists()) { + $candidate = $base.'-'.Str::lower(Str::random(4)); + } + + return $candidate; + } +} diff --git a/app/Http/Controllers/Organization/PayoutController.php b/app/Http/Controllers/Organization/PayoutController.php new file mode 100644 index 0000000..d42e277 --- /dev/null +++ b/app/Http/Controllers/Organization/PayoutController.php @@ -0,0 +1,191 @@ +billingOrganization($request, $slug); + $connect = ConnectAccounts::summary($organization); + + if (! $organization->hasStripeAccount()) { + return response()->json(['connect' => $connect, 'balance' => null, 'schedule' => null, 'payouts' => []]); + } + + $stripe = Stripe::client(); + $on = Stripe::onAccount($organization->stripe_connect_id); + $balance = $stripe->balance->retrieve(null, $on); + $payouts = $stripe->payouts->all(['limit' => 100], $on); + $account = $stripe->accounts->retrieve($organization->stripe_connect_id); + $schedule = $account['settings']['payouts']['schedule'] ?? null; + + $inTransit = []; + foreach ($payouts->data as $payout) { + if ($payout->status === 'in_transit') { + $currency = strtoupper($payout->currency); + $inTransit[$currency] = ($inTransit[$currency] ?? 0) + (int) $payout->amount; + } + } + + return response()->json([ + 'connect' => $connect, + 'balance' => [ + 'available' => $this->amounts($balance->available ?? []), + 'pending' => $this->amounts($balance->pending ?? []), + 'in_transit' => collect($inTransit)->map(fn ($amount, $currency) => ['amount' => $amount, 'currency' => $currency])->values()->all(), + ], + 'schedule' => $schedule ? [ + 'interval' => $schedule['interval'] ?? null, + 'delay_days' => $schedule['delay_days'] ?? null, + 'weekly_anchor' => $schedule['weekly_anchor'] ?? null, + 'monthly_anchor' => $schedule['monthly_anchor'] ?? null, + ] : null, + 'payouts' => collect($payouts->data)->take(10)->map(fn ($payout) => [ + 'id' => $payout->id, + 'amount' => (int) $payout->amount, + 'currency' => strtoupper($payout->currency), + 'status' => $payout->status, + 'arrival_date' => $payout->arrival_date ? Carbon::createFromTimestamp($payout->arrival_date)->toIso8601String() : null, + 'created_at' => Carbon::createFromTimestamp($payout->created)->toIso8601String(), + ])->values()->all(), + ]); + } + + /** + * GET /v1/organizations/{slug}/payouts/transactions[?from=&to=&extension=&status=&page=] — + * sales of the organization's extensions, newest first, with totals per currency (refunded + * sales left out of the totals). + */ + public function transactions(Request $request, string $slug): JsonResponse + { + $organization = $this->billingOrganization($request, $slug); + $query = $this->sales($request, $organization); + + // Refunded sales are listed but not counted: their money went back to the buyer. + $totals = (clone $query)->reorder() + ->where('status', '!=', Purchase::STATUS_REFUNDED) + ->selectRaw('currency, count(*) as count, sum(amount_total) as gross, sum(fee_amount) as fee, sum(net_amount) as net') + ->groupBy('currency') + ->get() + ->map(fn ($row) => ['currency' => $row->currency, 'count' => (int) $row->count, 'gross' => (int) $row->gross, 'fee' => (int) $row->fee, 'net' => (int) $row->net]) + ->values(); + + $page = $query->with(['extension', 'buyer'])->paginate(min(100, max(1, (int) $request->input('per_page', self::PER_PAGE)))); + + return response()->json([ + 'data' => PayoutTransactionResource::collection($page->getCollection())->resolve($request), + 'totals' => $totals, + 'meta' => [ + 'current_page' => $page->currentPage(), + 'last_page' => $page->lastPage(), + 'per_page' => $page->perPage(), + 'total' => $page->total(), + ], + ]); + } + + /** + * GET /v1/organizations/{slug}/payouts/transactions.csv — the same sales (same filters) as CSV. + */ + public function export(Request $request, string $slug): StreamedResponse + { + $organization = $this->billingOrganization($request, $slug); + $query = $this->sales($request, $organization)->with(['extension', 'buyer']); + $name = "{$organization->slug}-transactions-".now()->format('Y-m-d').'.csv'; + + return response()->streamDownload(function () use ($query) { + $out = fopen('php://output', 'w'); + fputcsv($out, ['date', 'id', 'extension', 'customer', 'status', 'currency', 'gross', 'fee', 'net', 'tax', 'refunded']); + $query->chunk(500, function ($purchases) use ($out) { + foreach ($purchases as $purchase) { + fputcsv($out, [ + $purchase->created_at?->toIso8601String(), + $purchase->public_id, + $purchase->extension?->name, + $purchase->buyer?->name, + $purchase->status, + $purchase->currency, + static::decimal($purchase->amount_total), + static::decimal($purchase->fee_amount), + static::decimal($purchase->net_amount), + static::decimal($purchase->meta['amount_tax'] ?? 0), + static::decimal($purchase->meta['amount_refunded'] ?? 0), + ]); + } + }); + fclose($out); + }, $name, ['Content-Type' => 'text/csv; charset=UTF-8']); + } + + protected function billingOrganization(Request $request, string $slug): Organization + { + $organization = $this->findOrganization($request, $slug); + $this->assertRole($organization, self::BILLING_ROLES, 'Only organization owners, admins and billing members can see payouts.'); + + return $organization; + } + + protected function sales(Request $request, Organization $organization): Builder + { + $request->validate([ + 'from' => ['nullable', 'date'], + 'to' => ['nullable', 'date'], + 'status' => ['nullable', 'string', 'in:'.implode(',', [Purchase::STATUS_PAID, Purchase::STATUS_REFUNDED, Purchase::STATUS_DISPUTED])], + 'extension' => ['nullable', 'string', 'max:191'], + ]); + + return Purchase::query() + ->whereHas('extension', fn (Builder $q) => $q->withTrashed()->where('organization_uuid', $organization->uuid)) + ->when($request->input('from'), fn (Builder $q, $from) => $q->where('created_at', '>=', Carbon::parse($from)->startOfDay())) + ->when($request->input('to'), fn (Builder $q, $to) => $q->where('created_at', '<=', Carbon::parse($to)->endOfDay())) + ->when($request->input('status'), fn (Builder $q, $status) => $q->where('status', $status)) + ->when($request->input('extension'), fn (Builder $q, $extension) => $q->whereHas('extension', fn (Builder $e) => $e->where('slug', $extension)->orWhere('public_id', $extension))) + ->orderByDesc('created_at') + ->orderByDesc('id'); + } + + /** + * @param iterable $amounts + */ + protected function amounts(iterable $amounts): array + { + $out = []; + foreach ($amounts as $amount) { + $out[] = ['amount' => (int) $amount->amount, 'currency' => strtoupper($amount->currency)]; + } + + return $out; + } + + protected static function decimal(int|string|null $minor): string + { + return number_format(((int) $minor) / 100, 2, '.', ''); + } +} diff --git a/app/Http/Controllers/Organization/StatsController.php b/app/Http/Controllers/Organization/StatsController.php new file mode 100644 index 0000000..daa68de --- /dev/null +++ b/app/Http/Controllers/Organization/StatsController.php @@ -0,0 +1,64 @@ +statsOrganization($request, $slug); + $range = StatsRange::fromRequest($request); + + return response()->json([ + 'organization' => ['id' => $organization->public_id, 'slug' => $organization->slug, 'name' => $organization->name], + ] + $this->report($organization, $range)); + } + + /** + * GET /v1/organizations/{slug}/stats.csv?from=&to=&group= — the series as CSV. + */ + public function export(Request $request, string $slug): StreamedResponse + { + $organization = $this->statsOrganization($request, $slug); + $range = StatsRange::fromRequest($request); + + return StatsCsv::download($this->report($organization, $range), StatsCsv::filename($organization->slug, $range)); + } + + protected function statsOrganization(Request $request, string $slug): Organization + { + $organization = $this->findOrganization($request, $slug); + $this->assertRole($organization, self::ROLES, 'Only organization owners, admins, publishers and billing members can see analytics.'); + + return $organization; + } + + protected function report(Organization $organization, StatsRange $range): array + { + $extensions = Extension::withTrashed()->where('organization_uuid', $organization->uuid)->orderBy('name')->get(); + + return (new ExtensionStats($extensions))->cachedReport($range, perExtension: true); + } +} diff --git a/app/Http/Controllers/Organization/StripeConnectController.php b/app/Http/Controllers/Organization/StripeConnectController.php new file mode 100644 index 0000000..fad3085 --- /dev/null +++ b/app/Http/Controllers/Organization/StripeConnectController.php @@ -0,0 +1,75 @@ +findOrganization($request, $slug); + + if ($request->boolean('refresh') && $organization->hasStripeAccount() && in_array($organization->pivot?->role, self::MANAGING_ROLES, true)) { + $accounts->refresh($organization); + } + + return response()->json(ConnectAccounts::summary($organization)); + } + + /** + * POST /v1/organizations/{slug}/stripe/account — create the Express account (once). + */ + public function store(Request $request, ConnectAccounts $accounts, string $slug): JsonResponse + { + $organization = $this->findOrganization($request, $slug); + $this->assertRole($organization, self::MANAGING_ROLES, 'Only organization owners and admins can set up payouts.'); + $created = ! $organization->hasStripeAccount(); + + $accounts->create($organization, $this->account($request)); + + return response()->json(ConnectAccounts::summary($organization), $created ? 201 : 200); + } + + /** + * POST /v1/organizations/{slug}/stripe/account-session — a client secret for Connect.js + * (onboarding, account management, payouts, tax settings and the notification banner). + */ + public function session(Request $request, ConnectAccounts $accounts, string $slug): JsonResponse + { + $organization = $this->findOrganization($request, $slug); + $this->assertRole($organization, self::MANAGING_ROLES, 'Only organization owners and admins can set up payouts.'); + + if (! $organization->hasStripeAccount()) { + throw new UnprocessableEntityHttpException('Create the Stripe account first.'); + } + + $session = $accounts->session($organization); + + return response()->json([ + 'client_secret' => $session->client_secret, + 'expires_at' => $session->expires_at, + 'publishable_key' => config('services.stripe.key'), + 'components' => ConnectAccounts::COMPONENTS, + ]); + } +} 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..e6dc3ab --- /dev/null +++ b/app/Http/Controllers/Packages/NpmController.php @@ -0,0 +1,110 @@ +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()), + )); + } + + /** + * GET /npm/{name}/-/dist/{version} + * + * The release's prebuilt engine dist (`fleetbase-build-engine-release` output, as uploaded + * with `flb publish`), which the console installs as a runtime engine without a rebuild. + * Not part of the npm protocol: npm clients never ask for it. Gated like the tarball; + * catalog releases carry its size, sha256 and builtAgainst under `dist`. + */ + public function dist(Request $request, string $package, string $version): Response + { + $resolved = $this->resolvePackage($request, ExtensionPackage::KIND_NPM, $package); + + $release = $resolved->extension->releases() + ->whereIn('status', [Release::STATUS_PUBLISHED, Release::STATUS_YANKED]) + ->where('version', $version) + ->with('engineDist') + ->first(); + abort_if($release?->engineDist === null, 404, 'Dist not found.'); + + return $this->storage->download($release->engineDist, $request, $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..86f36bd --- /dev/null +++ b/app/Http/Controllers/Publisher/ExtensionController.php @@ -0,0 +1,326 @@ +with([...self::DETAIL, 'releases' => fn ($q) => self::withDecidedAt($q)]) + ->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) => self::withDecidedAt($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($request, $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($request, $bySlug, $organization, $packages); + + return (new PublisherExtensionResource($bySlug->load(self::DETAIL)))->response()->setStatusCode(200); + } + + $this->assertClaimable($request, $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); + } + + /** + * GET /v1/publisher/packages/availability?organization=&npm=&composer= — whether the + * organization can claim each name for a new listing, by the rules `store` applies: + * `{npm: {name, available, reason}, composer: {…}}` for the names given. + */ + public function availability(Request $request): JsonResponse + { + $request->merge(array_map(PackageNames::normalize(...), $request->only(ExtensionPackage::KINDS))); + $request->validate([ + 'organization' => ['nullable', 'string', 'max:191'], + 'npm' => ['nullable', 'required_without:composer', 'string', 'max:1000'], + 'composer' => ['nullable', 'required_without:npm', 'string', 'max:1000'], + ]); + + $organization = $this->targetOrganization($request, $request->query('organization')); + $publishable = $this->publishableOrganizations($request); + $result = []; + + foreach (ExtensionPackage::KINDS as $kind) { + $name = $request->input($kind); + if (! is_string($name) || $name === '') { + continue; + } + + $reason = $this->names->problem($organization, $kind, $name, $publishable); + $result[$kind] = ['name' => $name, 'available' => $reason === null, 'reason' => $reason]; + } + + return response()->json($result); + } + + /** + * 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)); + } + + /** + * PUT /v1/publisher/extensions/{slug}/price — `{model, amount, currency}`. Paid pricing + * needs the organization's Stripe Connect account. The price takes effect at once; under + * D6 a price that differs from the last release's snapshot sends the next release to + * human review. + */ + public function price(UpdatePriceRequest $request, PriceCatalog $catalog, string $slug): PublisherExtensionResource + { + $extension = $this->findExtension($request, $slug, ['organization', 'price']); + $model = $request->input('model'); + + if ($model !== Price::MODEL_FREE && ! $extension->organization?->hasStripeAccount()) { + throw new UnprocessableEntityHttpException("Set up payouts for {$extension->organization?->name} before charging for an extension."); + } + + $attributes = $model === Price::MODEL_FREE + ? ['model' => Price::MODEL_FREE, 'amount' => 0, 'currency' => $request->input('currency') ?? $extension->price?->currency ?? 'USD', 'on_sale' => false, 'sale_amount' => null] + : [ + 'model' => $model, + 'amount' => (int) $request->input('amount'), + 'currency' => $request->input('currency'), + 'sale_amount' => $request->filled('sale_amount') ? (int) $request->input('sale_amount') : null, + 'on_sale' => $request->filled('sale_amount') && $request->boolean('on_sale'), + ]; + + $before = $extension->price ? (new PriceResource($extension->price))->resolve($request) : null; + $price = $extension->price ?? $extension->price()->make(); + $price->fill($attributes); + + // Mirror to Stripe first: a price Stripe refused is not saved. + $sync = $model === Price::MODEL_FREE + ? $price->stripe_price_id || $price->stripe_sale_price_id + : $price->isDirty() || ! $price->stripe_price_id; + if ($sync) { + $catalog->sync($price, $extension); + } + $price->save(); + + Event::record('extension.price_changed', $extension, $this->account($request), ['from' => $before, 'to' => $attributes]); + + if ($extension->status === Extension::STATUS_PUBLISHED) { + CatalogCache::flush(); + } + + return new PublisherExtensionResource($extension->fresh(self::DETAIL)); + } + + /** + * Each release's `decided_at`: when it was last approved or rejected (one subquery, so + * the release summaries need not load every review row). + */ + public static function withDecidedAt($query) + { + return $query->withMax(['reviews as decided_at' => fn ($q) => $q->whereIn('action', [Review::ACTION_APPROVE, Review::ACTION_REJECT])], 'created_at'); + } + + /** + * 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(Request $request, 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($request, $organization, $new); + foreach ($new as $kind => $name) { + $extension->packages()->create(['kind' => $kind, 'name' => $name]); + } + } + + /** + * Refuse names the organization may not claim, with the reasons the availability check + * gives (`PackageNames::problem`). + */ + protected function assertClaimable(Request $request, Organization $organization, array $packages): void + { + $publishable = $this->publishableOrganizations($request); + $errors = []; + foreach ($packages as $kind => $name) { + if ($problem = $this->names->problem($organization, $kind, $name, $publishable)) { + $errors['packages.'.$kind] = [$problem]; + } + } + + 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/ListingMediaController.php b/app/Http/Controllers/Publisher/ListingMediaController.php new file mode 100644 index 0000000..707b1ce --- /dev/null +++ b/app/Http/Controllers/Publisher/ListingMediaController.php @@ -0,0 +1,175 @@ +findExtension($request, $slug, ['icon']); + $request->validate(['icon' => ['required', 'file', 'mimes:'.self::IMAGE_MIMES, 'max:'.self::ICON_MAX_KB]]); + $upload = $this->image($request->file('icon'), 'icon'); + + $previous = $extension->icon; + $file = $this->storage->store($upload->getRealPath(), File::TYPE_ICON, $extension, [ + 'name' => 'icon.'.$upload->guessExtension(), + 'original_filename' => $upload->getClientOriginalName(), + 'uploader_account_uuid' => $this->account($request)->uuid, + 'mime' => $upload->getMimeType(), + ]); + + $extension->forceFill(['icon_file_uuid' => $file->uuid])->save(); + if ($previous !== null) { + $this->storage->delete($previous); + } + + return $this->changed($request, $extension, 'extension.icon_updated', ['file' => $file->public_id]); + } + + /** + * DELETE /v1/publisher/extensions/{slug}/icon + */ + public function destroyIcon(Request $request, string $slug): PublisherExtensionResource + { + $extension = $this->findExtension($request, $slug, ['icon']); + $icon = $extension->icon; + + if ($icon !== null) { + $extension->forceFill(['icon_file_uuid' => null])->save(); + $this->storage->delete($icon); + } + + return $this->changed($request, $extension, 'extension.icon_removed', ['file' => $icon?->public_id]); + } + + /** + * POST /v1/publisher/extensions/{slug}/screenshots — multipart `screenshot` (PNG, JPEG or + * WebP, 5 MB) and optional `caption`. Added last; at most 8 per listing. + */ + public function storeScreenshot(Request $request, string $slug): JsonResponse + { + $extension = $this->findExtension($request, $slug, ['screenshots']); + $request->validate([ + 'screenshot' => ['required', 'file', 'mimes:'.self::IMAGE_MIMES, 'max:'.self::SCREENSHOT_MAX_KB], + 'caption' => ['nullable', 'string', 'max:255'], + ]); + $upload = $this->image($request->file('screenshot'), 'screenshot'); + + if ($extension->screenshots->count() >= Extension::MAX_SCREENSHOTS) { + throw ValidationException::withMessages(['screenshot' => 'A listing can have at most '.Extension::MAX_SCREENSHOTS.' screenshots. Delete one first.']); + } + + $order = $extension->screenshots->max(fn (File $file) => $file->meta['order'] ?? -1); + $order = max((int) ($order ?? -1), $extension->screenshots->count() - 1) + 1; + + $file = $this->storage->store($upload->getRealPath(), File::TYPE_SCREENSHOT, $extension, [ + 'name' => 'screenshot.'.$upload->guessExtension(), + 'original_filename' => $upload->getClientOriginalName(), + 'uploader_account_uuid' => $this->account($request)->uuid, + 'mime' => $upload->getMimeType(), + 'meta' => array_filter(['caption' => $request->input('caption'), 'order' => $order], fn ($value) => $value !== null), + ]); + + return $this->changed($request, $extension, 'extension.screenshot_added', ['file' => $file->public_id])->response()->setStatusCode(201); + } + + /** + * PATCH /v1/publisher/extensions/{slug}/screenshots — `order`: every screenshot id of the + * listing, once, in display order. + */ + public function reorderScreenshots(Request $request, string $slug): PublisherExtensionResource + { + $extension = $this->findExtension($request, $slug, ['screenshots']); + $request->validate([ + 'order' => ['required', 'array', 'max:'.Extension::MAX_SCREENSHOTS], + 'order.*' => ['required', 'string', 'distinct'], + ]); + + $ids = array_values($request->input('order')); + $files = $extension->screenshots->keyBy('public_id'); + if (count($ids) !== $files->count() || array_diff($ids, $files->keys()->all()) !== []) { + throw ValidationException::withMessages(['order' => 'The order must list every screenshot of the listing exactly once.']); + } + + foreach ($ids as $position => $id) { + $file = $files->get($id); + $file->forceFill(['meta' => array_merge($file->meta ?? [], ['order' => $position])])->save(); + } + + return $this->changed($request, $extension, 'extension.screenshots_reordered', ['order' => $ids]); + } + + /** + * DELETE /v1/publisher/extensions/{slug}/screenshots/{file} + */ + public function destroyScreenshot(Request $request, string $slug, string $file): PublisherExtensionResource + { + $extension = $this->findExtension($request, $slug, ['screenshots']); + $screenshot = $extension->screenshots->first(fn (File $screenshot) => $screenshot->public_id === $file || $screenshot->uuid === $file); + + if ($screenshot === null) { + throw new NotFoundHttpException('Screenshot not found.'); + } + + $this->storage->delete($screenshot); + + return $this->changed($request, $extension, 'extension.screenshot_removed', ['file' => $screenshot->public_id]); + } + + /** + * The upload, once its bytes decode as an image (the `mimes` rule only sniffs the type). + */ + protected function image(UploadedFile $upload, string $field): UploadedFile + { + $size = @getimagesize($upload->getRealPath()); + if ($size === false || ! in_array($size[2], [IMAGETYPE_PNG, IMAGETYPE_JPEG, IMAGETYPE_WEBP], true)) { + throw ValidationException::withMessages([$field => 'The '.$field.' must be a PNG, JPEG or WebP image.']); + } + + return $upload; + } + + /** + * Record the change, flush the catalog for published listings and return the listing. + */ + protected function changed(Request $request, Extension $extension, string $event, array $payload): PublisherExtensionResource + { + Event::record($event, $extension, $this->account($request), $payload, $request->ip()); + + if ($extension->status === Extension::STATUS_PUBLISHED) { + CatalogCache::flush(); + } + + return new PublisherExtensionResource($extension->fresh(ExtensionController::DETAIL)); + } +} diff --git a/app/Http/Controllers/Publisher/PublisherController.php b/app/Http/Controllers/Publisher/PublisherController.php new file mode 100644 index 0000000..b774004 --- /dev/null +++ b/app/Http/Controllers/Publisher/PublisherController.php @@ -0,0 +1,120 @@ +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); + } + + /** The listing relations the release detail resource shows (the staff listing preview). */ + public const RELEASE_LISTING = ['extension.organization', 'extension.category', 'extension.price', 'extension.icon', 'extension.screenshots']; + + /** + * Relations the release detail resource reads. + */ + public static function releaseDetail(Release $release): Release + { + return $release->load(array_merge(self::RELEASE_LISTING, ['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..aeee293 --- /dev/null +++ b/app/Http/Controllers/Publisher/ReleaseController.php @@ -0,0 +1,127 @@ +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))); + } + + /** + * PATCH /v1/publisher/releases/{id} — `{notes}`: edit the release notes in any status but + * yanked. Like listing copy, notes never need review; a published release's notes show + * in the catalog at once. + */ + public function update(Request $request, string $release): ReleaseDetailResource + { + $request->validate(['notes' => ['present', ...UploadReleaseRequest::NOTES]]); + $release = $this->findRelease($request, $release); + + if ($release->status === Release::STATUS_YANKED) { + throw new UnprocessableEntityHttpException('A yanked release\'s notes cannot be changed.'); + } + + $release->forceFill(['notes' => $request->input('notes')])->save(); + Event::record('release.notes_updated', $release, $this->account($request), ['version' => $release->version]); + + if ($release->status === Release::STATUS_PUBLISHED) { + CatalogCache::flush(); + } + + return new ReleaseDetailResource(self::releaseDetail($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/Controllers/Publisher/StatsController.php b/app/Http/Controllers/Publisher/StatsController.php new file mode 100644 index 0000000..aa08ddd --- /dev/null +++ b/app/Http/Controllers/Publisher/StatsController.php @@ -0,0 +1,45 @@ +findExtension($request, $slug); + $range = StatsRange::fromRequest($request); + + return response()->json(['extension' => ExtensionStats::extensionReference($extension)] + $this->report($extension, $range)); + } + + /** + * GET /v1/publisher/extensions/{slug}/stats.csv?from=&to=&group= — the series as CSV. + */ + public function export(Request $request, string $slug): StreamedResponse + { + $extension = $this->findExtension($request, $slug); + $range = StatsRange::fromRequest($request); + + return StatsCsv::download($this->report($extension, $range), StatsCsv::filename($extension->slug, $range)); + } + + protected function report(Extension $extension, StatsRange $range): array + { + return (new ExtensionStats(collect([$extension])))->cachedReport($range, versions: true); + } +} diff --git a/app/Http/Controllers/Purchase/CheckoutController.php b/app/Http/Controllers/Purchase/CheckoutController.php new file mode 100644 index 0000000..87e0568 --- /dev/null +++ b/app/Http/Controllers/Purchase/CheckoutController.php @@ -0,0 +1,78 @@ +account($request); + $identifier = $request->input('organization') ?? $account->currentToken()?->organization_uuid ?? $account->personalOrganization()?->slug; + if ($identifier === null) { + throw new UnprocessableEntityHttpException('Choose the organization that is buying.'); + } + + $organization = $this->findOrganization($request, $identifier); + $this->assertRole($organization, self::BILLING_ROLES, 'Only organization owners, admins and billing members can buy extensions.'); + + $extension = Extension::findByIdentifier($request->input('extension')); + if (! $extension) { + return response()->error('Extension not found.', 404); + } + + $site = rtrim((string) (config('registry.public_url') ?: $request->getSchemeAndHttpHost()), '/'); + $checkout = $sessions->start( + $extension, + $organization, + Checkout::SOURCE_WEBSITE, + "{$site}/account/billing/{$organization->slug}", + "{$site}/extensions/{$extension->slug}", + ['account' => $account, 'email' => $account->email], + ); + + Event::record('checkout.started', $checkout, $account, ['extension' => $extension->public_id, 'source' => Checkout::SOURCE_WEBSITE], $request->ip()); + + return (new CheckoutResource($checkout->load(['extension', 'organization'])))->response()->setStatusCode(201); + } + + /** + * GET /v1/purchases/{handoff} — a checkout of one of the caller's organizations. + */ + public function show(Request $request, string $handoff): CheckoutResource + { + $checkout = Checkout::query() + ->where('public_id', $handoff) + ->with(['extension', 'organization', 'purchase.extension', 'purchase.licence']) + ->first(); + + if (! $checkout || ! $checkout->organization) { + throw new NotFoundHttpException('Checkout not found.'); + } + $this->findOrganization($request, $checkout->organization->uuid); + + return new CheckoutResource($checkout); + } +} diff --git a/app/Http/Controllers/Web/AccountPageController.php b/app/Http/Controllers/Web/AccountPageController.php new file mode 100644 index 0000000..f626103 --- /dev/null +++ b/app/Http/Controllers/Web/AccountPageController.php @@ -0,0 +1,119 @@ +check()) { + return redirect()->route('studio'); + } + + return Inertia::render('Auth/SignIn', [ + 'email' => $request->query('email'), + 'intended' => $request->session()->get('url.intended'), + // Set by the verify page, which sends a just-verified account here. + 'verified' => $request->boolean('verified'), + // Set by the forgot-password page after a reset. + 'reset' => $request->boolean('reset'), + ]); + } + + public function register(): Response + { + return Inertia::render('Auth/Register'); + } + + public function verify(Request $request): Response + { + return Inertia::render('Auth/Verify', [ + 'email' => $request->query('email'), + 'sent' => $request->boolean('sent'), + ]); + } + + /** + * GET /forgot-password (named `password.request`): the email, then the emailed code and + * a new password. The reset email links here with `email` and `sent`. + */ + public function forgotPassword(Request $request): Response + { + return Inertia::render('Auth/ForgotPassword', [ + 'email' => $request->query('email'), + 'sent' => $request->boolean('sent'), + ]); + } + + public function profile(): Response + { + return Inertia::render('Account/Profile'); + } + + /** + * GET /account/organizations[/{slug}] — the list, plus one organization's profile and + * members (the selected one, else the first that is not personal, else the first). + */ + public function organizations(Request $request, ?string $slug = null): Response + { + $organizations = $this->api($request, '/v1/organizations', [], OrganizationController::class, 'index'); + + $selected = $slug ?? (collect($organizations)->firstWhere('is_personal', false)['slug'] ?? ($organizations[0]['slug'] ?? null)); + + return Inertia::render('Account/Organizations', [ + 'organizations' => $organizations, + 'organization' => $selected + ? $this->api($request, "/v1/organizations/{$selected}", [], OrganizationController::class, 'show', ['slug' => $selected]) + : null, + ]); + } + + /** + * GET /account/billing[/{slug}][?handoff=] — what the organization bought, its licences with + * their instances, and receipts. Stripe returns website buyers here with the hand-off, which + * the page polls until the purchase lands. + */ + public function billing(Request $request, ?string $slug = null): Response + { + [$organizations, $organization] = $this->billingOrganizations($request, $slug, null); + $billing = $organization && in_array($organization['role'] ?? null, OrganizationController::BILLING_ROLES, true); + $checkout = null; + + if ($organization && $request->filled('handoff')) { + [$checkout] = $this->tryApi($request, '/v1/purchases/'.$request->query('handoff'), [], CheckoutController::class, 'show', ['handoff' => (string) $request->query('handoff')]); + } + + return Inertia::render('Account/Billing', [ + 'organizations' => $organizations, + 'organization' => $organization, + // Purchases and receipts are for owners, admins and billing members; licences for everyone. + 'purchases' => $billing ? $this->api($request, "/v1/organizations/{$organization['slug']}/billing/purchases", [], BillingController::class, 'purchases', ['slug' => $organization['slug']]) : null, + 'licences' => $organization ? $this->api($request, "/v1/organizations/{$organization['slug']}/billing/licences", [], BillingController::class, 'licences', ['slug' => $organization['slug']]) : [], + 'checkout' => $checkout, + ]); + } + + public function tokens(Request $request): Response + { + return Inertia::render('Account/Tokens', [ + 'tokens' => $this->api($request, '/v1/account/tokens', [], TokenController::class, 'index'), + ]); + } +} diff --git a/app/Http/Controllers/Web/CatalogPageController.php b/app/Http/Controllers/Web/CatalogPageController.php new file mode 100644 index 0000000..d5ee844 --- /dev/null +++ b/app/Http/Controllers/Web/CatalogPageController.php @@ -0,0 +1,122 @@ +only($request, self::FILTERS); + $console = $this->consoleVersion($request); + $query = array_diff_key($filters, ['compat' => true]) + ['limit' => 24]; + if ($console) { + $query += ['console' => $console] + (($filters['compat'] ?? null) === 'any' ? [] : ['compatible' => 1]); + } + $results = $this->api($request, '/v1/catalog/extensions', $query, CatalogController::class, 'extensions'); + + // Each card says whether a release of it works with the visitor's console. + $results['data'] = array_map(fn (array $item) => $item + [ + 'compatible' => isset($item['compatibility']) ? $item['compatibility']['newest_compatible'] !== null : null, + ], $results['data'] ?? []); + + return Inertia::render('Discover/Index', [ + 'filters' => $filters, + 'results' => $results, + 'categories' => $this->api($request, '/v1/catalog/categories', [], CatalogController::class, 'categories'), + ]); + } + + /** + * GET /extensions/{slug} — overview, screenshots, versions, install. + */ + public function extension(Request $request, string $slug): Response + { + $extension = $this->api($request, "/v1/catalog/extensions/{$slug}", [], CatalogController::class, 'extension', ['slug' => $slug]); + $releases = $this->api($request, "/v1/catalog/extensions/{$slug}/releases", [], CatalogController::class, 'releases', ['slug' => $slug]); + + return Inertia::render('Extension/Show', [ + 'extension' => $extension, + 'releases' => $releases['releases'] ?? [], + 'compatibility' => fn () => $this->compatibility($releases['releases'] ?? [], $request->query('check') ?: $this->consoleVersion($request)), + ]); + } + + /** + * GET /publishers/{slug} + */ + public function publisher(Request $request, string $slug): Response + { + return Inertia::render('Publisher/Show', [ + 'publisher' => $this->api($request, "/v1/catalog/publishers/{$slug}", [], CatalogController::class, 'publisher', ['slug' => $slug]), + ]); + } + + /** + * Whether the latest release works with a console version, and if not, the newest release + * that does. Null when no version is known. `?check=` asks for another version (a partial + * reload of this prop). + * + * @param list> $releases newest first + */ + protected function compatibility(array $releases, ?string $version): ?array + { + if (! $version || ! Semver::valid($version)) { + return $version ? ['version' => $version, 'valid' => false] : null; + } + + $published = array_values(array_filter($releases, fn ($release) => ($release['status'] ?? null) === 'published')); + $latest = $published[0] ?? null; + $newest = null; + foreach ($published as $release) { + if (Compat::satisfies($version, $release['compat']['console'] ?? null)) { + $newest = $release['version']; + break; + } + } + + return [ + 'version' => $version, + 'valid' => true, + 'compatible' => $latest !== null && Compat::satisfies($version, $latest['compat']['console'] ?? null), + 'newest_compatible' => $newest, + ]; + } + + protected function consoleVersion(Request $request): ?string + { + $version = $request->query('console') ?: $request->cookie(HandleInertiaRequests::CONSOLE_COOKIE); + + return is_string($version) && Semver::valid($version) ? $version : null; + } + + /** + * POST /console — remember the console version the visitor checks compatibility against. + */ + public function console(Request $request) + { + $request->validate(['version' => ['nullable', 'string', 'max:32', 'regex:/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/']]); + + $cookie = $request->filled('version') + ? cookie()->forever(HandleInertiaRequests::CONSOLE_COOKIE, $request->input('version'), httpOnly: false) + : cookie()->forget(HandleInertiaRequests::CONSOLE_COOKIE); + + return back()->withCookie($cookie); + } +} diff --git a/app/Http/Controllers/Web/ReviewPageController.php b/app/Http/Controllers/Web/ReviewPageController.php new file mode 100644 index 0000000..ea298bf --- /dev/null +++ b/app/Http/Controllers/Web/ReviewPageController.php @@ -0,0 +1,211 @@ + [Release::STATUS_SUBMITTED, Release::STATUS_IN_REVIEW], + 'decided' => [Release::STATUS_REJECTED, Release::STATUS_PUBLISHED], + 'failed' => [Release::STATUS_FAILED_CHECKS], + ]; + + /** + * GET /review?tab=waiting|decided|failed — oldest first. `claimers` names the reviewer who + * took each in-review release, keyed by release id (the queue API leaves the timeline out). + */ + public function queue(Request $request): Response + { + $this->authorizeStaff($request); + $tab = array_key_exists((string) $request->query('tab'), self::TABS) ? $request->query('tab') : 'waiting'; + $releases = $this->api($request, '/v1/admin/releases', ['status' => implode(',', self::TABS[$tab]), 'limit' => 100], ReviewController::class, 'queue'); + + return Inertia::render('Review/Queue', [ + 'tab' => $tab, + 'releases' => $releases, + 'claimers' => $this->latestReviewers($releases['data'] ?? [], Release::STATUS_IN_REVIEW, Review::ACTION_CLAIM), + 'counts' => $this->counts(), + ]); + } + + /** + * GET /review/publish — approved releases waiting to go live (with who approved them), and + * suspended listings (with the reason and who suspended them). + */ + public function pending(Request $request): Response + { + $this->authorizeStaff($request); + $releases = $this->api($request, '/v1/admin/releases', ['status' => Release::STATUS_APPROVED, 'limit' => 100], ReviewController::class, 'queue'); + + $suspended = Extension::query() + ->where('status', Extension::STATUS_SUSPENDED) + ->with(['organization', 'icon']) + ->orderByDesc('updated_at') + ->limit(50) + ->get(); + $suspenders = Account::query() + ->whereIn('public_id', $suspended->pluck('meta.suspension.by')->filter()->unique()->values()) + ->pluck('name', 'public_id'); + + return Inertia::render('Review/Pending', [ + 'releases' => $releases, + 'approvers' => $this->latestReviewers($releases['data'] ?? [], Release::STATUS_APPROVED, Review::ACTION_APPROVE), + 'suspended' => $suspended->map(fn (Extension $extension) => [ + 'id' => $extension->public_id, + 'slug' => $extension->slug, + 'name' => $extension->name, + 'icon_url' => $extension->icon?->url, + 'organization' => $extension->organization?->only(['slug', 'name']), + 'suspension' => $this->suspension($extension, $suspenders->all()), + ])->all(), + 'counts' => $this->counts(), + ]); + } + + /** + * GET /review/releases/{release} — the release with its listing preview, the review + * requirements, and the listing's suspension when it is suspended. + */ + public function release(Request $request, string $release): Response + { + $this->authorizeStaff($request); + $model = Release::findByIdOrFail($release); + $extension = $model->extension; + $suspension = $extension?->status === Extension::STATUS_SUSPENDED + ? $this->suspension($extension, Account::query()->where('public_id', $extension->meta['suspension']['by'] ?? null)->pluck('name', 'public_id')->all()) + : null; + + return Inertia::render('Review/Release', [ + 'release' => $this->api($request, "/v1/admin/releases/{$release}", [], ReviewController::class, 'show', ['release' => $release]), + 'reviewItems' => $this->api($request, '/v1/admin/review-items', [], ReviewController::class, 'reviewItems'), + 'suspension' => $suspension, + ]); + } + + /** + * GET /review/finance[?from=&to=&status=&publisher=&page=] — fee revenue, refunds, disputes + * and per-publisher totals over the range, and the purchases in it with a refund action. + */ + public function finance(Request $request): Response + { + $this->authorizeStaff($request); + $range = $this->range($request); + $filters = $range + $this->only($request, ['status', 'publisher']); + $status = in_array($filters['status'] ?? null, [Purchase::STATUS_PAID, Purchase::STATUS_REFUNDED, Purchase::STATUS_DISPUTED], true) ? $filters['status'] : null; + $filters = array_filter(['status' => $status] + $filters); + + return Inertia::render('Review/Finance', [ + 'summary' => $this->api($request, '/v1/admin/finance', $range, FinanceController::class, 'summary'), + 'purchases' => $this->api($request, '/v1/admin/finance/purchases', $filters + $this->only($request, ['page']), FinanceController::class, 'purchases'), + 'filters' => $filters, + 'stripe' => ['configured' => Stripe::configured()], + 'counts' => $this->counts(), + ]); + } + + /** + * `from` and `to` from the query when they are dates in order; anything else falls back to + * the report's default range. + * + * @return array{from?: string, to?: string} + */ + protected function range(Request $request): array + { + $dates = []; + foreach (['from', 'to'] as $key) { + $value = $request->query($key); + if (is_string($value) && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $value, $parts) && checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1])) { + $dates[$key] = $value; + } + } + + return isset($dates['from'], $dates['to']) && $dates['to'] < $dates['from'] ? [] : $dates; + } + + protected function authorizeStaff(Request $request): void + { + $account = $request->user(); + + abort_unless($account instanceof Account && $account->isStaff(), 403, 'The review console is for Fleetbase staff.'); + } + + /** + * The staff member behind the latest `$action` of each listed release in `$status`, keyed + * by release id: `{id, name, at}`. + * + * @param list> $releases ReleaseDetailResource arrays + * @return array + */ + protected function latestReviewers(array $releases, string $status, string $action): array + { + $ids = collect($releases)->where('status', $status)->pluck('id')->filter()->values(); + if ($ids->isEmpty()) { + return []; + } + + return Release::query() + ->whereIn('public_id', $ids) + ->with(['reviews' => fn ($query) => $query->where('action', $action)->with('reviewer')]) + ->get() + ->mapWithKeys(function (Release $release) { + $review = $release->reviews->sortBy('id')->last(); + + return [$release->public_id => [ + 'id' => $review?->reviewer?->public_id, + 'name' => $review?->reviewer?->name, + 'at' => $review?->created_at?->toIso8601String(), + ]]; + }) + ->filter(fn (array $reviewer) => $reviewer['name'] !== null) + ->all(); + } + + /** + * A listing's suspension (`reason`, `at`, `by`) with the suspending account's name. + * + * @param array $names account public id => name + */ + protected function suspension(Extension $extension, array $names): ?array + { + $suspension = $extension->meta['suspension'] ?? null; + if (! is_array($suspension)) { + return null; + } + + return [ + 'reason' => $suspension['reason'] ?? null, + 'at' => $suspension['at'] ?? null, + 'by' => $suspension['by'] ?? null, + 'by_name' => $names[$suspension['by'] ?? ''] ?? null, + ]; + } + + /** + * @return array + */ + protected function counts(): array + { + return [ + 'waiting' => Release::query()->whereIn('status', self::TABS['waiting'])->count(), + 'publish' => Release::query()->where('status', Release::STATUS_APPROVED)->count(), + 'suspended' => Extension::query()->where('status', Extension::STATUS_SUSPENDED)->count(), + ]; + } +} diff --git a/app/Http/Controllers/Web/StudioPageController.php b/app/Http/Controllers/Web/StudioPageController.php new file mode 100644 index 0000000..3932777 --- /dev/null +++ b/app/Http/Controllers/Web/StudioPageController.php @@ -0,0 +1,205 @@ + $this->api($request, '/v1/publisher/extensions', [], ExtensionController::class, 'index'), + 'organization' => $request->query('org'), + ]); + } + + public function create(Request $request): Response + { + return Inertia::render('Studio/Create', [ + 'categories' => $this->categories($request), + ]); + } + + /** + * GET /studio/{slug} — the listing editor. + */ + public function editor(Request $request, string $slug): Response + { + return Inertia::render('Studio/Editor', $this->listing($request, $slug) + [ + 'categories' => $this->categories($request), + ]); + } + + /** + * GET /studio/{slug}/releases — upload and the release list. + */ + public function releases(Request $request, string $slug): Response + { + return Inertia::render('Studio/Releases', $this->listing($request, $slug)); + } + + /** + * GET /studio/{slug}/releases/{release} — one release with its checks; the page polls this + * (a partial reload of `release`) while the checks run. + */ + public function release(Request $request, string $slug, string $release): Response + { + $extension = $this->api($request, "/v1/publisher/extensions/{$slug}", [], ExtensionController::class, 'show', ['slug' => $slug]); + + return Inertia::render('Studio/Release', [ + 'extension' => $extension, + 'release' => function () use ($request, $release, $extension) { + $detail = $this->api($request, "/v1/publisher/releases/{$release}", [], ReleaseController::class, 'show', ['release' => $release]); + // A release of another of the publisher's listings is not found under this one. + abort_if(($detail['extension']['id'] ?? null) !== $extension['id'], 404); + + return $detail; + }, + ]); + } + + /** + * GET /studio/{slug}/review — submit the open release, or follow its review. + */ + public function review(Request $request, string $slug): Response + { + $listing = $this->listing($request, $slug); + $open = collect($listing['releases'])->first(fn (array $release) => in_array($release['status'], self::OPEN, true)); + + return Inertia::render('Studio/Review', $listing + [ + 'candidate' => $open + ? $this->api($request, "/v1/publisher/releases/{$open['id']}", [], ReleaseController::class, 'show', ['release' => $open['id']]) + : null, + ]); + } + + /** + * GET /studio/{slug}/history — every release's review timeline, newest first. + */ + public function history(Request $request, string $slug): Response + { + $listing = $this->listing($request, $slug); + $details = collect($listing['releases'])->take(20)->map( + fn (array $release) => $this->api($request, "/v1/publisher/releases/{$release['id']}", [], ReleaseController::class, 'show', ['release' => $release['id']]) + )->values()->all(); + + return Inertia::render('Studio/History', [ + 'extension' => $listing['extension'], + 'releases' => $details, + 'truncated' => count($listing['releases']) > 20, + ]); + } + + /** + * GET /studio/insights[/{organization}][?extension=&from=&to=&group=] — analytics for one of the + * organization's listings, or all of them together, over a date range. + */ + public function insights(Request $request, ?string $slug = null): Response + { + [$organizations, $organization] = $this->billingOrganizations($request, $slug, Membership::PUBLISHING_ROLES); + $filters = $this->only($request, ['from', 'to', 'group']); + $extensions = []; + $selected = $request->query('extension'); + $stats = $error = null; + + if ($organization) { + $extensions = collect($this->api($request, '/v1/publisher/extensions', [], ExtensionController::class, 'index')) + ->filter(fn (array $extension) => ($extension['organization']['slug'] ?? null) === $organization['slug']) + ->map(fn (array $extension) => array_intersect_key($extension, array_flip(['id', 'slug', 'name', 'status', 'icon_url', 'fa_icon']))) + ->values() + ->all(); + + abort_if($selected !== null && ! collect($extensions)->contains('slug', $selected), 404); + + try { + $stats = $selected !== null + ? $this->api($request, "/v1/publisher/extensions/{$selected}/stats", $filters, StatsController::class, 'show', ['slug' => $selected]) + : $this->api($request, "/v1/organizations/{$organization['slug']}/stats", $filters, OrganizationStatsController::class, 'show', ['slug' => $organization['slug']]); + } catch (ValidationException $e) { + $error = collect($e->errors())->flatten()->first(); + } + } + + return Inertia::render('Studio/Insights', [ + 'organizations' => $organizations, + 'organization' => $organization, + 'extensions' => $extensions, + 'extension' => $selected, + 'stats' => $stats, + 'filters' => $filters, + 'error' => $error, + ]); + } + + /** + * GET /studio/payouts[/{slug}] — an organization's Stripe Connect account (onboarding and + * management through Connect.js), balance, payout schedule and sales. + */ + public function payouts(Request $request, ?string $slug = null): Response + { + [$organizations, $organization] = $this->billingOrganizations($request, $slug); + $payouts = $error = $transactions = null; + + if ($organization) { + $slug = $organization['slug']; + [$payouts, $error] = $this->tryApi($request, "/v1/organizations/{$slug}/payouts", [], PayoutController::class, 'summary', ['slug' => $slug]); + $payouts ??= ['connect' => $this->api($request, "/v1/organizations/{$slug}/stripe", [], StripeConnectController::class, 'show', ['slug' => $slug])]; + $transactions = $this->api($request, "/v1/organizations/{$slug}/payouts/transactions", $this->only($request, ['page', 'from', 'to', 'status']), PayoutController::class, 'transactions', ['slug' => $slug]); + } + + return Inertia::render('Studio/Payouts', [ + 'organizations' => $organizations, + 'organization' => $organization, + 'payouts' => $payouts, + 'transactions' => $transactions, + 'filters' => $this->only($request, ['from', 'to', 'status']), + 'error' => $error, + 'stripe' => ['configured' => Stripe::configured(), 'publishable_key' => config('services.stripe.key')], + ]); + } + + /** + * The listing (as its publisher sees it) and every release with its checks, newest first. + * + * @return array{extension: array, releases: list} + */ + protected function listing(Request $request, string $slug): array + { + return [ + 'extension' => $this->api($request, "/v1/publisher/extensions/{$slug}", [], ExtensionController::class, 'show', ['slug' => $slug]), + 'releases' => $this->api($request, "/v1/publisher/extensions/{$slug}/releases", [], ReleaseController::class, 'index', ['slug' => $slug]), + ]; + } + + protected function categories(Request $request): array + { + return $this->api($request, '/v1/catalog/categories', [], CatalogController::class, 'categories'); + } +} diff --git a/app/Http/Controllers/Web/WebController.php b/app/Http/Controllers/Web/WebController.php new file mode 100644 index 0000000..f291cfc --- /dev/null +++ b/app/Http/Controllers/Web/WebController.php @@ -0,0 +1,110 @@ + $parameters route parameters (slug, release…) + */ + protected function api(Request $request, string $uri, array $query, string $controller, string $method, array $parameters = []): array + { + $sub = Request::create($uri, 'GET', $query); + $sub->setUserResolver($request->getUserResolver()); + if ($request->hasSession()) { + $sub->setLaravelSession($request->session()); + } + + $previous = app('request'); + app()->instance('request', $sub); + + try { + $result = app()->call([app($controller), $method], ['request' => $sub] + $parameters); + + if ($result instanceof JsonResource) { + return $result->response($sub)->getData(true); + } + + if ($result instanceof JsonResponse) { + return $result->getData(true); + } + + return (array) $result; + } finally { + app()->instance('request', $previous); + } + } + + /** + * The caller's organizations with one of `$roles` (default: those who see money: owners, + * admins, billing members; null: every role), and the selected one: `$slug`, else the first that is not personal, else the first. + * + * @return array{0: list, 1: ?array} + */ + protected function billingOrganizations(Request $request, ?string $slug, ?array $roles = OrganizationController::BILLING_ROLES): array + { + $organizations = collect($this->api($request, '/v1/organizations', [], OrganizationController::class, 'index')) + ->filter(fn (array $organization) => $roles === null || in_array($organization['role'] ?? null, $roles, true)) + ->values(); + + $selected = $slug !== null + ? $organizations->firstWhere('slug', $slug) + : ($organizations->firstWhere('is_personal', false) ?? $organizations->first()); + + if ($slug !== null && $selected === null) { + abort(404); + } + + return [$organizations->all(), $selected]; + } + + /** + * Like api(), but a failure (Stripe not configured or unreachable) answers null and the + * reason instead of an error page. + * + * @return array{0: ?array, 1: ?string} + */ + protected function tryApi(Request $request, string $uri, array $query, string $controller, string $method, array $parameters = []): array + { + try { + return [$this->api($request, $uri, $query, $controller, $method, $parameters), null]; + } catch (HttpExceptionInterface $e) { + return [null, $e->getMessage()]; + } catch (ApiErrorException $e) { + report($e); + + return [null, Stripe::failure($e)]; + } + } + + /** + * Only the query parameters a listing page passes through to the API. + * + * @param list $keys + */ + protected function only(Request $request, array $keys): array + { + return array_filter($request->only($keys), fn ($value) => $value !== null && $value !== ''); + } +} diff --git a/app/Http/Controllers/Webhook/StripeWebhookController.php b/app/Http/Controllers/Webhook/StripeWebhookController.php new file mode 100644 index 0000000..73f83e9 --- /dev/null +++ b/app/Http/Controllers/Webhook/StripeWebhookController.php @@ -0,0 +1,57 @@ +error('Stripe webhooks are not set up on this registry.', 503); + } + + $payload = $request->getContent(); + $signature = (string) $request->header('Stripe-Signature'); + $event = null; + + foreach ($secrets as $secret) { + try { + $event = Webhook::constructEvent($payload, $signature, $secret, (int) config('services.stripe.webhook_tolerance', 300)); + break; + } catch (SignatureVerificationException) { + continue; + } catch (\UnexpectedValueException) { + return response()->error('The payload is not a Stripe event.', 400); + } + } + + if ($event === null) { + return response()->error('The Stripe signature is invalid.', 400); + } + + try { + $status = $webhooks->handle($event); + } catch (\Throwable $e) { + Log::error('Stripe webhook failed', ['event' => $event->id, 'type' => $event->type, 'error' => $e->getMessage()]); + report($e); + + return response()->error('The event could not be handled; Stripe will retry it.', 500); + } + + return response()->json(['received' => true, 'event' => $event->id, 'status' => $status]); + } +} diff --git a/app/Http/Filter/ExtensionFilter.php b/app/Http/Filter/ExtensionFilter.php new file mode 100644 index 0000000..f694310 --- /dev/null +++ b/app/Http/Filter/ExtensionFilter.php @@ -0,0 +1,156 @@ +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); + } + + /** + * `compatible=1` with `console=`: only listings with a published release that works + * with that console version (the release the console would install). Ignored without a valid + * console version. + */ + public function compatible(string $compatible): void + { + $console = $this->request->input('console'); + if (! filter_var($compatible, FILTER_VALIDATE_BOOLEAN) || ! is_string($console) || ! Semver::valid($console)) { + return; + } + + $this->builder->whereIn('extensions.uuid', array_keys(Compat::newestCompatible($console))); + } + + /** + * 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/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php new file mode 100644 index 0000000..0088450 --- /dev/null +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -0,0 +1,66 @@ + + */ + public function share(Request $request): array + { + /** @var Account|null $account */ + $account = Auth::guard('web')->user(); + + return [ + ...parent::share($request), + 'auth' => [ + 'account' => $account ? (new AccountResource($account->loadMissing('organizations')))->resolve($request) : null, + // Sign-in methods besides email and password that this registry has set up. + 'providers' => ['github' => GithubSignInController::configured()], + ], + 'registry' => [ + 'host' => parse_url((string) (config('registry.public_url') ?: $request->getSchemeAndHttpHost()), PHP_URL_HOST) ?: $request->getHost(), + 'url' => rtrim((string) (config('registry.public_url') ?: $request->getSchemeAndHttpHost()), '/'), + ], + // Fleetbase's facilitator fee now (the Studio price editor shows the publisher's share). + 'commerce' => fn () => ['fee_bps' => FacilitatorFee::rateAt(now()), 'enabled' => Stripe::configured()], + 'console' => $this->consoleVersion($request), + 'flash' => fn () => $request->session()->get('flash'), + ]; + } + + /** + * The console version from `?console=` (a console linking here sends it) or the cookie. + */ + protected function consoleVersion(Request $request): ?string + { + $version = $request->query('console') ?: $request->cookie(self::CONSOLE_COOKIE); + + return is_string($version) && preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version) ? $version : null; + } +} diff --git a/app/Http/Middleware/StatefulSession.php b/app/Http/Middleware/StatefulSession.php new file mode 100644 index 0000000..b3ec689 --- /dev/null +++ b/app/Http/Middleware/StatefulSession.php @@ -0,0 +1,52 @@ +isStateful($request)) { + return $next($request); + } + + return (new Pipeline(app())) + ->send($request) + ->through([EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class, VerifyCsrfToken::class]) + ->then(fn (Request $request) => $next($request)); + } + + public static function isStateful(Request $request): bool + { + if (Str::startsWith((string) $request->header('Authorization'), ['Bearer ', 'bearer '])) { + return false; + } + + return $request->routeIs(self::SESSION_ROUTES) + || $request->cookies->has((string) config('session.cookie')) + || $request->cookies->has('XSRF-TOKEN'); + } +} diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php new file mode 100644 index 0000000..f09b031 --- /dev/null +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -0,0 +1,18 @@ + ['required', 'string'], + // Same rule as registration. + 'password' => ['required', 'string', 'confirmed', Password::min(8)->letters()->numbers(), 'max:128'], + ]; + } +} 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/Account/UpdateAccountRequest.php b/app/Http/Requests/Account/UpdateAccountRequest.php new file mode 100644 index 0000000..82a696b --- /dev/null +++ b/app/Http/Requests/Account/UpdateAccountRequest.php @@ -0,0 +1,30 @@ + ['sometimes', 'required', 'string', 'max:120'], + 'username' => ['sometimes', 'required', 'string', 'min:3', 'max:40', 'regex:/^[a-z0-9][a-z0-9-]*$/', Rule::unique('accounts', 'username')->ignore($this->user()->uuid, 'uuid')], + 'timezone' => ['sometimes', 'nullable', 'string', 'timezone:all'], + ]; + } + + public function messages(): array + { + return [ + 'username.regex' => 'The username may only contain lowercase letters, numbers and dashes.', + ]; + } +} diff --git a/app/Http/Requests/Admin/ReviewDecisionRequest.php b/app/Http/Requests/Admin/ReviewDecisionRequest.php new file mode 100644 index 0000000..2273223 --- /dev/null +++ b/app/Http/Requests/Admin/ReviewDecisionRequest.php @@ -0,0 +1,44 @@ +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', Rule::in(array_column(config('registry.review.items', []), 'code'))], + '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/ResetPasswordRequest.php b/app/Http/Requests/Auth/ResetPasswordRequest.php new file mode 100644 index 0000000..8a70552 --- /dev/null +++ b/app/Http/Requests/Auth/ResetPasswordRequest.php @@ -0,0 +1,24 @@ + ['required', 'string', 'email:rfc'], + 'code' => ['required', 'string', 'digits:6'], + // Same rule as registration. + 'password' => ['required', 'string', 'confirmed', Password::min(8)->letters()->numbers(), 'max:128'], + ]; + } +} 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..8e0d59c --- /dev/null +++ b/app/Http/Requests/Instance/RegisterInstanceRequest.php @@ -0,0 +1,28 @@ + ['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'], + 'previous_token' => ['nullable', 'string', 'max:128'], + ]; + } +} 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/Instance/StartCheckoutRequest.php b/app/Http/Requests/Instance/StartCheckoutRequest.php new file mode 100644 index 0000000..e59107e --- /dev/null +++ b/app/Http/Requests/Instance/StartCheckoutRequest.php @@ -0,0 +1,28 @@ + ['required', 'string', 'max:191'], + 'return_url' => ['required', 'string', 'max:2000', 'url:http,https'], + 'cancel_url' => ['nullable', 'string', 'max:2000', 'url:http,https'], + 'email' => ['nullable', 'string', 'email', 'max:191'], + ]; + } +} diff --git a/app/Http/Requests/Organization/CreateOrganizationRequest.php b/app/Http/Requests/Organization/CreateOrganizationRequest.php new file mode 100644 index 0000000..5a32fed --- /dev/null +++ b/app/Http/Requests/Organization/CreateOrganizationRequest.php @@ -0,0 +1,33 @@ + ['required', 'string', 'min:2', 'max:120'], + // The slug is also the organization's npm scope and Composer vendor, so it never changes. + 'slug' => ['nullable', 'string', 'min:2', 'max:100', 'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', Rule::unique('organizations', 'slug'), Rule::notIn(config('registry.reserved_scopes', []))], + 'website' => ['nullable', 'url:http,https', 'max:255'], + 'bio' => ['nullable', 'string', 'max:2000'], + ]; + } + + public function messages(): array + { + return [ + 'slug.regex' => 'The slug may only contain lowercase letters, numbers and single dashes.', + 'slug.not_in' => 'That slug is reserved.', + ]; + } +} diff --git a/app/Http/Requests/Organization/MemberRequest.php b/app/Http/Requests/Organization/MemberRequest.php new file mode 100644 index 0000000..442b3ff --- /dev/null +++ b/app/Http/Requests/Organization/MemberRequest.php @@ -0,0 +1,26 @@ +routeIs('v1.organizations.members.store') ? ['email' => ['required', 'string', 'email:rfc', 'max:191']] : [], + ['role' => ['required', 'string', Rule::in(Membership::ROLES)]], + ); + } +} diff --git a/app/Http/Requests/Organization/UpdateOrganizationRequest.php b/app/Http/Requests/Organization/UpdateOrganizationRequest.php new file mode 100644 index 0000000..1fae02a --- /dev/null +++ b/app/Http/Requests/Organization/UpdateOrganizationRequest.php @@ -0,0 +1,31 @@ + ['sometimes', 'required', 'string', 'min:2', 'max:120'], + 'slug' => ['prohibited'], + 'website' => ['sometimes', 'nullable', 'url:http,https', 'max:255'], + 'bio' => ['sometimes', 'nullable', 'string', 'max:2000'], + 'avatar_url' => ['sometimes', 'nullable', 'url:https', 'max:255'], + ]; + } + + public function messages(): array + { + return [ + 'slug.prohibited' => 'An organization\'s slug cannot be changed.', + ]; + } +} diff --git a/app/Http/Requests/Publisher/CreateExtensionRequest.php b/app/Http/Requests/Publisher/CreateExtensionRequest.php new file mode 100644 index 0000000..ac0c7c6 --- /dev/null +++ b/app/Http/Requests/Publisher/CreateExtensionRequest.php @@ -0,0 +1,63 @@ +input('packages'); + if (is_array($packages)) { + $this->merge(['packages' => array_map(PackageNames::normalize(...), $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', self::packageName(ExtensionPackage::KIND_NPM)], + 'packages.composer' => ['nullable', 'required_without:packages.npm', 'string', self::packageName(ExtensionPackage::KIND_COMPOSER)], + ], $this->listingRules()); + } + + /** The format rule `PackageNames` applies, so availability checks and creation agree. */ + public static function packageName(string $kind): Closure + { + return function (string $attribute, mixed $value, Closure $fail) use ($kind) { + if (is_string($value) && ($error = PackageNames::formatError($kind, $value))) { + $fail($error); + } + }; + } + + /** + * @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..c0a0b13 --- /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:20000'], + '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/UpdatePriceRequest.php b/app/Http/Requests/Publisher/UpdatePriceRequest.php new file mode 100644 index 0000000..f27893e --- /dev/null +++ b/app/Http/Requests/Publisher/UpdatePriceRequest.php @@ -0,0 +1,40 @@ +input('currency'))) { + $this->merge(['currency' => strtoupper(trim($this->input('currency')))]); + } + } + + public function rules(): array + { + return [ + 'model' => ['required', 'string', Rule::in(Price::MODELS)], + 'amount' => ['nullable', 'required_if:model,'.Price::MODEL_ONE_TIME, 'integer', 'min:'.($this->input('model') === Price::MODEL_ONE_TIME ? 1 : 0), 'max:'.self::MAX_AMOUNT], + 'currency' => ['nullable', 'required_if:model,'.Price::MODEL_ONE_TIME, 'string', 'size:3', 'regex:/^[A-Z]{3}$/'], + 'sale_amount' => ['nullable', 'integer', 'min:1', 'lt:amount'], + 'on_sale' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/app/Http/Requests/Publisher/UploadReleaseRequest.php b/app/Http/Requests/Publisher/UploadReleaseRequest.php new file mode 100644 index 0000000..263f79f --- /dev/null +++ b/app/Http/Requests/Publisher/UploadReleaseRequest.php @@ -0,0 +1,96 @@ + ['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' => self::NOTES, + '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/Requests/Purchase/StartCheckoutRequest.php b/app/Http/Requests/Purchase/StartCheckoutRequest.php new file mode 100644 index 0000000..841c0e0 --- /dev/null +++ b/app/Http/Requests/Purchase/StartCheckoutRequest.php @@ -0,0 +1,25 @@ + ['required', 'string', 'max:191'], + 'organization' => ['nullable', 'string', 'max:191'], + ]; + } +} diff --git a/app/Http/Resources/AccountResource.php b/app/Http/Resources/AccountResource.php new file mode 100644 index 0000000..65e62ce --- /dev/null +++ b/app/Http/Resources/AccountResource.php @@ -0,0 +1,42 @@ + $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, + 'github_login' => $this->github_login, + // False for accounts created with GitHub until they set one with Forgot password. + 'has_password' => $this->password !== null, + 'timezone' => $this->timezone, + 'status' => $this->status, + 'email_verified_at' => $this->email_verified_at, + 'is_staff' => $this->relationLoaded('organizations') + ? $this->organizations->contains(fn ($organization) => $organization->is_staff === true) + : $this->resource->isStaff(), + '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, + 'is_staff' => (bool) $organization->is_staff, + 'verified_at' => $organization->verified_at, + ])->all()), + 'last_login_at' => $this->last_login_at, + 'updated_at' => $this->updated_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/AdminReleaseResource.php b/app/Http/Resources/AdminReleaseResource.php new file mode 100644 index 0000000..f9d93ae --- /dev/null +++ b/app/Http/Resources/AdminReleaseResource.php @@ -0,0 +1,50 @@ + $resource->withPrevious = true); + } + + public function toArray($request): array + { + $previous = $this->relationLoaded('previousRelease') ? $this->previousRelease : null; + $data = parent::toArray($request); + $data['previous_version'] = $previous?->version; + + if ($this->withPrevious) { + $data['previous'] = $previous === null ? null : [ + 'id' => Http::isInternalRequest() ? $previous->id : $previous->public_id, + 'version' => $previous->version, + 'published_at' => $previous->published_at, + 'compat' => $previous->compat ?? [], + 'manifest' => $previous->manifest ?? [], + 'size_bytes' => (int) $previous->size_bytes, + ]; + } + + return $data; + } +} 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/CheckoutResource.php b/app/Http/Resources/CheckoutResource.php new file mode 100644 index 0000000..fb5f940 --- /dev/null +++ b/app/Http/Resources/CheckoutResource.php @@ -0,0 +1,32 @@ +resource->hasLapsed() ? Checkout::STATUS_EXPIRED : $this->status; + + return [ + 'handoff' => $this->public_id, + 'status' => $status, + 'source' => $this->source, + 'extension' => $this->whenLoaded('extension', fn () => $this->extension ? (new ExtensionReferenceResource($this->extension))->resolve($request) : null), + 'organization' => $this->whenLoaded('organization', fn () => $this->organization ? ['id' => $this->organization->public_id, 'slug' => $this->organization->slug, 'name' => $this->organization->name] : null), + 'amount' => (int) $this->amount, + 'currency' => $this->currency, + 'url' => $status === Checkout::STATUS_OPEN ? $this->checkout_url : null, + 'expires_at' => $this->expires_at, + 'completed_at' => $this->completed_at, + 'purchase' => $this->whenLoaded('purchase', fn () => $this->purchase ? (new PurchaseResource($this->purchase))->resolve($request) : null), + 'created_at' => $this->created_at, + ]; + } +} 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..2ddf846 --- /dev/null +++ b/app/Http/Resources/ExtensionResource.php @@ -0,0 +1,52 @@ + $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 () => static::screenshotList($this->resource)), + '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, + 'dist' => $release->relationLoaded('engineDist') ? ReleaseResource::dist($release) : null, + ])->all()), + 'versions_count' => $this->whenCounted('publishedReleases'), + 'publisher' => $this->whenLoaded('organization', fn () => (new PublisherResource($this->organization))->resolve($request)), + ]); + } + + /** + * The listing's loaded screenshots in display order, as the catalog, the listing editor + * and the review console show them. `order` is the position, from 0. + */ + public static function screenshotList(Extension $extension): array + { + return $extension->orderedScreenshots()->map(fn ($file, int $position) => [ + 'id' => $file->public_id, + 'url' => $file->url, + 'caption' => $file->meta['caption'] ?? null, + 'order' => $position, + 'name' => $file->name, + 'mime' => $file->mime, + ])->all(); + } +} diff --git a/app/Http/Resources/ExtensionSummaryResource.php b/app/Http/Resources/ExtensionSummaryResource.php new file mode 100644 index 0000000..a8e9857 --- /dev/null +++ b/app/Http/Resources/ExtensionSummaryResource.php @@ -0,0 +1,53 @@ +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 ?? []), + // Only with ?console= on the catalog list: {console, compatible, newest_compatible}. + 'compatibility' => $this->when(array_key_exists('compatibility', $this->resource->getAttributes()), fn () => $this->resource->getAttributes()['compatibility']), + '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/FinancePurchaseResource.php b/app/Http/Resources/FinancePurchaseResource.php new file mode 100644 index 0000000..79e6012 --- /dev/null +++ b/app/Http/Resources/FinancePurchaseResource.php @@ -0,0 +1,45 @@ +resource); + $organization = $this->extension?->organization; + + return [ + 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'extension' => $this->extension ? (new ExtensionReferenceResource($this->extension))->resolve($request) : null, + 'publisher' => $organization ? ['id' => $organization->public_id, 'slug' => $organization->slug, 'name' => $organization->name] : null, + 'customer' => $this->buyer ? ['id' => $this->buyer->public_id, 'slug' => $this->buyer->slug, 'name' => $this->buyer->name] : null, + 'gross' => $figures['gross'], + 'fee' => $figures['fee'], + 'net' => (int) $this->net_amount, + 'tax' => (int) ($this->meta['amount_tax'] ?? 0), + 'refunded' => $figures['refunded'], + 'fee_refunded' => $figures['fee_refunded'], + 'refundable' => Refunds::refundable($this->resource), + 'currency' => $this->currency, + 'status' => $this->status, + 'dispute' => is_array($this->meta['dispute'] ?? null) ? $this->meta['dispute'] : null, + 'refunded_at' => $this->meta['refunded_at'] ?? null, + 'stripe' => [ + 'payment_intent' => $this->stripe_payment_intent_id, + 'charge' => $this->meta['stripe_charge_id'] ?? null, + 'account' => $this->meta['stripe_account'] ?? null, + ], + '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..aac88a8 --- /dev/null +++ b/app/Http/Resources/LicenceResource.php @@ -0,0 +1,43 @@ + $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), + 'organization' => $this->whenLoaded('organization', fn () => $this->organization ? ['id' => $this->organization->public_id, 'slug' => $this->organization->slug, 'name' => $this->organization->name] : null), + 'purchase' => $this->whenLoaded('purchase', fn () => $this->purchase?->public_id), + 'receipt_url' => $this->whenLoaded('purchase', fn () => $this->purchase?->receipt_url), + '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')), + 'activations' => $this->whenLoaded('activeActivations', fn () => $this->activeActivations->map(fn ($activation) => [ + 'id' => $activation->uuid, + 'instance' => $activation->relationLoaded('instance') && $activation->instance ? [ + 'id' => $activation->instance->public_id, + 'name' => $activation->instance->name, + 'host' => $activation->instance->host, + 'kind' => $activation->instance->kind, + ] : null, + 'release' => $activation->relationLoaded('release') ? $activation->release?->version : null, + 'activated_at' => $activation->activated_at, + ])->values()->all()), + 'revoked_reason' => $this->when($this->status === 'revoked', fn () => $this->meta['revoked_reason'] ?? null), + 'expires_at' => $this->expires_at, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/MemberResource.php b/app/Http/Resources/MemberResource.php new file mode 100644 index 0000000..c189111 --- /dev/null +++ b/app/Http/Resources/MemberResource.php @@ -0,0 +1,24 @@ +relationLoaded('account') ? $this->account : null; + + return [ + 'id' => $account?->public_id, + 'name' => $account?->name, + 'username' => $account?->username, + 'email' => $account?->email, + 'avatar_url' => $account?->avatar_url, + 'role' => $this->role, + 'joined_at' => $this->created_at, + ]; + } +} diff --git a/app/Http/Resources/OrganizationResource.php b/app/Http/Resources/OrganizationResource.php new file mode 100644 index 0000000..b8b9c68 --- /dev/null +++ b/app/Http/Resources/OrganizationResource.php @@ -0,0 +1,31 @@ + $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_staff' => (bool) $this->is_staff, + 'is_verified' => $this->verified_at !== null, + 'verified_at' => $this->verified_at, + 'role' => $this->whenPivotLoaded('memberships', fn () => $this->pivot->role), + 'members_count' => $this->whenCounted('memberships'), + 'members' => $this->whenLoaded('memberships', fn () => MemberResource::collection($this->memberships)->resolve($request)), + '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/PayoutTransactionResource.php b/app/Http/Resources/PayoutTransactionResource.php new file mode 100644 index 0000000..1bf1c67 --- /dev/null +++ b/app/Http/Resources/PayoutTransactionResource.php @@ -0,0 +1,30 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'extension' => $this->whenLoaded('extension', fn () => $this->extension ? (new ExtensionReferenceResource($this->extension))->resolve($request) : null), + 'customer' => $this->whenLoaded('buyer', fn () => $this->buyer ? ['id' => $this->buyer->public_id, 'slug' => $this->buyer->slug, 'name' => $this->buyer->name] : null), + 'gross' => (int) $this->amount_total, + 'fee' => (int) $this->fee_amount, + 'net' => (int) $this->net_amount, + 'tax' => (int) ($this->meta['amount_tax'] ?? 0), + 'refunded' => (int) ($this->meta['amount_refunded'] ?? 0), + 'fee_rate_bps' => isset($this->meta['fee_rate_bps']) ? (int) $this->meta['fee_rate_bps'] : null, + 'currency' => $this->currency, + 'status' => $this->status, + '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..ffdecf3 --- /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' => $this->resource->isOnSale(), + 'sale_amount' => $this->resource->hasSaleAmount() ? (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..cec3b03 --- /dev/null +++ b/app/Http/Resources/PublisherExtensionResource.php @@ -0,0 +1,70 @@ +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, + 'icon_url' => $this->whenLoaded('icon', fn () => $this->icon?->url, null), + 'screenshots' => $this->whenLoaded('screenshots', fn () => ExtensionResource::screenshotList($this->resource), []), + '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, + // A withdrawn release keeps its old submission time in meta; it is not submitted now. + 'submitted_at' => in_array($release->status, self::UNSUBMITTED, true) ? null : $release->metaValue('submission.submitted_at'), + // Set when the releases were loaded with ExtensionController::withDecidedAt. + 'decided_at' => self::decidedAt($release->getAttributes()['decided_at'] ?? null), + '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, + ]; + } + + protected static function decidedAt(mixed $value): ?string + { + return $value === null ? null : Date::parse($value)->toJSON(); + } +} 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/PurchaseResource.php b/app/Http/Resources/PurchaseResource.php new file mode 100644 index 0000000..1f31901 --- /dev/null +++ b/app/Http/Resources/PurchaseResource.php @@ -0,0 +1,32 @@ + $this->when(Http::isInternalRequest(), $this->id, $this->public_id), + 'extension' => $this->whenLoaded('extension', fn () => $this->extension ? (new ExtensionReferenceResource($this->extension))->resolve($request) : null), + 'organization' => $this->whenLoaded('buyer', fn () => $this->buyer ? ['id' => $this->buyer->public_id, 'slug' => $this->buyer->slug, 'name' => $this->buyer->name] : null), + 'bought_by' => $this->whenLoaded('buyerAccount', fn () => $this->buyerAccount ? ['id' => $this->buyerAccount->public_id, 'name' => $this->buyerAccount->name, 'email' => $this->buyerAccount->email] : null), + 'source' => $this->meta['source'] ?? null, + 'amount_total' => (int) $this->amount_total, + 'amount_subtotal' => (int) ($this->meta['amount_subtotal'] ?? $this->amount_total), + 'amount_tax' => (int) ($this->meta['amount_tax'] ?? 0), + 'amount_refunded' => (int) ($this->meta['amount_refunded'] ?? 0), + 'currency' => $this->currency, + 'status' => $this->status, + 'receipt_url' => $this->receipt_url, + 'licence' => $this->whenLoaded('licence', fn () => $this->licence ? (new LicenceResource($this->licence))->resolve($request) : null), + '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..b2849b7 --- /dev/null +++ b/app/Http/Resources/ReleaseDetailResource.php @@ -0,0 +1,109 @@ + $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 () => $this->extensionPreview($request)), + '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, + ]; + } + + protected function extensionPreview($request): array + { + $extension = $this->extension; + $loaded = fn (string $relation) => $extension->relationLoaded($relation); + + $preview = [ + 'id' => $extension->public_id, + 'slug' => $extension->slug, + 'name' => $extension->name, + 'status' => $extension->status, + 'organization' => $loaded('organization') && $extension->organization + ? ['id' => $extension->organization->public_id, 'slug' => $extension->organization->slug, 'name' => $extension->organization->name, 'is_verified' => $extension->organization->verified_at !== null] + : null, + 'subtitle' => $extension->subtitle, + 'description' => $extension->description, + 'promotional_text' => $extension->promotional_text, + 'tags' => $extension->tags ?? [], + 'languages' => $extension->languages ?? [], + 'hosting' => $extension->hosting, + 'fa_icon' => $extension->fa_icon, + 'website_url' => $extension->website_url, + 'repo_url' => $extension->repo_url, + 'support_url' => $extension->support_url, + 'privacy_url' => $extension->privacy_url, + 'tos_url' => $extension->tos_url, + 'copyright' => $extension->copyright, + ]; + + if ($loaded('icon')) { + $preview['icon_url'] = $extension->icon?->url; + } + if ($loaded('screenshots')) { + $preview['screenshots'] = ExtensionResource::screenshotList($extension); + } + if ($loaded('category')) { + $preview['category'] = $extension->category ? ['id' => $extension->category->public_id, 'slug' => $extension->category->slug, 'name' => $extension->category->name] : null; + } + if ($loaded('price')) { + $preview['price'] = (new PriceResource($extension->price))->resolve($request); + } + + return $preview; + } +} diff --git a/app/Http/Resources/ReleaseResource.php b/app/Http/Resources/ReleaseResource.php new file mode 100644 index 0000000..debbc3e --- /dev/null +++ b/app/Http/Resources/ReleaseResource.php @@ -0,0 +1,50 @@ + $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, + 'dist' => $this->whenLoaded('engineDist', fn () => static::dist($this->resource)), + 'published_at' => $this->published_at, + 'yanked_at' => $this->yanked_at, + 'created_at' => $this->created_at, + ]; + } + + /** + * The prebuilt engine dist of a release (its `engineDist` loaded), or null: what an + * installer needs to fetch `GET /npm/{engine}/-/dist/{version}` and verify it, and the + * `@fleetbase/ember-ui` / `@fleetbase/ember-core` versions it was built against. + * + * @return array{size: int, sha256: ?string, integrity: ?string, built_against: array}|null + */ + public static function dist(Release $release): ?array + { + $file = $release->engineDist; + if ($file === null) { + return null; + } + + return [ + 'size' => (int) $file->size, + 'sha256' => $file->checksum, + 'integrity' => $file->integrity, + 'built_against' => (array) $release->metaValue('dist.built_against', []), + ]; + } +} 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), + 'installed_at' => $createdAt, + 'uninstalled_at' => $installed ? null : ($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/DisputeOpenedMail.php b/app/Mail/DisputeOpenedMail.php new file mode 100644 index 0000000..48d8bf8 --- /dev/null +++ b/app/Mail/DisputeOpenedMail.php @@ -0,0 +1,48 @@ +purchase->loadMissing('extension'); + + return new Envelope(subject: 'Dispute opened: '.$this->purchase->extension->name.' ('.$this->purchase->public_id.')'); + } + + public function content(): Content + { + $this->purchase->loadMissing(['extension.organization', 'buyer', 'licence']); + $dispute = (array) ($this->purchase->meta['dispute'] ?? []); + + return new Content( + markdown: 'mail.dispute-opened', + with: [ + 'extension' => $this->purchase->extension->name, + 'publisher' => $this->purchase->extension->organization?->name, + 'buyer' => $this->purchase->buyer?->name, + 'amount' => number_format(((int) ($dispute['amount'] ?? $this->purchase->amount_total)) / 100, 2).' '.$this->purchase->currency, + 'reason' => $dispute['reason'] ?? null, + 'disputeId' => $dispute['id'] ?? null, + 'purchaseId' => $this->purchase->public_id, + 'licenceId' => $this->purchase->licence?->public_id, + ], + ); + } +} diff --git a/app/Mail/PasswordResetCodeMail.php b/app/Mail/PasswordResetCodeMail.php new file mode 100644 index 0000000..aa4f5b0 --- /dev/null +++ b/app/Mail/PasswordResetCodeMail.php @@ -0,0 +1,37 @@ + $this->account->name, + 'code' => $this->verificationCode->code, + 'minutes' => VerificationCode::LIFETIME_MINUTES, + 'url' => url('/forgot-password?email='.rawurlencode($this->account->email).'&sent=1'), + ], + ); + } +} diff --git a/app/Mail/PurchaseReceiptMail.php b/app/Mail/PurchaseReceiptMail.php new file mode 100644 index 0000000..e28389b --- /dev/null +++ b/app/Mail/PurchaseReceiptMail.php @@ -0,0 +1,47 @@ +purchase->loadMissing('extension'); + + return new Envelope(subject: 'Your receipt for '.$this->purchase->extension->name); + } + + public function content(): Content + { + $this->purchase->loadMissing(['extension', 'buyer', 'licence']); + + return new Content( + markdown: 'mail.purchase-receipt', + with: [ + 'extension' => $this->purchase->extension->name, + 'organization' => $this->purchase->buyer?->name, + 'amount' => number_format($this->purchase->amount_total / 100, 2).' '.$this->purchase->currency, + 'seats' => $this->purchase->licence?->max_activations, + 'receiptUrl' => $this->purchase->receipt_url, + 'billingUrl' => rtrim((string) (config('registry.public_url') ?: config('app.url')), '/').'/account/billing/'.$this->purchase->buyer?->slug, + 'purchaseId' => $this->purchase->public_id, + ], + ); + } +} 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..ee9737a --- /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/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 0000000..19df38c --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,83 @@ + Money::class, + 'fee_amount' => Money::class, + 'fee_rate_bps' => 'integer', + 'price_snapshot' => 'array', + 'expires_at' => 'datetime', + 'completed_at' => 'datetime', + ]; + + 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 account(): BelongsTo + { + return $this->belongsTo(Account::class, 'account_uuid', 'uuid'); + } + + public function instance(): BelongsTo + { + return $this->belongsTo(Instance::class, 'instance_uuid', 'uuid'); + } + + public function purchase(): BelongsTo + { + return $this->belongsTo(Purchase::class, 'purchase_uuid', 'uuid'); + } + + /** + * Open, but past the session's expiry (the `checkout.session.expired` webhook may lag). + */ + public function hasLapsed(): bool + { + return $this->status === self::STATUS_OPEN && $this->expires_at !== null && $this->expires_at->isPast(); + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php new file mode 100644 index 0000000..eae0fda --- /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..61747f5 --- /dev/null +++ b/app/Models/Extension.php @@ -0,0 +1,203 @@ + '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'); + } + + /** Most screenshots a listing can have. */ + public const MAX_SCREENSHOTS = 8; + + /** + * The loaded screenshots in display order: `meta.order` (set by the publisher's reorder), + * then upload time. Imported screenshots have no order and keep their upload order. + * + * @return Collection + */ + public function orderedScreenshots(): Collection + { + return $this->screenshots->sortBy([ + fn (File $a, File $b) => ($a->meta['order'] ?? PHP_INT_MAX) <=> ($b->meta['order'] ?? PHP_INT_MAX), + fn (File $a, File $b) => [$a->created_at, $a->id] <=> [$b->created_at, $b->id], + ])->values(); + } + + 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); + } + + /** + * An extension by slug, public id, uuid, or one of its npm or Composer package names (what + * an instance knows from its installed packages). + */ + public static function findByIdentifier(string $identifier): ?static + { + $identifier = urldecode($identifier); + + $extension = static::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 ? static::find($package->extension_uuid) : null; + } + + 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/FacilitatorFee.php b/app/Models/FacilitatorFee.php new file mode 100644 index 0000000..3d5c535 --- /dev/null +++ b/app/Models/FacilitatorFee.php @@ -0,0 +1,52 @@ + 'integer', 'effective_from' => 'datetime']; + + public function createdBy(): BelongsTo + { + return $this->belongsTo(Account::class, 'created_by_account_uuid', 'uuid'); + } + + /** + * The rate in effect at a moment, falling back to `registry.commerce.facilitator_fee_bps` + * before the first row takes effect. + */ + public static function rateAt(?\DateTimeInterface $at = null): int + { + $rate = static::query() + ->where('effective_from', '<=', $at ?? now()) + ->orderByDesc('effective_from') + ->orderByDesc('id') + ->value('rate_bps'); + + return $rate ?? (int) config('registry.commerce.facilitator_fee_bps', 1000); + } + + /** + * The fee on an amount in minor units, rounded half up. + */ + public static function feeFor(int $amount, int $rateBps): int + { + return (int) round($amount * $rateBps / 10000, 0, PHP_ROUND_HALF_UP); + } +} 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..a8daace --- /dev/null +++ b/app/Models/Install.php @@ -0,0 +1,82 @@ + 'datetime', + 'finished_at' => 'datetime', + 'installed_at' => 'datetime', + 'uninstalled_at' => 'datetime', + 'replaced_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'); + } + + public function previousRelease(): BelongsTo + { + return $this->belongsTo(Release::class, 'previous_release_uuid', 'uuid'); + } + + /** + * Installs running now: what the catalog's `installs_count` and the active-instance figures + * count (one installed row per instance and extension). + */ + public function scopeActive(Builder $query): Builder + { + return $query->where('status', self::STATUS_INSTALLED); + } +} diff --git a/app/Models/Instance.php b/app/Models/Instance.php new file mode 100644 index 0000000..1574517 --- /dev/null +++ b/app/Models/Instance.php @@ -0,0 +1,195 @@ + '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(); + } + + /** + * The instance's registered host name, lowercased, whether `host` was stored as a URL or a + * bare host (with or without a port). + */ + public function hostname(): ?string + { + $host = trim((string) $this->host); + if ($host === '') { + return null; + } + + $name = parse_url(str_contains($host, '://') ? $host : 'http://'.$host, PHP_URL_HOST); + + return is_string($name) && $name !== '' ? strtolower($name) : null; + } + + /** + * A URL on this instance's registered host or its parent domain: an instance registered as + * `api.acme.com` may send buyers back to `acme.com` or `console.acme.com`. IP addresses and + * single-label hosts (`localhost`) only match themselves. + */ + public function ownsUrl(string $url): bool + { + $registered = $this->hostname(); + $host = parse_url($url, PHP_URL_HOST); + if ($registered === null || ! is_string($host) || $host === '') { + return false; + } + + $host = strtolower($host); + if ($host === $registered) { + return true; + } + + $labels = explode('.', $registered); + if (filter_var($registered, FILTER_VALIDATE_IP) || count($labels) < 2) { + return false; + } + + $base = count($labels) > 2 ? implode('.', array_slice($labels, 1)) : $registered; + + return $host === $base || str_ends_with($host, '.'.$base); + } + + /** + * The active instance of an organization registered from the same host (scheme, case and a + * trailing slash ignored), most recently seen first. Null when the host is empty. + */ + public static function findByHost(string $organizationUuid, ?string $host): ?static + { + $host = static::normalizeHost($host); + if ($host === null) { + return null; + } + + return static::query() + ->where('organization_uuid', $organizationUuid) + ->where('status', self::STATUS_ACTIVE) + ->whereNotNull('host') + ->orderByDesc('last_seen_at') + ->orderByDesc('created_at') + ->get() + ->first(fn (Instance $instance) => static::normalizeHost($instance->host) === $host); + } + + /** + * `HTTPS://Fleet.Acme.test/` and `fleet.acme.test` are the same host. + */ + public static function normalizeHost(?string $host): ?string + { + $host = strtolower(trim((string) $host)); + $host = rtrim((string) preg_replace('#^[a-z][a-z0-9+.-]*://#', '', $host), '/'); + + return $host !== '' ? $host : null; + } + + 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..0afb0a5 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,118 @@ + '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; + } + + public function hasStripeAccount(): bool + { + return filled($this->stripe_connect_id); + } + + /** + * What `account.updated` last said about the connected account (charges, payouts, + * transfers capability, requirements), or an empty array. + */ + public function stripeState(): array + { + return (array) ($this->meta['stripe'] ?? []); + } + + /** + * Destination charges can be paid out to this organization: its connected account's + * `transfers` capability is active. + */ + public function canReceivePayments(): bool + { + return $this->hasStripeAccount() + && ($this->stripeState()['transfers'] ?? null) === 'active' + && $this->payout_status !== self::PAYOUT_REJECTED; + } +} diff --git a/app/Models/Price.php b/app/Models/Price.php new file mode 100644 index 0000000..29444ce --- /dev/null +++ b/app/Models/Price.php @@ -0,0 +1,87 @@ + 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; + } + + /** + * A sale amount is set: positive and below the regular amount. (Money stores a cleared + * sale amount as 0.) + */ + public function hasSaleAmount(): bool + { + return (int) $this->sale_amount > 0 && (int) $this->sale_amount < (int) $this->amount; + } + + /** + * The sale is on: `on_sale` with a sale amount. + */ + public function isOnSale(): bool + { + return (bool) $this->on_sale && $this->hasSaleAmount(); + } + + /** + * The amount a buyer pays right now, honouring an active sale. + */ + public function getEffectiveAmountAttribute(): int + { + return $this->isOnSale() ? (int) $this->sale_amount : (int) $this->amount; + } + + /** + * The Stripe price Checkout charges: the sale price while the sale is on. + */ + public function checkoutStripePriceId(): ?string + { + return $this->isOnSale() ? $this->stripe_sale_price_id : $this->stripe_price_id; + } +} diff --git a/app/Models/Purchase.php b/app/Models/Purchase.php new file mode 100644 index 0000000..7d9c26e --- /dev/null +++ b/app/Models/Purchase.php @@ -0,0 +1,66 @@ + '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 buyerAccount(): BelongsTo + { + return $this->belongsTo(Account::class, 'buyer_account_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..5a720fc --- /dev/null +++ b/app/Models/VerificationCode.php @@ -0,0 +1,87 @@ + 'integer', 'expires_at' => '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), + ]); + } + + /** + * Use the subject's live code for the purpose. A wrong code counts against the live one, + * which stops working after MAX_ATTEMPTS wrong guesses. + */ + 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) + ->whereNull('used_at') + ->where('expires_at', '>', now()) + ->where('attempts', '<', static::MAX_ATTEMPTS) + ->latest('created_at') + ->first(); + + if (! $record) { + return false; + } + + if (! hash_equals((string) $record->code, $code)) { + $record->increment('attempts'); + + 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..59fa7c0 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,105 @@ +app->bind(StripeClient::class, fn () => new StripeClient([ + 'api_key' => (string) config('services.stripe.secret'), + 'max_network_retries' => 2, + ])); + } + + public function boot(): void + { + // Fleetbase resources are returned unwrapped; paginated lists get { data, meta }. + JsonResource::withoutWrapping(); + + Model::shouldBeStrict(! $this->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; + * `auth:web` resolves the website's cookie session to its session token (flbs_…). + */ + protected function registerAuthGuard(): void + { + Auth::viaRequest('registry-token', new TokenGuard(TokenGuard::KIND_PERSONAL)); + Auth::viaRequest('registry-instance-token', new TokenGuard(TokenGuard::KIND_INSTANCE)); + Auth::viaRequest('registry-session', new SessionGuard); + } + + /** + * `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())); + + // Stripe retries and bursts (a refund sends several events at once). + RateLimiter::for('webhooks', fn (Request $request) => Limit::perMinute(600)->by($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 @@ +/