diff --git a/.env.example b/.env.example index 53f3bc1..1ea6049 100644 --- a/.env.example +++ b/.env.example @@ -26,8 +26,12 @@ DB_CONNECTION=sqlite # 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 diff --git a/README.md b/README.md index 071b2b3..792a2ac 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ Phase 1, Workstreams A to C, and Phase 2 Workstream E: | Instance API: registration codes, register, me, updates, installs, licence seats | done | | `registry:import` from the cloud registry-bridge tables (dry run, anomaly report, tarball copy) | done | | Releases pipeline: publisher API, upload, queued checks, state machine, D6 review policy, staff review API, publishing | done | -| Publisher website (Workstream F), CLI v2 (G), Stripe (Phase 4) | later workstreams | +| Website backend (Workstream F): cookie sessions, organizations and members, listing media and pricing, staff downloads and review items, SPA fallback | done; the website itself is in progress | +| CLI v2 (G), Stripe (Phase 4) | later workstreams | ## Stack @@ -66,6 +67,11 @@ Run the tests: composer test ``` +`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: @@ -93,12 +99,33 @@ validation failures add a `fields` map. Resources are returned unwrapped; paged | `POST /v1/auth/verify/resend` | email. Sends a new code for unverified accounts | | `POST /v1/auth/login` | email + password (+ token_name, scopes, expires_in_days). Returns a personal token once | | `POST /v1/auth/logout` | Revokes the token used for the request | -| `GET /v1/account` | The caller's account with organizations and roles | +| `GET /v1/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 | | `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. + 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 @@ -181,6 +208,10 @@ releases answer 404. | `POST /v1/publisher/extensions` | name, `packages: {npm, composer}` (one or both), optional slug, organization and listing fields. Creates a draft listing with a free price (201). Idempotent on package names: names that already belong to one of the caller's listings return it (200) and add any package kind it lacks; a different name for a kind it has, names spread over two listings, or names of another publisher answer 409 | | `GET /v1/publisher/extensions/{slug}` | Listing with its releases | | `PATCH /v1/publisher/extensions/{slug}` | Listing fields (name, subtitle, description, promotional_text, category, tags, languages, hosting, website/repo/support/privacy/tos URLs, copyright, fa_icon). No review | +| `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`. Paid prices need the organization's Stripe Connect account (Phase 4); a price change sends the next release to review | | `GET /v1/publisher/extensions/{slug}/releases` | Every release with its checks | | `POST /v1/publisher/extensions/{slug}/releases` | Multipart: `manifest` (extension.json), `engine` (npm tarball, gzipped) and/or `api` (Composer tar or tar.gz), optional `dist` (the `/` folder from ember-core's `fleetbase-build-engine-release`, tarred), optional `channel` (`stable`, `beta`; default from the manifest), `notes`, `submit` (submit as soon as the checks pass). Returns the release (201) | | `GET /v1/publisher/releases/{id}` | Files, checks, submission state, review policy outcome, the review timeline with reasons and items | @@ -269,6 +300,9 @@ checks are emailed too. | `POST /v1/admin/releases/{id}/reject` | Required `reason` and `items: [{code, passed, note?}]` (at least one) | | `POST /v1/admin/releases/{id}/publish` | An approved release | | `POST /v1/admin/extensions/{slug}/suspend` | Required `reason`. The listing leaves the catalog and the package endpoints; uploads and publishing stop | +| `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 | ### Instance API (instance token) diff --git a/app/Auth/SessionGuard.php b/app/Auth/SessionGuard.php new file mode 100644 index 0000000..78fd697 --- /dev/null +++ b/app/Auth/SessionGuard.php @@ -0,0 +1,76 @@ +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(); + } + + 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/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.

', + '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/Http/Controllers/Account/AccountController.php b/app/Http/Controllers/Account/AccountController.php index b83ad74..b2c932b 100644 --- a/app/Http/Controllers/Account/AccountController.php +++ b/app/Http/Controllers/Account/AccountController.php @@ -3,8 +3,16 @@ namespace App\Http\Controllers\Account; use App\Http\Controllers\Controller; +use App\Http\Requests\Account\ChangePasswordRequest; +use App\Http\Requests\Account\UpdateAccountRequest; use App\Http\Resources\AccountResource; +use App\Models\Account; +use App\Models\Event; +use App\Models\Token; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Hash; +use Illuminate\Validation\ValidationException; class AccountController extends Controller { @@ -15,4 +23,47 @@ public function show(Request $request): AccountResource { return new AccountResource($request->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/TokenController.php b/app/Http/Controllers/Account/TokenController.php index 2033eba..5085493 100644 --- a/app/Http/Controllers/Account/TokenController.php +++ b/app/Http/Controllers/Account/TokenController.php @@ -15,14 +15,20 @@ class TokenController extends Controller { /** - * GET /v1/account/tokens — the caller's tokens, newest first, masked. + * GET /v1/account/tokens — the caller's tokens, newest first, masked. Website session + * tokens (`flbs_`) are not listed; they end with their session. */ public function index(Request $request) { /** @var Account $account */ $account = $request->user(); - $tokens = $account->tokens()->with('organization')->whereNull('revoked_at')->orderByDesc('created_at')->get(); + $tokens = $account->tokens() + ->with('organization') + ->whereNull('revoked_at') + ->where('prefix', '!=', Token::PREFIX_SESSION) + ->orderByDesc('created_at') + ->get(); return TokenResource::collection($tokens); } @@ -75,7 +81,10 @@ public function destroy(Request $request, string $id): JsonResponse /** @var Account $account */ $account = $request->user(); - $token = $account->tokens()->where(fn ($q) => $q->where('public_id', $id)->orWhere('uuid', $id))->first(); + $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); diff --git a/app/Http/Controllers/Admin/ReviewController.php b/app/Http/Controllers/Admin/ReviewController.php index 40d4582..fdc23ce 100644 --- a/app/Http/Controllers/Admin/ReviewController.php +++ b/app/Http/Controllers/Admin/ReviewController.php @@ -3,21 +3,29 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Http\Controllers\Publisher\ExtensionController as PublisherExtensionController; use App\Http\Controllers\Publisher\PublisherController; use App\Http\Requests\Admin\ReviewDecisionRequest; use App\Http\Requests\Admin\SuspendExtensionRequest; use App\Http\Resources\PublisherExtensionResource; use App\Http\Resources\ReleaseDetailResource; +use App\Models\Event; use App\Models\Extension; +use App\Models\File; use App\Models\Release; use App\Releases\ReleasePipeline; +use App\Services\ReleaseStorage; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** - * Staff review console API: queue, claim, approve, reject, publish, suspend. Needs a token - * with the `review` scope and a membership in an `is_staff` organization. + * Staff review console API: queue, claim, approve, reject, publish, archive downloads, + * suspend and unsuspend. Needs a token (or website session) with the `review` scope and a + * membership in an `is_staff` organization. */ class ReviewController extends Controller { @@ -38,7 +46,7 @@ public function queue(Request $request) $request->merge(['statuses' => $statuses])->validate(['statuses.*' => [Rule::in(Release::STATUSES)]]); $releases = Release::query() - ->with(['extension.organization', 'engineTarball', 'apiTarball', 'engineDist', 'checks']) + ->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') @@ -49,6 +57,17 @@ public function queue(Request $request) return ReleaseDetailResource::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 and the listing preview. + */ public function show(string $release): ReleaseDetailResource { return new ReleaseDetailResource(PublisherController::releaseDetail(Release::findByIdOrFail($release))); @@ -101,6 +120,24 @@ public function publish(Request $request, string $release): ReleaseDetailResourc return new ReleaseDetailResource(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. */ @@ -109,6 +146,20 @@ public function suspend(SuspendExtensionRequest $request, string $slug): Publish $extension = Extension::query()->where(fn (Builder $q) => $q->where('slug', $slug)->orWhere('public_id', $slug))->firstOrFail(); $extension = $this->pipeline->suspend($extension, $request->user(), $request->validated('reason')); - return new PublisherExtensionResource($extension->load(['organization', 'category', 'packages', 'price'])); + 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 index f45bc76..eafe7a4 100644 --- a/app/Http/Controllers/Auth/AuthController.php +++ b/app/Http/Controllers/Auth/AuthController.php @@ -15,11 +15,12 @@ use App\Models\VerificationCode; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; class AuthController extends Controller { + use ChecksCredentials; + /** * POST /v1/auth/register */ @@ -83,20 +84,9 @@ public function resendVerification(Request $request, RegisterAccount $registerAc */ public function login(LoginRequest $request): JsonResponse { - $account = Account::where('email', Str::lower($request->input('email')))->first(); - - if (! $account || ! $account->password || ! Hash::check($request->input('password'), $account->password)) { - Event::record('account.login_failed', $account, null, ['email' => Str::lower($request->input('email'))], $request->ip()); - - return response()->error('Invalid email or password.', 401); - } - - if (! $account->hasVerifiedEmail()) { - return response()->error('Email address is not verified.', 403, ['code' => 'email_unverified']); - } - - if (! $account->isActive()) { - return response()->error('This account is '.$account->status.'.', 403, ['code' => 'account_'.$account->status]); + $account = $this->attempt($request); + if ($account instanceof JsonResponse) { + return $account; } $token = Token::issue( 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/SessionController.php b/app/Http/Controllers/Auth/SessionController.php new file mode 100644 index 0000000..9124c0a --- /dev/null +++ b/app/Http/Controllers/Auth/SessionController.php @@ -0,0 +1,102 @@ +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; + } + + // Signing in over an existing session replaces its token. + SessionGuard::token($request)?->revoke(); + + $token = Token::issue( + $account, + 'web session '.now()->toDateString(), + [Token::SCOPE_ALL], + now()->addMinutes(SessionGuard::lifetimeMinutes()), + null, + Token::PREFIX_SESSION, + ); + $token->plainTextToken = null; + + $request->session()->regenerate(); + $request->session()->put(SessionGuard::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' => 'session'], $request->ip()); + + 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/Organization/OrganizationController.php b/app/Http/Controllers/Organization/OrganizationController.php new file mode 100644 index 0000000..4b1ce2c --- /dev/null +++ b/app/Http/Controllers/Organization/OrganizationController.php @@ -0,0 +1,312 @@ +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 account(Request $request): Account + { + return $request->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; + } + + 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/Publisher/ExtensionController.php b/app/Http/Controllers/Publisher/ExtensionController.php index 992e750..1dc4881 100644 --- a/app/Http/Controllers/Publisher/ExtensionController.php +++ b/app/Http/Controllers/Publisher/ExtensionController.php @@ -4,6 +4,8 @@ use App\Http\Requests\Publisher\CreateExtensionRequest; use App\Http\Requests\Publisher\UpdateExtensionRequest; +use App\Http\Requests\Publisher\UpdatePriceRequest; +use App\Http\Resources\PriceResource; use App\Http\Resources\PublisherExtensionResource; use App\Models\Category; use App\Models\Event; @@ -20,10 +22,11 @@ use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; class ExtensionController extends PublisherController { - public const DETAIL = ['organization', 'category', 'packages', 'price']; + public const DETAIL = ['organization', 'category', 'packages', 'price', 'icon', 'screenshots']; public function __construct(protected PackageNamespaces $namespaces) {} @@ -32,8 +35,9 @@ public function __construct(protected PackageNamespaces $namespaces) {} */ public function index(Request $request): JsonResponse { + // Releases give My extensions its live version and the latest release's status. $extensions = Extension::query() - ->with(self::DETAIL) + ->with([...self::DETAIL, 'releases']) ->whereIn('organization_uuid', $this->publishableOrganizations($request)) ->orderBy('name') ->get(); @@ -141,6 +145,38 @@ public function update(UpdateExtensionRequest $request, string $slug): Publisher 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, string $slug): PublisherExtensionResource + { + $extension = $this->findExtension($request, $slug, ['organization', 'price']); + $model = $request->input('model'); + + if ($model !== Price::MODEL_FREE && blank($extension->organization?->stripe_connect_id)) { + throw new UnprocessableEntityHttpException('Paid pricing opens when Stripe Connect payouts ship (Phase 4).'); + } + + $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')]; + + $before = $extension->price ? (new PriceResource($extension->price))->resolve($request) : null; + $price = $extension->price ?? $extension->price()->make(); + $price->fill($attributes)->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)); + } + /** * The one listing that owns any of the names, or null. Names spread over two listings * answer 409. 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 index 245346b..b774004 100644 --- a/app/Http/Controllers/Publisher/PublisherController.php +++ b/app/Http/Controllers/Publisher/PublisherController.php @@ -107,11 +107,14 @@ protected function canPublishFor(Request $request, string $organizationUuid): bo 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(['extension.organization', 'engineTarball', 'apiTarball', 'engineDist', 'checks', 'reviews.reviewer']); + return $release->load(array_merge(self::RELEASE_LISTING, ['engineTarball', 'apiTarball', 'engineDist', 'checks', 'reviews.reviewer'])); } } diff --git a/app/Http/Controllers/WebsiteController.php b/app/Http/Controllers/WebsiteController.php new file mode 100644 index 0000000..56010a7 --- /dev/null +++ b/app/Http/Controllers/WebsiteController.php @@ -0,0 +1,46 @@ +isMethod('GET') && ! $request->isMethod('HEAD')) { + throw new NotFoundHttpException; + } + + if ($request->is(...self::API_PATHS)) { + throw new NotFoundHttpException; + } + + // A built asset the web server did not serve itself (php artisan serve does). + $public = realpath(public_path()); + $file = realpath(public_path(ltrim($request->path(), '/'))); + if ($request->path() !== '/' && $file !== false && $public !== false && str_starts_with($file, $public.DIRECTORY_SEPARATOR) && is_file($file) && basename($file) !== 'index.php') { + return response()->file($file); + } + + $index = public_path('index.html'); + if (! is_file($index)) { + return response()->error('The website has not been built. Run npm run build in web/.', 503); + } + + return response()->file($index, [ + 'Content-Type' => 'text/html; charset=UTF-8', + 'Cache-Control' => 'no-cache', + ]); + } +} 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/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 index 1f29904..2273223 100644 --- a/app/Http/Requests/Admin/ReviewDecisionRequest.php +++ b/app/Http/Requests/Admin/ReviewDecisionRequest.php @@ -3,10 +3,11 @@ namespace App\Http\Requests\Admin; use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; /** * Approve or reject. `items` are the review requirement results shown to the developer: - * `[{code, passed, note}]` (codes such as R1–R6). Reject needs a reason and at least one item. + * `[{code, passed, note}]`, codes from `registry.review.items` (R1–R6). Reject needs a reason and at least one item. */ class ReviewDecisionRequest extends FormRequest { @@ -23,7 +24,7 @@ public function rules(): array 'reason' => [$rejecting ? 'required' : 'nullable', 'string', 'min:3', 'max:5000'], 'items' => [$rejecting ? 'required' : 'nullable', 'array', $rejecting ? 'min:1' : 'min:0', 'max:50'], 'items.*' => ['array:code,passed,note'], - 'items.*.code' => ['required', 'string', 'max:32'], + 'items.*.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'], 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/ListingRules.php b/app/Http/Requests/Publisher/ListingRules.php index b3f642d..c0a0b13 100644 --- a/app/Http/Requests/Publisher/ListingRules.php +++ b/app/Http/Requests/Publisher/ListingRules.php @@ -15,7 +15,7 @@ protected function listingRules(): array return [ 'subtitle' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string', 'max:20000'], - 'promotional_text' => ['nullable', 'string', 'max:1000'], + 'promotional_text' => ['nullable', 'string', 'max:20000'], 'category' => ['nullable', 'string', 'max:191'], 'tags' => ['nullable', 'array', 'max:20'], 'tags.*' => ['string', 'min:1', 'max:64'], diff --git a/app/Http/Requests/Publisher/UpdatePriceRequest.php b/app/Http/Requests/Publisher/UpdatePriceRequest.php new file mode 100644 index 0000000..3d8edb5 --- /dev/null +++ b/app/Http/Requests/Publisher/UpdatePriceRequest.php @@ -0,0 +1,37 @@ +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}$/'], + ]; + } +} diff --git a/app/Http/Resources/AccountResource.php b/app/Http/Resources/AccountResource.php index c2114e7..df25748 100644 --- a/app/Http/Resources/AccountResource.php +++ b/app/Http/Resources/AccountResource.php @@ -19,12 +19,17 @@ public function toArray($request): array '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, diff --git a/app/Http/Resources/ExtensionResource.php b/app/Http/Resources/ExtensionResource.php index 4b59394..5f0e75e 100644 --- a/app/Http/Resources/ExtensionResource.php +++ b/app/Http/Resources/ExtensionResource.php @@ -2,6 +2,8 @@ namespace App\Http\Resources; +use App\Models\Extension; + /** * Full listing: everything in the summary plus description, links, screenshots and versions. */ @@ -19,12 +21,7 @@ public function toArray($request): array 'privacy_url' => $this->privacy_url, 'tos_url' => $this->tos_url, 'copyright' => $this->copyright, - 'screenshots' => $this->whenLoaded('screenshots', fn () => $this->screenshots->map(fn ($file) => [ - 'id' => $file->public_id, - 'url' => $file->url, - 'name' => $file->name, - 'mime' => $file->mime, - ])->all()), + '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, @@ -35,4 +32,20 @@ public function toArray($request): array '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/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 index e84fbb4..b8b9c68 100644 --- a/app/Http/Resources/OrganizationResource.php +++ b/app/Http/Resources/OrganizationResource.php @@ -17,8 +17,12 @@ public function toArray($request): array '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/PublisherExtensionResource.php b/app/Http/Resources/PublisherExtensionResource.php index fa90be3..f402a11 100644 --- a/app/Http/Resources/PublisherExtensionResource.php +++ b/app/Http/Resources/PublisherExtensionResource.php @@ -35,6 +35,8 @@ public function toArray($request): array '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], diff --git a/app/Http/Resources/ReleaseDetailResource.php b/app/Http/Resources/ReleaseDetailResource.php index 308f864..b2849b7 100644 --- a/app/Http/Resources/ReleaseDetailResource.php +++ b/app/Http/Resources/ReleaseDetailResource.php @@ -8,7 +8,9 @@ /** * A release as its publisher and the review console see it: files, checks, submission state, - * the D6 policy outcome and the review timeline. + * the D6 policy outcome, the review timeline and, under `extension`, the listing as the + * catalog would show it (the staff listing preview; fields whose relations were not loaded + * are left out). */ class ReleaseDetailResource extends FleetbaseResource { @@ -27,15 +29,7 @@ public function toArray($request): array return [ 'id' => $this->when(Http::isInternalRequest(), $this->id, $this->public_id), - 'extension' => $this->whenLoaded('extension', fn () => [ - 'id' => $this->extension->public_id, - 'slug' => $this->extension->slug, - 'name' => $this->extension->name, - 'status' => $this->extension->status, - 'organization' => $this->extension->relationLoaded('organization') && $this->extension->organization - ? ['id' => $this->extension->organization->public_id, 'slug' => $this->extension->organization->slug, 'name' => $this->extension->organization->name] - : null, - ]), + 'extension' => $this->whenLoaded('extension', fn () => $this->extensionPreview($request)), 'version' => $this->version, 'channel' => $this->channel, 'status' => $this->status, @@ -68,4 +62,48 @@ public function toArray($request): array '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/Models/Extension.php b/app/Models/Extension.php index a678b1b..c4623ab 100644 --- a/app/Models/Extension.php +++ b/app/Models/Extension.php @@ -7,6 +7,7 @@ use App\Traits\HasUuid; use Database\Factories\ExtensionFactory; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -105,6 +106,23 @@ public function screenshots(): MorphMany ->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'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 91f3a6d..e575ca7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Auth\SessionGuard; use App\Auth\TokenGuard; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Database\Eloquent\Model; @@ -42,12 +43,14 @@ public function boot(): void /** * `auth:token` resolves personal tokens (flbp_…) to accounts; - * `auth:instance` resolves instance tokens (flbi_…) to instances. + * `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); } /** diff --git a/app/Releases/ReleasePipeline.php b/app/Releases/ReleasePipeline.php index 3eeb3bc..c348a63 100644 --- a/app/Releases/ReleasePipeline.php +++ b/app/Releases/ReleasePipeline.php @@ -405,6 +405,37 @@ public function suspend(Extension $extension, Account $staff, string $reason): E return $extension; } + /** + * Lift a suspension: back to the status `suspend` recorded, else `published` when the + * listing has a published release, else `draft`. The suspension stays in the audit log. + */ + public function unsuspend(Extension $extension, Account $staff, ?string $reason = null): Extension + { + if ($extension->status !== Extension::STATUS_SUSPENDED) { + throw new ConflictHttpException('The extension is not suspended.'); + } + + $meta = $extension->meta ?? []; + $suspension = $meta['suspension'] ?? []; + unset($meta['suspension']); + + $previous = $suspension['previous_status'] ?? null; + if (! in_array($previous, Extension::STATUSES, true) || $previous === Extension::STATUS_SUSPENDED) { + $previous = $extension->publishedReleases()->exists() ? Extension::STATUS_PUBLISHED : Extension::STATUS_DRAFT; + } + + $extension->forceFill([ + 'status' => $previous, + 'meta' => $meta ?: null, + 'published_at' => $previous === Extension::STATUS_PUBLISHED ? ($extension->published_at ?? now()) : $extension->published_at, + ])->save(); + + Event::record('extension.unsuspended', $extension, $staff, ['reason' => $reason, 'status' => $previous, 'suspension' => $suspension]); + CatalogCache::flush(); + + return $extension; + } + // --------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------- diff --git a/app/Support/Tarball.php b/app/Support/Tarball.php index 71870a6..04fb2d2 100644 --- a/app/Support/Tarball.php +++ b/app/Support/Tarball.php @@ -3,7 +3,8 @@ namespace App\Support; /** - * Minimal streaming reader for tar and tar.gz archives (ustar, GNU long names, pax paths). + * Minimal streaming reader for tar and tar.gz archives (ustar, GNU long names, pax paths), + * and a writer for small generated archives (`registry:seed-website`). * * Used to pull `package.json` / `composer.json` out of uploaded package archives without * extracting them to disk or going through PharData. Entries are never written anywhere, @@ -124,6 +125,33 @@ function (string $name, string $contents) use (&$candidates) { return is_array($decoded) ? $decoded : null; } + /** + * Write a ustar archive of regular files (`path => contents`, paths up to 100 bytes), + * gzipped unless `$gzip` is false, and return its bytes. + */ + public static function create(array $files, bool $gzip = true): string + { + $tar = ''; + foreach ($files as $name => $contents) { + if (strlen($name) > 100) { + throw new \InvalidArgumentException("Entry name [{$name}] is longer than 100 bytes."); + } + + $header = str_pad($name, 100, "\0") + .str_pad('0000644', 8, "\0").str_pad('0000000', 8, "\0").str_pad('0000000', 8, "\0") + .str_pad(decoct(strlen($contents)), 11, '0', STR_PAD_LEFT)."\0" + .str_pad(decoct(0), 11, '0', STR_PAD_LEFT)."\0" + .' '.'0'.str_repeat("\0", 100)."ustar\0".'00'.str_repeat("\0", 32 + 32 + 8 + 8 + 155 + 12); + $checksum = array_sum(array_map('ord', str_split($header))); + $header = substr_replace($header, str_pad(decoct($checksum), 6, '0', STR_PAD_LEFT)."\0 ", 148, 8); + + $tar .= $header.$contents.str_repeat("\0", (self::BLOCK - strlen($contents) % self::BLOCK) % self::BLOCK); + } + $tar .= str_repeat("\0", self::BLOCK * 2); + + return $gzip ? gzencode($tar, 9) : $tar; + } + /** * Entry names in archive order (for tests and diagnostics). */ diff --git a/bootstrap/app.php b/bootstrap/app.php index b0adc24..27ceb8b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,7 +3,9 @@ use App\Http\Middleware\EnsurePackageLicence; use App\Http\Middleware\EnsureStaff; use App\Http\Middleware\EnsureTokenScope; +use App\Http\Middleware\StatefulSession; use Illuminate\Auth\AuthenticationException; +use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; @@ -27,7 +29,11 @@ 'scope' => EnsureTokenScope::class, 'licence' => EnsurePackageLicence::class, 'staff' => EnsureStaff::class, + 'stateful' => StatefulSession::class, ]); + + // The website's cookie session must start before `auth:token,web` resolves the caller. + $middleware->prependToPriorityList(EncryptCookies::class, StatefulSession::class); }) ->withExceptions(function (Exceptions $exceptions): void { $isPackage = fn (Request $request) => $request->is('npm', 'npm/*', 'composer', 'composer/*'); diff --git a/config/auth.php b/config/auth.php index bc54bb5..de7693e 100644 --- a/config/auth.php +++ b/config/auth.php @@ -10,9 +10,11 @@ | Authentication Defaults |-------------------------------------------------------------------------- | - | Every API request authenticates with a bearer token. The `token` guard - | resolves personal tokens (flbp_) and the `instance` guard resolves - | instance tokens (flbi_), both through App\Auth\TokenGuard. + | API clients authenticate with a bearer token. The `token` guard resolves + | personal tokens (flbp_) and the `instance` guard resolves instance tokens + | (flbi_), both through App\Auth\TokenGuard. The `web` guard resolves the + | publisher website's cookie session to its `flbs_` session token through + | App\Auth\SessionGuard (see App\Http\Middleware\StatefulSession). | */ @@ -30,6 +32,10 @@ 'driver' => 'registry-instance-token', 'provider' => 'instances', ], + 'web' => [ + 'driver' => 'registry-session', + 'provider' => 'accounts', + ], ], 'providers' => [ diff --git a/config/registry.php b/config/registry.php index 3a41dd1..af80df2 100644 --- a/config/registry.php +++ b/config/registry.php @@ -54,6 +54,19 @@ 'checks_queue' => env('REGISTRY_CHECKS_QUEUE'), ], + 'review' => [ + // Review requirements staff mark on approve and reject (`items: [{code, passed, note}]`); + // `items.*.code` must be one of these codes. Served by GET /v1/admin/review-items. + 'items' => [ + ['code' => 'R1', 'label' => 'Manifest is valid'], + ['code' => 'R2', 'label' => 'Installs on a clean instance'], + ['code' => 'R3', 'label' => 'Listing is complete'], + ['code' => 'R4', 'label' => 'No secrets or credentials'], + ['code' => 'R5', 'label' => 'Permissions go through the auth schema'], + ['code' => 'R6', 'label' => 'Pricing matches Stripe Connect'], + ], + ], + 'import' => [ // Disk holding the cloud `files` objects (bundles, icons) that `registry:import` copies. 'source_disk' => env('REGISTRY_IMPORT_SOURCE_DISK', 'import_source'), diff --git a/database/migrations/2026_10_22_000005_create_sessions_table.php b/database/migrations/2026_10_22_000005_create_sessions_table.php new file mode 100644 index 0000000..b03ac32 --- /dev/null +++ b/database/migrations/2026_10_22_000005_create_sessions_table.php @@ -0,0 +1,29 @@ +string('id')->primary(); + $table->string('user_id', 64)->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/2026_10_22_000006_widen_extension_listing_text.php b/database/migrations/2026_10_22_000006_widen_extension_listing_text.php new file mode 100644 index 0000000..f152ac7 --- /dev/null +++ b/database/migrations/2026_10_22_000006_widen_extension_listing_text.php @@ -0,0 +1,28 @@ +mediumText('description')->nullable()->change(); + $table->mediumText('promotional_text')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('extensions', function (Blueprint $table) { + $table->text('description')->nullable()->change(); + $table->text('promotional_text')->nullable()->change(); + }); + } +}; diff --git a/phpunit.xml b/phpunit.xml index e7f0a48..c1e45d6 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -19,6 +19,8 @@ + + diff --git a/routes/api.php b/routes/api.php index f09c7a0..fdaf2d6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,12 +5,15 @@ use App\Http\Controllers\Account\TokenController; use App\Http\Controllers\Admin\ReviewController; use App\Http\Controllers\Auth\AuthController; +use App\Http\Controllers\Auth\SessionController; use App\Http\Controllers\Catalog\CatalogController; use App\Http\Controllers\Instance\InstallController; use App\Http\Controllers\Instance\InstanceController; +use App\Http\Controllers\Organization\OrganizationController; use App\Http\Controllers\Packages\ComposerController; use App\Http\Controllers\Packages\NpmController; use App\Http\Controllers\Publisher\ExtensionController as PublisherExtensionController; +use App\Http\Controllers\Publisher\ListingMediaController; use App\Http\Controllers\Publisher\ReleaseController as PublisherReleaseController; use Illuminate\Support\Facades\Route; @@ -20,20 +23,30 @@ |-------------------------------------------------------------------------- | | Public catalog: no auth. -| Account routes: Authorization: Bearer flbp_… (personal token). +| Account routes: Authorization: Bearer flbp_… (personal token), or the publisher +| website's cookie session (`web` guard: StatefulSession + CSRF, see auth/session). | Package routes (/npm, /composer) sit outside /v1 because npm and Composer | clients address them as registry roots. | Instance routes: Authorization: Bearer flbi_… (instance token), except | `instances/register`, which takes a registration code in the body. -| Publisher routes: personal token with the `publish` scope and the owner, admin or -| publisher role in the listing's organization. -| Admin routes: personal token with the `review` scope, member of an `is_staff` organization. +| Publisher routes: personal token (or website session) with the `publish` scope and the +| owner, admin or publisher role in the listing's organization. +| Admin routes: personal token (or website session) with the `review` scope, member of an +| `is_staff` organization. | Stripe routes are added in a later workstream. | */ -Route::prefix('v1')->name('v1.')->group(function () { - Route::prefix('catalog')->name('catalog.')->middleware('throttle:catalog')->group(function () { +Route::prefix('v1')->name('v1.')->middleware('stateful')->group(function () { + Route::get('/', fn () => response()->json([ + 'service' => config('app.name'), + 'version' => config('registry.version'), + 'docs' => 'https://docs.fleetbase.io/registry', + 'catalog' => url('/v1/catalog/extensions'), + ]))->withoutMiddleware('stateful')->name('info'); + + // Public and cached: never starts a session. + Route::prefix('catalog')->name('catalog.')->middleware('throttle:catalog')->withoutMiddleware('stateful')->group(function () { Route::get('extensions', [CatalogController::class, 'extensions'])->name('extensions'); Route::get('extensions/{slug}', [CatalogController::class, 'extension'])->name('extension'); Route::get('extensions/{slug}/releases', [CatalogController::class, 'releases'])->name('releases'); @@ -47,21 +60,42 @@ Route::post('verify/resend', [AuthController::class, 'resendVerification'])->name('verify.resend'); Route::post('login', [AuthController::class, 'login'])->name('login'); Route::post('logout', [AuthController::class, 'logout'])->middleware('auth:token')->withoutMiddleware('throttle:auth')->name('logout'); + + // The publisher website's cookie session. Always stateful; POST and DELETE need X-XSRF-TOKEN. + Route::get('session', [SessionController::class, 'show'])->withoutMiddleware('throttle:auth')->middleware('throttle:api')->name('session.show'); + Route::post('session', [SessionController::class, 'store'])->name('session.store'); + Route::delete('session', [SessionController::class, 'destroy'])->withoutMiddleware('throttle:auth')->middleware('throttle:api')->name('session.destroy'); }); - Route::middleware(['auth:token', 'throttle:api'])->group(function () { + Route::middleware(['auth:token,web', 'throttle:api'])->group(function () { Route::get('account', [AccountController::class, 'show'])->name('account'); + Route::patch('account', [AccountController::class, 'update'])->name('account.update'); + Route::post('account/password', [AccountController::class, 'password'])->name('account.password'); Route::get('account/tokens', [TokenController::class, 'index'])->name('account.tokens.index'); Route::post('account/tokens', [TokenController::class, 'store'])->name('account.tokens.store'); Route::delete('account/tokens/{token}', [TokenController::class, 'destroy'])->name('account.tokens.destroy'); Route::post('account/instance-codes', [InstanceCodeController::class, 'store'])->middleware('scope:instance:register')->name('account.instance-codes.store'); + + Route::get('organizations', [OrganizationController::class, 'index'])->name('organizations.index'); + Route::post('organizations', [OrganizationController::class, 'store'])->name('organizations.store'); + Route::get('organizations/{slug}', [OrganizationController::class, 'show'])->name('organizations.show'); + Route::patch('organizations/{slug}', [OrganizationController::class, 'update'])->name('organizations.update'); + Route::post('organizations/{slug}/members', [OrganizationController::class, 'addMember'])->name('organizations.members.store'); + Route::patch('organizations/{slug}/members/{member}', [OrganizationController::class, 'updateMember'])->name('organizations.members.update'); + Route::delete('organizations/{slug}/members/{member}', [OrganizationController::class, 'removeMember'])->name('organizations.members.destroy'); }); - Route::prefix('publisher')->name('publisher.')->middleware(['auth:token', 'throttle:publish', 'scope:publish'])->group(function () { + Route::prefix('publisher')->name('publisher.')->middleware(['auth:token,web', 'throttle:publish', 'scope:publish'])->group(function () { Route::get('extensions', [PublisherExtensionController::class, 'index'])->name('extensions.index'); Route::post('extensions', [PublisherExtensionController::class, 'store'])->name('extensions.store'); Route::get('extensions/{slug}', [PublisherExtensionController::class, 'show'])->name('extensions.show'); Route::patch('extensions/{slug}', [PublisherExtensionController::class, 'update'])->name('extensions.update'); + Route::put('extensions/{slug}/price', [PublisherExtensionController::class, 'price'])->name('extensions.price'); + Route::post('extensions/{slug}/icon', [ListingMediaController::class, 'storeIcon'])->middleware('throttle:upload')->name('extensions.icon.store'); + Route::delete('extensions/{slug}/icon', [ListingMediaController::class, 'destroyIcon'])->name('extensions.icon.destroy'); + Route::post('extensions/{slug}/screenshots', [ListingMediaController::class, 'storeScreenshot'])->middleware('throttle:upload')->name('extensions.screenshots.store'); + Route::patch('extensions/{slug}/screenshots', [ListingMediaController::class, 'reorderScreenshots'])->name('extensions.screenshots.reorder'); + Route::delete('extensions/{slug}/screenshots/{file}', [ListingMediaController::class, 'destroyScreenshot'])->name('extensions.screenshots.destroy'); Route::get('extensions/{slug}/releases', [PublisherReleaseController::class, 'index'])->name('releases.index'); Route::post('extensions/{slug}/releases', [PublisherReleaseController::class, 'store'])->middleware('throttle:upload')->name('releases.store'); Route::get('releases/{release}', [PublisherReleaseController::class, 'show'])->name('releases.show'); @@ -70,17 +104,21 @@ Route::post('releases/{release}/yank', [PublisherReleaseController::class, 'yank'])->name('releases.yank'); }); - Route::prefix('admin')->name('admin.')->middleware(['auth:token', 'throttle:api', 'scope:review', 'staff'])->group(function () { + Route::prefix('admin')->name('admin.')->middleware(['auth:token,web', 'throttle:api', 'scope:review', 'staff'])->group(function () { + Route::get('review-items', [ReviewController::class, 'reviewItems'])->name('review-items'); Route::get('releases', [ReviewController::class, 'queue'])->name('releases.queue'); Route::get('releases/{release}', [ReviewController::class, 'show'])->name('releases.show'); + Route::get('releases/{release}/files/{kind}', [ReviewController::class, 'download'])->where('kind', 'engine|api|dist')->name('releases.files.download'); Route::post('releases/{release}/claim', [ReviewController::class, 'claim'])->name('releases.claim'); Route::post('releases/{release}/approve', [ReviewController::class, 'approve'])->name('releases.approve'); Route::post('releases/{release}/reject', [ReviewController::class, 'reject'])->name('releases.reject'); Route::post('releases/{release}/publish', [ReviewController::class, 'publish'])->name('releases.publish'); Route::post('extensions/{slug}/suspend', [ReviewController::class, 'suspend'])->name('extensions.suspend'); + Route::post('extensions/{slug}/unsuspend', [ReviewController::class, 'unsuspend'])->name('extensions.unsuspend'); }); - Route::prefix('instances')->name('instances.')->group(function () { + // Instances authenticate with flbi_ tokens only: never a session or CSRF. + Route::prefix('instances')->name('instances.')->withoutMiddleware('stateful')->group(function () { Route::post('register', [InstanceController::class, 'register'])->middleware('throttle:instance-register')->name('register'); Route::middleware(['auth:instance', 'throttle:instances'])->group(function () { diff --git a/routes/web.php b/routes/web.php index 2b95af1..0c349d2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,10 +1,26 @@ response()->json([ - 'service' => config('app.name'), - 'version' => config('registry.version'), - 'docs' => 'https://docs.fleetbase.io/registry', - 'catalog' => url('/v1/catalog/extensions'), -])); +/* +|-------------------------------------------------------------------------- +| Publisher website +|-------------------------------------------------------------------------- +| +| Every other GET path serves the Ember app's public/index.html; the app routes on the +| client. The shell needs no session: the app calls GET /v1/auth/session for its cookies. +| /v1, /npm, /composer and /up keep their JSON 404s. The service info moved to GET /v1. +| +*/ + +Route::any('{path?}', WebsiteController::class) + ->where('path', '.*') + ->withoutMiddleware([EncryptCookies::class, AddQueuedCookiesToResponse::class, StartSession::class, ShareErrorsFromSession::class, ValidateCsrfToken::class]) + ->fallback() + ->name('website'); diff --git a/tests/Feature/Auth/AccountProfileTest.php b/tests/Feature/Auth/AccountProfileTest.php new file mode 100644 index 0000000..3416700 --- /dev/null +++ b/tests/Feature/Auth/AccountProfileTest.php @@ -0,0 +1,49 @@ +create(['username' => 'old-name']); + Account::factory()->create(['username' => 'taken']); + actingAsAccount($account); + + $this->patchJson('/v1/account', ['name' => 'New Name', 'username' => 'new-name', 'timezone' => 'Asia/Singapore']) + ->assertOk() + ->assertJsonPath('name', 'New Name') + ->assertJsonPath('username', 'new-name') + ->assertJsonPath('timezone', 'Asia/Singapore'); + + // Keeping one's own username is fine; another account's is not. + $this->patchJson('/v1/account', ['username' => 'new-name'])->assertOk(); + $this->patchJson('/v1/account', ['username' => 'taken'])->assertStatus(422)->assertJsonPath('fields.username.0', 'The username has already been taken.'); + $this->patchJson('/v1/account', ['username' => 'Bad Name'])->assertStatus(422); + $this->patchJson('/v1/account', ['timezone' => 'Mars/Olympus'])->assertStatus(422); + $this->patchJson('/v1/account', ['email' => 'x@example.com'])->assertOk(); + + expect($account->fresh()->email)->not->toBe('x@example.com'); +}); + +it('changes the password with the current one and ends other website sessions', function () { + $account = Account::factory()->create(['password' => 'password']); + $cookies = websiteSignIn($account); + $otherSession = Token::issue($account, 'web session', ['*'], now()->addHour(), null, Token::PREFIX_SESSION); + $currentSession = Token::where('prefix', Token::PREFIX_SESSION)->whereKeyNot($otherSession->getKey())->sole(); + + websiteRequest('POST', '/v1/account/password', $cookies, ['current_password' => 'wrong', 'password' => 'new-pass-123', 'password_confirmation' => 'new-pass-123']) + ->assertStatus(422) + ->assertJsonPath('fields.current_password.0', 'The current password is incorrect.'); + websiteRequest('POST', '/v1/account/password', $cookies, ['current_password' => 'password', 'password' => 'new-pass-123', 'password_confirmation' => 'other']) + ->assertStatus(422); + websiteRequest('POST', '/v1/account/password', $cookies, ['current_password' => 'password', 'password' => 'short1', 'password_confirmation' => 'short1']) + ->assertStatus(422); + + websiteRequest('POST', '/v1/account/password', $cookies, ['current_password' => 'password', 'password' => 'new-pass-123', 'password_confirmation' => 'new-pass-123']) + ->assertOk(); + + expect(Hash::check('new-pass-123', $account->fresh()->password))->toBeTrue() + ->and($otherSession->fresh()->revoked_at)->not->toBeNull() + ->and($currentSession->fresh()->revoked_at)->toBeNull(); + websiteRequest('GET', '/v1/account', $cookies)->assertOk(); +}); diff --git a/tests/Feature/Auth/SessionTest.php b/tests/Feature/Auth/SessionTest.php new file mode 100644 index 0000000..ce127bc --- /dev/null +++ b/tests/Feature/Auth/SessionTest.php @@ -0,0 +1,227 @@ +assertOk() + ->assertExactJson(['authenticated' => false, 'account' => null]); + + $cookies = collect($response->headers->getCookies())->keyBy(fn ($cookie) => $cookie->getName()); + $session = $cookies->get(config('session.cookie')); + + expect($session)->not->toBeNull() + ->and($session->isHttpOnly())->toBeTrue() + ->and($session->getSameSite())->toBe('lax') + ->and($cookies->get('XSRF-TOKEN'))->not->toBeNull() + ->and($cookies->get('XSRF-TOKEN')->isHttpOnly())->toBeFalse(); +}); + +it('signs in with a cookie and binds the session to a session token', function () { + $account = Account::factory()->create(['email' => 'dev@example.com', 'password' => 'password']); + $cookies = cookiesFrom(websiteRequest('GET', '/v1/auth/session', [])); + + $response = websiteRequest('POST', '/v1/auth/session', $cookies, ['email' => 'DEV@example.com', 'password' => 'password']) + ->assertOk() + ->assertJsonPath('account.email', 'dev@example.com') + ->assertJsonPath('account.is_staff', false) + ->assertJsonMissingPath('token'); + + // The session id is regenerated on sign-in. + $signedIn = cookiesFrom($response, $cookies); + expect($signedIn[config('session.cookie')])->not->toBe($cookies[config('session.cookie')]); + + $token = Token::where('prefix', Token::PREFIX_SESSION)->sole(); + expect($token->account_uuid)->toBe($account->uuid) + ->and($token->scopes)->toBe(['*']) + ->and($token->name)->toBe('web session '.now()->toDateString()) + ->and($token->expires_at->between(now()->addMinutes(119), now()->addMinutes(121)))->toBeTrue() + ->and($token->plainTextToken)->toBeNull() + ->and(Event::where('kind', 'account.login')->sole()->payload)->toMatchArray(['via' => 'session', 'token' => $token->public_id]); + + websiteRequest('GET', '/v1/auth/session', $signedIn) + ->assertOk() + ->assertJsonPath('authenticated', true) + ->assertJsonPath('account.email', 'dev@example.com'); + + websiteRequest('GET', '/v1/account', $signedIn)->assertOk()->assertJsonPath('id', $account->public_id); + expect($token->fresh()->last_used_at)->not->toBeNull(); +}); + +it('answers sign-in errors like login', function () { + Account::factory()->create(['email' => 'dev@example.com', 'password' => 'password']); + Account::factory()->unverified()->create(['email' => 'new@example.com', 'password' => 'password']); + Account::factory()->suspended()->create(['email' => 'gone@example.com', 'password' => 'password']); + $cookies = cookiesFrom(websiteRequest('GET', '/v1/auth/session', [])); + + websiteRequest('POST', '/v1/auth/session', $cookies, ['email' => 'dev@example.com', 'password' => 'nope']) + ->assertStatus(401) + ->assertExactJson(['errors' => ['Invalid email or password.']]); + websiteRequest('POST', '/v1/auth/session', $cookies, ['email' => 'new@example.com', 'password' => 'password']) + ->assertStatus(403) + ->assertJsonPath('code', 'email_unverified'); + websiteRequest('POST', '/v1/auth/session', $cookies, ['email' => 'gone@example.com', 'password' => 'password']) + ->assertStatus(403) + ->assertJsonPath('code', 'account_suspended'); + + expect(Token::count())->toBe(0); +}); + +it('reaches account, publisher and admin routes with the session cookie', function () { + [$publisher, , $listing] = publisher(); + $publisher->forceFill(['password' => 'password'])->save(); + $cookies = websiteSignIn($publisher); + + websiteRequest('GET', '/v1/account/tokens', $cookies)->assertOk(); + websiteRequest('GET', '/v1/publisher/extensions', $cookies)->assertOk()->assertJsonPath('0.slug', $listing->slug); + websiteRequest('PATCH', '/v1/publisher/extensions/'.$listing->slug, $cookies, ['subtitle' => 'From the website']) + ->assertOk() + ->assertJsonPath('subtitle', 'From the website'); + websiteRequest('GET', '/v1/admin/releases', $cookies)->assertForbidden(); + + $staff = staffAccount(); + $staff->forceFill(['password' => 'password'])->save(); + $staffCookies = websiteSignIn($staff); + + websiteRequest('GET', '/v1/auth/session', $staffCookies)->assertJsonPath('account.is_staff', true); + websiteRequest('GET', '/v1/admin/releases', $staffCookies)->assertOk(); + websiteRequest('GET', '/v1/admin/review-items', $staffCookies)->assertOk()->assertJsonPath('0.code', 'R1'); +}); + +it('refuses unsafe session requests without the CSRF header', function () { + $account = Account::factory()->create(['password' => 'password']); + $cookies = websiteSignIn($account); + + websiteRequest('POST', '/v1/account/tokens', $cookies, ['name' => 'cli', 'scopes' => ['read']], csrf: false) + ->assertStatus(419) + ->assertExactJson(['errors' => ['CSRF token mismatch.']]); + websiteRequest('PATCH', '/v1/account', $cookies, ['name' => 'Nope'], csrf: false)->assertStatus(419); + websiteRequest('DELETE', '/v1/auth/session', $cookies, csrf: false)->assertStatus(419); + + // Sign-in itself needs the header too (login CSRF), even before any cookie exists. + websiteRequest('POST', '/v1/auth/session', [], ['email' => $account->email, 'password' => 'password']) + ->assertStatus(419) + ->assertExactJson(['errors' => ['CSRF token mismatch.']]); + + websiteRequest('POST', '/v1/account/tokens', $cookies, ['name' => 'cli', 'scopes' => ['read']])->assertCreated(); + expect($account->tokens()->where('prefix', Token::PREFIX_PERSONAL)->count())->toBe(1); +}); + +it('leaves bearer requests without session, cookies or CSRF', function () { + $account = Account::factory()->create(); + actingAsAccount($account); + + $response = $this->postJson('/v1/account/tokens', ['name' => 'cli', 'scopes' => ['read']])->assertCreated(); + expect($response->headers->getCookies())->toBe([]); + + $response = $this->getJson('/v1/account')->assertOk(); + expect($response->headers->getCookies())->toBe([]); + + // CLI login and registration take no cookies and need no CSRF token. + $this->withHeaders(['Authorization' => ''])->postJson('/v1/auth/login', ['email' => $account->email, 'password' => 'password'])->assertOk(); +}); + +it('ignores the session cookie when a bearer token is sent', function () { + $sessionAccount = Account::factory()->create(['password' => 'password']); + $cookies = websiteSignIn($sessionAccount); + $other = Account::factory()->create(); + $token = Token::issue($other, 'cli'); + + $response = test()->call('GET', '/v1/account', [], $cookies, [], ['HTTP_ACCEPT' => 'application/json', 'HTTP_AUTHORIZATION' => 'Bearer '.$token->plainTextToken]) + ->assertOk() + ->assertJsonPath('id', $other->public_id); + expect($response->headers->getCookies())->toBe([]); + + // A bad bearer token does not fall back to the cookie. + test()->call('GET', '/v1/account', [], $cookies, [], ['HTTP_ACCEPT' => 'application/json', 'HTTP_AUTHORIZATION' => 'Bearer flbp_nope'])->assertUnauthorized(); +}); + +it('signs out by revoking the session token', function () { + $account = Account::factory()->create(['password' => 'password']); + $cookies = websiteSignIn($account); + $token = Token::where('prefix', Token::PREFIX_SESSION)->sole(); + + $response = websiteRequest('DELETE', '/v1/auth/session', $cookies)->assertOk()->assertJsonPath('authenticated', false); + expect($token->fresh()->revoked_at)->not->toBeNull(); + + // The old cookies no longer authenticate; the session was invalidated. + websiteRequest('GET', '/v1/account', $cookies)->assertUnauthorized(); + websiteRequest('GET', '/v1/auth/session', cookiesFrom($response, $cookies))->assertJsonPath('authenticated', false); + + // Signing out without a session still answers 200. + $fresh = cookiesFrom(websiteRequest('GET', '/v1/auth/session', [])); + websiteRequest('DELETE', '/v1/auth/session', $fresh)->assertOk(); +}); + +it('never accepts a session token as a bearer token', function () { + $account = Account::factory()->create(); + $session = Token::issue($account, 'web session', ['*'], now()->addHour(), null, Token::PREFIX_SESSION); + + $this->withHeader('Authorization', 'Bearer '.$session->plainTextToken)->getJson('/v1/account')->assertUnauthorized(); +}); + +it('stops a suspended account\'s session', function () { + $account = Account::factory()->create(['password' => 'password']); + $cookies = websiteSignIn($account); + websiteRequest('GET', '/v1/account', $cookies)->assertOk(); + + $account->forceFill(['status' => Account::STATUS_SUSPENDED])->save(); + + websiteRequest('GET', '/v1/account', $cookies)->assertUnauthorized(); + websiteRequest('GET', '/v1/auth/session', $cookies)->assertJsonPath('authenticated', false); +}); + +it('slides the session token expiry once half the lifetime is used', function () { + $account = Account::factory()->create(['password' => 'password']); + $cookies = websiteSignIn($account); + $token = Token::where('prefix', Token::PREFIX_SESSION)->sole(); + + $token->forceFill(['expires_at' => now()->addMinutes(90)])->save(); + websiteRequest('GET', '/v1/account', $cookies)->assertOk(); + expect($token->fresh()->expires_at->between(now()->addMinutes(89), now()->addMinutes(91)))->toBeTrue(); + + $token->forceFill(['expires_at' => now()->addMinutes(10)])->save(); + websiteRequest('GET', '/v1/account', $cookies)->assertOk(); + expect($token->fresh()->expires_at->between(now()->addMinutes(119), now()->addMinutes(121)))->toBeTrue(); + + $token->forceFill(['expires_at' => now()->subMinute()])->save(); + websiteRequest('GET', '/v1/account', $cookies)->assertUnauthorized(); +}); + +it('keeps session tokens out of the token list', function () { + $account = Account::factory()->create(['password' => 'password']); + $cookies = websiteSignIn($account); + $personal = Token::issue($account, 'cli'); + $session = Token::where('prefix', Token::PREFIX_SESSION)->sole(); + + websiteRequest('GET', '/v1/account/tokens', $cookies) + ->assertOk() + ->assertJsonCount(1) + ->assertJsonPath('0.id', $personal->public_id); + + websiteRequest('DELETE', '/v1/account/tokens/'.$session->public_id, $cookies)->assertNotFound(); +}); + +it('keeps logout token-only', function () { + $account = Account::factory()->create(['password' => 'password']); + $cookies = websiteSignIn($account); + + websiteRequest('POST', '/v1/auth/logout', $cookies)->assertUnauthorized(); +}); + +it('reports staff and organization flags on the account', function () { + $account = Account::factory()->create(); + $organization = Organization::factory()->staff()->verified()->create(); + $account->organizations()->attach($organization->uuid, ['role' => Membership::ROLE_MEMBER]); + actingAsAccount($account); + + $this->getJson('/v1/account') + ->assertOk() + ->assertJsonPath('is_staff', true) + ->assertJsonPath('organizations.0.is_staff', true) + ->assertJsonPath('organizations.0.verified_at', $organization->verified_at->toJSON()); +}); diff --git a/tests/Feature/Organizations/OrganizationTest.php b/tests/Feature/Organizations/OrganizationTest.php new file mode 100644 index 0000000..8e02249 --- /dev/null +++ b/tests/Feature/Organizations/OrganizationTest.php @@ -0,0 +1,217 @@ +} + */ +function team(array $roles = [Membership::ROLE_ADMIN, Membership::ROLE_PUBLISHER, Membership::ROLE_MEMBER]): array +{ + $organization = Organization::factory()->create(['slug' => 'acme']); + $accounts = ['owner' => Account::factory()->create(['name' => 'Olivia Owner'])]; + foreach ($roles as $role) { + $accounts[$role] = Account::factory()->create(); + } + foreach ($accounts as $role => $account) { + $account->organizations()->attach($organization->uuid, ['role' => $role]); + } + + return [$organization, $accounts]; +} + +it('lists the caller\'s organizations with role and member count', function () { + [$organization, $accounts] = team(); + $personal = Organization::factory()->personal()->create(); + $accounts['admin']->organizations()->attach($personal->uuid, ['role' => Membership::ROLE_OWNER]); + Organization::factory()->create(); + actingAsAccount($accounts['admin']); + + $this->getJson('/v1/organizations') + ->assertOk() + ->assertJsonCount(2) + ->assertJsonPath('0.slug', $personal->slug) + ->assertJsonPath('0.role', 'owner') + ->assertJsonPath('0.members_count', 1) + ->assertJsonPath('1.slug', 'acme') + ->assertJsonPath('1.role', 'admin') + ->assertJsonPath('1.members_count', 4); +}); + +it('creates an organization with the caller as owner', function () { + $account = Account::factory()->create(); + Organization::factory()->create(['slug' => 'taken']); + actingAsAccount($account); + + $this->postJson('/v1/organizations', ['name' => 'Globex Corp', 'website' => 'https://globex.example.com', 'bio' => 'Hi']) + ->assertCreated() + ->assertJsonPath('slug', 'globex-corp') + ->assertJsonPath('role', 'owner') + ->assertJsonPath('members.0.id', $account->public_id) + ->assertJsonPath('members.0.role', 'owner') + ->assertJsonPath('is_personal', false); + + $this->postJson('/v1/organizations', ['name' => 'Initech', 'slug' => 'initech'])->assertCreated()->assertJsonPath('slug', 'initech'); + $this->postJson('/v1/organizations', ['name' => 'X', 'slug' => 'taken'])->assertStatus(422); + $this->postJson('/v1/organizations', ['name' => 'Fleetbase', 'slug' => 'fleetbase'])->assertStatus(422)->assertJsonPath('fields.slug.0', 'That slug is reserved.'); + $this->postJson('/v1/organizations', ['name' => 'Bad', 'slug' => 'Bad_Slug'])->assertStatus(422); + $this->postJson('/v1/organizations', ['name' => 'Fleetbase'])->assertCreated()->assertJsonPath('slug', fn ($slug) => str_starts_with($slug, 'fleetbase-')); + + expect($account->roleIn(Organization::where('slug', 'initech')->sole()))->toBe('owner') + ->and(Event::where('kind', 'organization.created')->count())->toBe(3); +}); + +it('shows an organization with its members to members only', function () { + [$organization, $accounts] = team(); + actingAsAccount($accounts['member']); + + $this->getJson('/v1/organizations/acme') + ->assertOk() + ->assertJsonPath('id', $organization->public_id) + ->assertJsonPath('role', 'member') + ->assertJsonPath('members_count', 4) + ->assertJsonPath('members.0.role', 'owner') + ->assertJsonPath('members.0.name', 'Olivia Owner') + ->assertJsonPath('members.0.email', $accounts['owner']->email) + ->assertJsonPath('members.0.username', $accounts['owner']->username) + ->assertJsonStructure(['members' => [['id', 'name', 'username', 'email', 'role', 'joined_at']]]); + $this->getJson('/v1/organizations/'.$organization->public_id)->assertOk(); + + actingAsAccount(Account::factory()->create()); + $this->getJson('/v1/organizations/acme')->assertNotFound(); +}); + +it('lets owners and admins edit the profile but never the slug', function () { + [, $accounts] = team(); + + actingAsAccount($accounts['publisher']); + $this->patchJson('/v1/organizations/acme', ['name' => 'Nope'])->assertForbidden(); + + actingAsAccount($accounts['admin']); + $this->patchJson('/v1/organizations/acme', ['name' => 'Acme Inc', 'bio' => 'We make anvils.', 'website' => 'https://acme.example.com', 'avatar_url' => 'https://cdn.example.com/a.png']) + ->assertOk() + ->assertJsonPath('name', 'Acme Inc') + ->assertJsonPath('bio', 'We make anvils.') + ->assertJsonPath('avatar_url', 'https://cdn.example.com/a.png'); + $this->patchJson('/v1/organizations/acme', ['slug' => 'other'])->assertStatus(422)->assertJsonPath('fields.slug.0', 'An organization\'s slug cannot be changed.'); + $this->patchJson('/v1/organizations/acme', ['website' => 'javascript:alert(1)'])->assertStatus(422); + + // Personal organizations can be edited by their owner. + $personal = Organization::factory()->personal()->create(); + $accounts['admin']->organizations()->attach($personal->uuid, ['role' => Membership::ROLE_OWNER]); + $this->patchJson('/v1/organizations/'.$personal->slug, ['name' => 'Me'])->assertOk()->assertJsonPath('name', 'Me'); +}); + +it('adds existing accounts as members', function () { + [$organization, $accounts] = team(); + $newcomer = Account::factory()->create(['email' => 'new@example.com']); + + actingAsAccount($accounts['publisher']); + $this->postJson('/v1/organizations/acme/members', ['email' => 'new@example.com', 'role' => 'member'])->assertForbidden(); + + actingAsAccount($accounts['admin']); + $this->postJson('/v1/organizations/acme/members', ['email' => 'nobody@example.com', 'role' => 'member']) + ->assertStatus(422) + ->assertExactJson(['errors' => ['No registry account uses that email. Ask them to register first.']]); + $this->postJson('/v1/organizations/acme/members', ['email' => 'NEW@example.com', 'role' => 'owner']) + ->assertForbidden() + ->assertJsonPath('errors.0', 'Only owners can grant or change the owner role.'); + $this->postJson('/v1/organizations/acme/members', ['email' => 'new@example.com', 'role' => 'wizard'])->assertStatus(422); + + $this->postJson('/v1/organizations/acme/members', ['email' => 'NEW@example.com', 'role' => 'publisher']) + ->assertCreated() + ->assertJsonPath('id', $newcomer->public_id) + ->assertJsonPath('role', 'publisher'); + $this->postJson('/v1/organizations/acme/members', ['email' => 'new@example.com', 'role' => 'member'])->assertStatus(409); + + expect($newcomer->roleIn($organization))->toBe('publisher') + ->and(Event::where('kind', 'organization.member_added')->sole()->payload)->toBe(['account' => $newcomer->public_id, 'role' => 'publisher']); + + // Personal organizations take no members. + $personal = Organization::factory()->personal()->create(); + $accounts['admin']->organizations()->attach($personal->uuid, ['role' => Membership::ROLE_OWNER]); + $this->postJson('/v1/organizations/'.$personal->slug.'/members', ['email' => 'new@example.com', 'role' => 'member'])->assertStatus(422); +}); + +it('changes roles, with the owner role reserved to owners', function () { + [$organization, $accounts] = team(); + + actingAsAccount($accounts['admin']); + $this->patchJson('/v1/organizations/acme/members/'.$accounts['member']->public_id, ['role' => 'publisher']) + ->assertOk() + ->assertJsonPath('role', 'publisher'); + $this->patchJson('/v1/organizations/acme/members/'.$accounts['member']->public_id, ['role' => 'owner'])->assertForbidden(); + $this->patchJson('/v1/organizations/acme/members/'.$accounts['owner']->public_id, ['role' => 'member'])->assertForbidden(); + $this->patchJson('/v1/organizations/acme/members/acct_missing', ['role' => 'member'])->assertNotFound(); + + actingAsAccount($accounts['owner']); + $this->patchJson('/v1/organizations/acme/members/'.$accounts['admin']->username, ['role' => 'owner'])->assertOk()->assertJsonPath('role', 'owner'); + + expect($accounts['member']->roleIn($organization))->toBe('publisher') + ->and(Event::where('kind', 'organization.member_role_changed')->count())->toBe(2); +}); + +it('never leaves an organization without an owner', function () { + [$organization, $accounts] = team([Membership::ROLE_ADMIN]); + + actingAsAccount($accounts['owner']); + $this->patchJson('/v1/organizations/acme/members/'.$accounts['owner']->public_id, ['role' => 'admin']) + ->assertStatus(422) + ->assertExactJson(['errors' => ['The last owner cannot be demoted. Make another member an owner first.']]); + $this->deleteJson('/v1/organizations/acme/members/'.$accounts['owner']->public_id) + ->assertStatus(422) + ->assertExactJson(['errors' => ['The last owner cannot be removed. Make another member an owner first.']]); + + // With a second owner, the first may step down. + $this->patchJson('/v1/organizations/acme/members/'.$accounts['admin']->public_id, ['role' => 'owner'])->assertOk(); + $this->patchJson('/v1/organizations/acme/members/'.$accounts['owner']->public_id, ['role' => 'admin'])->assertOk(); + + expect(Membership::where('organization_uuid', $organization->uuid)->where('role', 'owner')->pluck('account_uuid')->all())->toBe([$accounts['admin']->uuid]); +}); + +it('removes members: managers remove others, anyone can leave', function () { + [$organization, $accounts] = team([Membership::ROLE_ADMIN, Membership::ROLE_PUBLISHER, Membership::ROLE_MEMBER, Membership::ROLE_BILLING]); + + actingAsAccount($accounts['publisher']); + $this->deleteJson('/v1/organizations/acme/members/'.$accounts['member']->public_id)->assertForbidden(); + $this->deleteJson('/v1/organizations/acme/members/'.$accounts['publisher']->public_id)->assertOk()->assertJsonPath('removed', true); + + actingAsAccount($accounts['admin']); + $this->deleteJson('/v1/organizations/acme/members/'.$accounts['owner']->public_id)->assertForbidden(); + $this->deleteJson('/v1/organizations/acme/members/'.$accounts['member']->public_id)->assertOk(); + + expect($organization->memberships()->pluck('role')->sort()->values()->all())->toBe(['admin', 'billing', 'owner']) + ->and(Event::where('kind', 'organization.member_left')->count())->toBe(1) + ->and(Event::where('kind', 'organization.member_removed')->count())->toBe(1); + + // A removed member no longer sees the organization. + actingAsAccount($accounts['member']); + $this->getJson('/v1/organizations/acme')->assertNotFound(); +}); + +it('keeps tokens pinned to an organization inside it', function () { + [, $accounts] = team(); + $other = Organization::factory()->create(); + $accounts['owner']->organizations()->attach($other->uuid, ['role' => Membership::ROLE_OWNER]); + $token = Token::issue($accounts['owner'], 'pinned', ['*'], null, $other->uuid); + $this->withHeader('Authorization', 'Bearer '.$token->plainTextToken); + + $this->getJson('/v1/organizations')->assertOk()->assertJsonCount(1)->assertJsonPath('0.slug', $other->slug); + $this->getJson('/v1/organizations/acme')->assertForbidden(); + $this->postJson('/v1/organizations', ['name' => 'New'])->assertForbidden(); +}); + +it('works over the website session', function () { + [, $accounts] = team(); + $accounts['owner']->forceFill(['password' => 'password'])->save(); + $cookies = websiteSignIn($accounts['owner']); + + websiteRequest('GET', '/v1/organizations', $cookies)->assertOk()->assertJsonPath('0.slug', 'acme'); + websiteRequest('PATCH', '/v1/organizations/acme', $cookies, ['bio' => 'Updated'], csrf: false)->assertStatus(419); + websiteRequest('PATCH', '/v1/organizations/acme', $cookies, ['bio' => 'Updated'])->assertOk()->assertJsonPath('bio', 'Updated'); +}); diff --git a/tests/Feature/Releases/AdminExtrasTest.php b/tests/Feature/Releases/AdminExtrasTest.php new file mode 100644 index 0000000..193bd36 --- /dev/null +++ b/tests/Feature/Releases/AdminExtrasTest.php @@ -0,0 +1,131 @@ + '1'])->assertCreated()->json('id'); + actingAsReviewer(); + + $this->getJson('/v1/admin/review-items') + ->assertOk() + ->assertExactJson([ + ['code' => 'R1', 'label' => 'Manifest is valid'], + ['code' => 'R2', 'label' => 'Installs on a clean instance'], + ['code' => 'R3', 'label' => 'Listing is complete'], + ['code' => 'R4', 'label' => 'No secrets or credentials'], + ['code' => 'R5', 'label' => 'Permissions go through the auth schema'], + ['code' => 'R6', 'label' => 'Pricing matches Stripe Connect'], + ]); + + $this->postJson('/v1/admin/releases/'.$id.'/reject', ['reason' => 'Needs work', 'items' => [['code' => 'R9', 'passed' => false]]]) + ->assertStatus(422) + ->assertJsonStructure(['fields' => ['items.0.code']]); + $this->postJson('/v1/admin/releases/'.$id.'/reject', ['reason' => 'Needs work', 'items' => [['code' => 'R6', 'passed' => false]]])->assertOk(); + + actingAsAccount($account, [Token::SCOPE_ALL]); + $this->getJson('/v1/admin/review-items')->assertForbidden(); +}); + +it('downloads a release archive for staff and records it', function () { + [$account, , $listing] = publisher(); + $id = uploadRelease($account, $listing, '1.0.0')->assertCreated()->json('id'); + $release = Release::findById($id); + $engine = File::find($release->engine_tarball_file_uuid); + actingAsReviewer(); + + $response = $this->get('/v1/admin/releases/'.$id.'/files/engine')->assertOk(); + expect($response->headers->get('Content-Disposition'))->toBe('attachment; filename="'.$engine->name.'"') + ->and($response->streamedContent())->toBe(Storage::disk('packages')->get($engine->path)) + ->and(Event::where('kind', 'release.downloaded')->sole()->payload)->toBe(['kind' => 'engine', 'file' => $engine->public_id]); + + $this->get('/v1/admin/releases/'.$id.'/files/api')->assertOk(); + $this->getJson('/v1/admin/releases/'.$id.'/files/dist')->assertNotFound()->assertExactJson(['errors' => ['Resource not found.']]); + $this->getJson('/v1/admin/releases/'.$id.'/files/manifest')->assertNotFound()->assertExactJson(['errors' => ['Resource not found.']]); + $this->getJson('/v1/admin/releases/rel_missing/files/engine')->assertNotFound(); + + actingAsAccount($account, [Token::SCOPE_ALL]); + $this->getJson('/v1/admin/releases/'.$id.'/files/engine')->assertForbidden(); +}); + +it('unsuspends a listing back to the status it had', function () { + Mail::fake(); + [$account, , $listing] = publisher(); + publishedRelease($account, $listing, '1.0.0'); + [, , $draft] = publisher(); + actingAsReviewer(); + + $this->postJson('/v1/admin/extensions/'.$listing->slug.'/unsuspend')->assertStatus(409); + + $this->postJson('/v1/admin/extensions/'.$listing->slug.'/suspend', ['reason' => 'Malware report'])->assertOk()->assertJsonPath('status', 'suspended'); + $this->getJson('/v1/catalog/extensions/'.$listing->slug)->assertNotFound(); + + $this->postJson('/v1/admin/extensions/'.$listing->slug.'/unsuspend', ['reason' => 'False alarm']) + ->assertOk() + ->assertJsonPath('status', Extension::STATUS_PUBLISHED) + ->assertJsonPath('suspension', null); + $this->getJson('/v1/catalog/extensions/'.$listing->slug)->assertOk(); + expect(Event::where('kind', 'extension.unsuspended')->sole()->payload)->toMatchArray(['reason' => 'False alarm', 'status' => 'published']); + + $this->postJson('/v1/admin/extensions/'.$draft->slug.'/suspend', ['reason' => 'Spam'])->assertOk(); + $this->postJson('/v1/admin/extensions/'.$draft->slug.'/unsuspend')->assertOk()->assertJsonPath('status', Extension::STATUS_DRAFT); + + // Suspended without a recorded status: published when it has a published release. + $listing->forceFill(['status' => Extension::STATUS_SUSPENDED, 'meta' => null])->save(); + $this->postJson('/v1/admin/extensions/'.$listing->slug.'/unsuspend')->assertOk()->assertJsonPath('status', Extension::STATUS_PUBLISHED); + $draft->forceFill(['status' => Extension::STATUS_SUSPENDED, 'meta' => null])->save(); + $this->postJson('/v1/admin/extensions/'.$draft->slug.'/unsuspend')->assertOk()->assertJsonPath('status', Extension::STATUS_DRAFT); +}); + +it('shows the listing preview in the review queue and release detail', function () { + Mail::fake(); + [$account, $organization, $listing] = publisher(extension: ['promotional_text' => '

Rich overview

']); + actingAsAccount($account, [Token::SCOPE_PUBLISH]); + test()->post('/v1/publisher/extensions/'.$listing->slug.'/icon', ['icon' => UploadedFile::fake()->image('logo.png')], ['Accept' => 'application/json'])->assertOk(); + test()->post('/v1/publisher/extensions/'.$listing->slug.'/screenshots', ['screenshot' => UploadedFile::fake()->image('a.png'), 'caption' => 'Main'], ['Accept' => 'application/json'])->assertCreated(); + $id = uploadRelease($account, $listing, '1.0.0', [], ['submit' => '1'])->assertCreated()->json('id'); + actingAsReviewer(); + + $this->getJson('/v1/admin/releases') + ->assertOk() + ->assertJsonPath('data.0.extension.subtitle', $listing->subtitle) + ->assertJsonPath('data.0.extension.promotional_text', '

Rich overview

') + ->assertJsonPath('data.0.extension.screenshots.0.caption', 'Main'); + + $this->getJson('/v1/admin/releases/'.$id) + ->assertOk() + ->assertJsonPath('extension.name', $listing->name) + ->assertJsonPath('extension.description', $listing->description) + ->assertJsonPath('extension.icon_url', fn ($url) => str_contains((string) $url, 'icons/')) + ->assertJsonPath('extension.category.slug', $listing->category->slug) + ->assertJsonPath('extension.tags', $listing->tags) + ->assertJsonPath('extension.website_url', $listing->website_url) + ->assertJsonPath('extension.organization.slug', $organization->slug) + ->assertJsonPath('extension.price.model', 'free'); +}); + +it('reaches the staff routes over the website session', function () { + [$account, , $listing] = publisher(); + $id = uploadRelease($account, $listing, '1.0.0', [], ['submit' => '1'])->json('id'); + $staff = staffAccount(); + $staff->forceFill(['password' => 'password'])->save(); + config(['session.driver' => 'database']); + $cookies = websiteSignIn($staff); + + websiteRequest('GET', '/v1/admin/releases/'.$id, $cookies)->assertOk(); + websiteRequest('POST', '/v1/admin/releases/'.$id.'/claim', $cookies, [], csrf: false)->assertStatus(419); + websiteRequest('POST', '/v1/admin/releases/'.$id.'/claim', $cookies)->assertOk()->assertJsonPath('status', Release::STATUS_IN_REVIEW); +}); diff --git a/tests/Feature/Releases/ListingMediaTest.php b/tests/Feature/Releases/ListingMediaTest.php new file mode 100644 index 0000000..4280066 --- /dev/null +++ b/tests/Feature/Releases/ListingMediaTest.php @@ -0,0 +1,145 @@ +post($path, $fields, ['Accept' => 'application/json']); +} + +it('uploads, replaces and removes the listing icon', function () { + [$account, , $listing] = publisher(); + actingAsAccount($account, [Token::SCOPE_PUBLISH]); + $path = '/v1/publisher/extensions/'.$listing->slug.'/icon'; + + $first = mediaUpload($path, ['icon' => UploadedFile::fake()->image('logo.png', 128, 128)]) + ->assertOk() + ->assertJsonPath('icon_url', fn ($url) => is_string($url) && str_contains($url, 'icons/')) + ->json('icon_url'); + + $icon = File::where('type', File::TYPE_ICON)->sole(); + expect($listing->fresh()->icon_file_uuid)->toBe($icon->uuid) + ->and($icon->mime)->toBe('image/png') + ->and($icon->subject_uuid)->toBe($listing->uuid) + ->and(Storage::disk('packages')->exists($icon->path))->toBeTrue(); + + // A new icon replaces the old one and deletes its object. + mediaUpload($path, ['icon' => UploadedFile::fake()->image('logo.webp', 64, 64)])->assertOk()->assertJsonPath('icon_url', fn ($url) => $url !== $first); + expect(File::where('type', File::TYPE_ICON)->count())->toBe(1) + ->and(Storage::disk('packages')->exists($icon->path))->toBeFalse(); + + $this->deleteJson($path)->assertOk()->assertJsonPath('icon_url', null); + expect($listing->fresh()->icon_file_uuid)->toBeNull() + ->and(File::where('type', File::TYPE_ICON)->count())->toBe(0) + ->and(Event::where('kind', 'extension.icon_updated')->count())->toBe(2) + ->and(Event::where('kind', 'extension.icon_removed')->count())->toBe(1); +}); + +it('accepts only small PNG, JPEG and WebP icons', function () { + [$account, , $listing] = publisher(); + actingAsAccount($account, [Token::SCOPE_PUBLISH]); + $path = '/v1/publisher/extensions/'.$listing->slug.'/icon'; + + $svg = fakeUpload('', 'icon.svg'); + mediaUpload($path, ['icon' => $svg])->assertStatus(422)->assertJsonPath('fields.icon.0', 'The icon field must be a file of type: png, jpg, jpeg, webp.'); + mediaUpload($path, ['icon' => fakeUpload('not an image', 'icon.png')])->assertStatus(422); + mediaUpload($path, ['icon' => UploadedFile::fake()->image('big.png')->size(1100)])->assertStatus(422); + mediaUpload($path, [])->assertStatus(422); + + expect(File::count())->toBe(0); +}); + +it('counts an uploaded icon for listing readiness', function () { + [$account, , $listing] = publisher(extension: ['fa_icon' => null]); + expect(app(ListingReadiness::class)->errors($listing))->toContain('The listing needs an icon.'); + + actingAsAccount($account, [Token::SCOPE_PUBLISH]); + mediaUpload('/v1/publisher/extensions/'.$listing->slug.'/icon', ['icon' => UploadedFile::fake()->image('logo.png')]) + ->assertOk() + ->assertJsonPath('readiness.ready', true); +}); + +it('adds, orders and removes screenshots, at most eight', function () { + [$account, , $listing] = publisher(); + actingAsAccount($account, [Token::SCOPE_PUBLISH]); + $path = '/v1/publisher/extensions/'.$listing->slug.'/screenshots'; + + mediaUpload($path, ['screenshot' => UploadedFile::fake()->image('a.png', 800, 600), 'caption' => 'Dashboard']) + ->assertCreated() + ->assertJsonPath('screenshots.0.caption', 'Dashboard') + ->assertJsonPath('screenshots.0.order', 0); + mediaUpload($path, ['screenshot' => UploadedFile::fake()->image('b.jpg', 800, 600)])->assertCreated(); + $ids = mediaUpload($path, ['screenshot' => UploadedFile::fake()->image('c.webp', 800, 600), 'caption' => 'Reports']) + ->assertCreated() + ->json('screenshots.*.id'); + + expect($ids)->toHaveCount(3); + + $this->patchJson($path, ['order' => [$ids[2], $ids[0], $ids[1]]]) + ->assertOk() + ->assertJsonPath('screenshots.*.id', [$ids[2], $ids[0], $ids[1]]) + ->assertJsonPath('screenshots.0.caption', 'Reports') + ->assertJsonPath('screenshots.*.order', [0, 1, 2]); + $this->patchJson($path, ['order' => [$ids[0], $ids[1]]])->assertStatus(422); + $this->patchJson($path, ['order' => [$ids[0], $ids[0], $ids[1]]])->assertStatus(422); + $this->patchJson($path, ['order' => [$ids[0], $ids[1], 'file_other']])->assertStatus(422); + + $this->deleteJson($path.'/'.$ids[0])->assertOk()->assertJsonPath('screenshots.*.id', [$ids[2], $ids[1]]); + $this->deleteJson($path.'/'.$ids[0])->assertNotFound(); + + // A new screenshot goes last. + $last = mediaUpload($path, ['screenshot' => UploadedFile::fake()->image('d.png')])->assertCreated()->json('screenshots.2.id'); + expect($last)->not->toBeIn($ids); + + foreach (range(1, 5) as $i) { + mediaUpload($path, ['screenshot' => UploadedFile::fake()->image("e{$i}.png")])->assertCreated(); + } + mediaUpload($path, ['screenshot' => UploadedFile::fake()->image('f.png')]) + ->assertStatus(422) + ->assertJsonPath('fields.screenshot.0', 'A listing can have at most 8 screenshots. Delete one first.'); + mediaUpload($path, ['screenshot' => UploadedFile::fake()->image('big.png')->size(6000)])->assertStatus(422); + mediaUpload($path, ['screenshot' => fakeUpload('', 's.svg')])->assertStatus(422); +}); + +it('keeps listing media to the listing\'s publishers', function () { + [, , $listing] = publisher(); + [$stranger] = publisher(); + [$member, $organization] = publisher(Membership::ROLE_MEMBER); + $member->organizations()->attach($listing->organization_uuid, ['role' => Membership::ROLE_MEMBER]); + + actingAsAccount($stranger, [Token::SCOPE_PUBLISH]); + mediaUpload('/v1/publisher/extensions/'.$listing->slug.'/icon', ['icon' => UploadedFile::fake()->image('logo.png')])->assertNotFound(); + + actingAsAccount($member, [Token::SCOPE_PUBLISH]); + mediaUpload('/v1/publisher/extensions/'.$listing->slug.'/screenshots', ['screenshot' => UploadedFile::fake()->image('a.png')])->assertNotFound(); + + [$owner, , $own] = publisher(); + actingAsAccount($owner, [Token::SCOPE_READ]); + mediaUpload('/v1/publisher/extensions/'.$own->slug.'/icon', ['icon' => UploadedFile::fake()->image('logo.png')])->assertForbidden(); +}); + +it('shows the icon and ordered screenshots in the public catalog', function () { + [$account, , $listing] = publisher(extension: ['status' => Extension::STATUS_PUBLISHED, 'published_at' => now()]); + actingAsAccount($account, [Token::SCOPE_PUBLISH]); + $base = '/v1/publisher/extensions/'.$listing->slug; + + mediaUpload($base.'/icon', ['icon' => UploadedFile::fake()->image('logo.png')])->assertOk(); + $first = mediaUpload($base.'/screenshots', ['screenshot' => UploadedFile::fake()->image('a.png'), 'caption' => 'First'])->json('screenshots.0.id'); + $second = mediaUpload($base.'/screenshots', ['screenshot' => UploadedFile::fake()->image('b.png'), 'caption' => 'Second'])->json('screenshots.1.id'); + $this->patchJson($base.'/screenshots', ['order' => [$second, $first]])->assertOk(); + + $this->withHeaders(['Authorization' => ''])->getJson('/v1/catalog/extensions/'.$listing->slug) + ->assertOk() + ->assertJsonPath('icon_url', fn ($url) => str_contains((string) $url, 'icons/')) + ->assertJsonPath('screenshots.*.id', [$second, $first]) + ->assertJsonPath('screenshots.0.caption', 'Second') + ->assertJsonPath('screenshots.0.order', 0); + $this->getJson('/v1/catalog/extensions')->assertJsonPath('data.0.icon_url', fn ($url) => str_contains((string) $url, 'icons/')); +}); diff --git a/tests/Feature/Releases/ListingPriceTest.php b/tests/Feature/Releases/ListingPriceTest.php new file mode 100644 index 0000000..c866d09 --- /dev/null +++ b/tests/Feature/Releases/ListingPriceTest.php @@ -0,0 +1,62 @@ +slug.'/price'; + + $this->putJson($path, ['model' => 'one_time', 'amount' => 4900, 'currency' => 'usd']) + ->assertStatus(422) + ->assertExactJson(['errors' => ['Paid pricing opens when Stripe Connect payouts ship (Phase 4).']]); + + $this->putJson($path, ['model' => 'free']) + ->assertOk() + ->assertJsonPath('price.model', 'free') + ->assertJsonPath('price.amount', 0); + + expect($listing->price()->sole()->model)->toBe(Price::MODEL_FREE); +}); + +it('sets a one-time price for an organization with Stripe Connect', function () { + [$account, $organization, $listing] = publisher(); + $organization->forceFill(['stripe_connect_id' => 'acct_123'])->save(); + actingAsAccount($account, [Token::SCOPE_PUBLISH]); + $path = '/v1/publisher/extensions/'.$listing->slug.'/price'; + + $this->putJson($path, ['model' => 'one_time'])->assertStatus(422)->assertJsonStructure(['fields' => ['amount', 'currency']]); + $this->putJson($path, ['model' => 'one_time', 'amount' => 0, 'currency' => 'USD'])->assertStatus(422); + $this->putJson($path, ['model' => 'one_time', 'amount' => 49.5, 'currency' => 'USD'])->assertStatus(422); + $this->putJson($path, ['model' => 'one_time', 'amount' => 4900, 'currency' => 'US'])->assertStatus(422); + $this->putJson($path, ['model' => 'subscription', 'amount' => 4900, 'currency' => 'USD'])->assertStatus(422); + + $this->putJson($path, ['model' => 'one_time', 'amount' => 4900, 'currency' => 'eur']) + ->assertOk() + ->assertJsonPath('price', ['model' => 'one_time', 'amount' => 4900, 'currency' => 'EUR', 'on_sale' => false, 'sale_amount' => null, 'effective_amount' => 4900]); + + $this->putJson($path, ['model' => 'free'])->assertOk()->assertJsonPath('price.amount', 0); + expect(Price::where('extension_uuid', $listing->uuid)->count())->toBe(1) + ->and(Event::where('kind', 'extension.price_changed')->count())->toBe(2); +}); + +it('sends the next release to review after a price change', function () { + Mail::fake(); + [$account, $organization, $listing] = publisher(); + $organization->forceFill(['stripe_connect_id' => 'acct_123'])->save(); + publishedRelease($account, $listing, '1.0.0'); + + actingAsAccount($account, [Token::SCOPE_PUBLISH]); + $this->putJson('/v1/publisher/extensions/'.$listing->slug.'/price', ['model' => 'one_time', 'amount' => 1500, 'currency' => 'USD'])->assertOk(); + + uploadRelease($account, $listing, '1.1.0', [], ['submit' => '1']) + ->assertCreated() + ->assertJsonPath('status', Release::STATUS_SUBMITTED) + ->assertJsonPath('review.required', true) + ->assertJsonPath('review.reasons.'.ReviewPolicy::PRICING, 'Pricing changed from free to 15.00 USD (one_time).'); +}); diff --git a/tests/Feature/Releases/PublisherExtensionsTest.php b/tests/Feature/Releases/PublisherExtensionsTest.php index 76602c5..8f00342 100644 --- a/tests/Feature/Releases/PublisherExtensionsTest.php +++ b/tests/Feature/Releases/PublisherExtensionsTest.php @@ -6,6 +6,7 @@ use App\Models\Membership; use App\Models\Organization; use App\Models\Price; +use App\Models\Release; use App\Models\Token; use Illuminate\Support\Str; @@ -154,7 +155,14 @@ function listingPayload(Organization $organization, array $overrides = []): arra [, , $foreign] = publisher(); actingAsAccount($account, [Token::SCOPE_PUBLISH]); - $this->getJson('/v1/publisher/extensions')->assertOk()->assertJsonCount(1)->assertJsonPath('0.slug', $extension->slug); + $this->getJson('/v1/publisher/extensions')->assertOk()->assertJsonCount(1)->assertJsonPath('0.slug', $extension->slug)->assertJsonPath('0.releases', []); + + // The list carries each listing's releases, for the website's status pipeline. + $release = Release::factory()->create(['extension_uuid' => $extension->uuid, 'version' => '1.2.0', 'status' => Release::STATUS_SUBMITTED]); + $this->getJson('/v1/publisher/extensions')->assertOk() + ->assertJsonPath('0.releases.0.id', $release->public_id) + ->assertJsonPath('0.releases.0.status', Release::STATUS_SUBMITTED); + $release->delete(); $this->getJson('/v1/publisher/extensions/'.$extension->public_id)->assertOk()->assertJsonPath('readiness.ready', true)->assertJsonPath('releases', []); $this->getJson('/v1/publisher/extensions/'.$foreign->slug)->assertNotFound(); diff --git a/tests/Feature/Website/SeedWebsiteTest.php b/tests/Feature/Website/SeedWebsiteTest.php new file mode 100644 index 0000000..8953616 --- /dev/null +++ b/tests/Feature/Website/SeedWebsiteTest.php @@ -0,0 +1,51 @@ + true]))->toBe(0); + + return json_decode(Artisan::output(), true); +} + +it('seeds the website demo and is safe to re-run', function () { + $first = seedWebsite(); + $second = seedWebsite(); + + expect($second)->toBe($first) + ->and($first['staff'])->toBe(['email' => 'staff@registry.test', 'password' => 'password123']) + ->and($first['publisher'])->toBe(['email' => 'publisher@registry.test', 'password' => 'password123']) + ->and($first['extensions'])->toBe(['registry-demo-hello', 'registry-demo-pro', 'acme-fleet-insights', 'acme-route-planner']) + ->and(Release::findById($first['queued_release'])->status)->toBe(Release::STATUS_SUBMITTED) + ->and(Release::count())->toBe(3) + ->and(Extension::where('slug', 'acme-route-planner')->sole()->status)->toBe(Extension::STATUS_DRAFT); + + $staff = Account::where('email', 'staff@registry.test')->sole(); + expect($staff->isStaff())->toBeTrue() + ->and(Hash::check('password123', $staff->password))->toBeTrue() + ->and(Account::where('email', 'publisher@registry.test')->sole()->roleIn(Organization::where('slug', 'acme')->sole()))->toBe('owner'); + + // The catalog shows the free and the paid listing with their copy. + $this->getJson('/v1/catalog/extensions?pricing=paid')->assertJsonPath('data.0.slug', 'registry-demo-pro')->assertJsonPath('data.0.category.slug', 'analytics-and-reporting'); + $this->getJson('/v1/catalog/extensions/registry-demo-hello') + ->assertOk() + ->assertJsonPath('is_paid', false) + ->assertJsonPath('promotional_text', fn ($text) => str_contains($text, 'publisher website')); + + // The accounts sign in and see their work. + actingAsAccount($staff); + $this->getJson('/v1/admin/releases')->assertOk()->assertJsonPath('data.0.id', $first['queued_release']); +}); + +it('never runs in production', function () { + app()->detectEnvironment(fn () => 'production'); + + $this->artisan('registry:seed-website')->assertFailed(); + expect(Account::count())->toBe(0); +}); diff --git a/tests/Feature/Website/WebsiteTest.php b/tests/Feature/Website/WebsiteTest.php new file mode 100644 index 0000000..3edb6f0 --- /dev/null +++ b/tests/Feature/Website/WebsiteTest.php @@ -0,0 +1,56 @@ +publicPath = sys_get_temp_dir().'/registry-public-'.uniqid(); + File::ensureDirectoryExists($this->publicPath.'/assets'); + app()->usePublicPath($this->publicPath); +}); + +afterEach(function () { + File::deleteDirectory($this->publicPath); +}); + +it('serves the website shell for every other GET path', function () { + file_put_contents($this->publicPath.'/index.html', 'Registry'); + file_put_contents($this->publicPath.'/assets/app.js', 'console.log(1);'); + + foreach (['/', '/extensions/fleet-ops', '/publisher/listings/new?tab=media', '/admin/queue'] as $path) { + $response = $this->get($path)->assertOk(); + expect($response->headers->get('Content-Type'))->toStartWith('text/html') + ->and($response->headers->get('Cache-Control'))->toContain('no-cache') + ->and(file_get_contents($response->baseResponse->getFile()->getPathname()))->toBe('Registry') + ->and($response->headers->getCookies())->toBe([]); + } + + // A built file the web server did not serve itself. + $asset = $this->get('/assets/app.js')->assertOk(); + expect($asset->baseResponse->getFile()->getPathname())->toBe(realpath($this->publicPath.'/assets/app.js')); +}); + +it('answers 503 until the website is built', function () { + $this->get('/extensions') + ->assertStatus(503) + ->assertExactJson(['errors' => ['The website has not been built. Run npm run build in web/.']]); +}); + +it('keeps API, package and health paths out of the website', function () { + file_put_contents($this->publicPath.'/index.html', ''); + + $this->get('/v1/nope')->assertNotFound()->assertExactJson(['errors' => ['Resource not found.']]); + $this->get('/v1/catalog/nope/deeper')->assertNotFound()->assertExactJson(['errors' => ['Resource not found.']]); + $this->postJson('/v1/nope')->assertNotFound()->assertExactJson(['errors' => ['Resource not found.']]); + $this->get('/composer/nope')->assertNotFound()->assertJsonPath('error', 'Not found.'); + $this->get('/npm/-/nope/deeper')->assertNotFound(); + $this->get('/up')->assertOk(); + $this->postJson('/extensions')->assertNotFound(); +}); + +it('moves the service info to GET /v1', function () { + $this->getJson('/v1') + ->assertOk() + ->assertJsonPath('service', config('app.name')) + ->assertJsonPath('version', config('registry.version')) + ->assertJsonPath('catalog', url('/v1/catalog/extensions')); +}); diff --git a/tests/Helpers/session.php b/tests/Helpers/session.php new file mode 100644 index 0000000..9d71ebf --- /dev/null +++ b/tests/Helpers/session.php @@ -0,0 +1,43 @@ +headers->getCookies() as $cookie) { + $cookies[$cookie->getName()] = $cookie->getValue(); + } + + return $cookies; +} + +/** + * A JSON request from the website: the given cookies and, unless `$csrf` is false, the + * `X-XSRF-TOKEN` header copied from the `XSRF-TOKEN` cookie as the SPA sends it. + */ +function websiteRequest(string $method, string $uri, array $cookies, array $data = [], bool $csrf = true): TestResponse +{ + $server = ['HTTP_ACCEPT' => 'application/json', 'CONTENT_TYPE' => 'application/json']; + if ($csrf && isset($cookies['XSRF-TOKEN'])) { + $server['HTTP_X_XSRF_TOKEN'] = $cookies['XSRF-TOKEN']; + } + + return test()->call($method, $uri, [], $cookies, [], $server, $data === [] ? null : json_encode($data)); +} + +/** + * Sign in through the website session (GET then POST /v1/auth/session) and return the + * browser's cookies afterwards. The account's password must be `password`. + */ +function websiteSignIn(Account $account, string $password = 'password'): array +{ + $cookies = cookiesFrom(websiteRequest('GET', '/v1/auth/session', [])->assertOk()); + $response = websiteRequest('POST', '/v1/auth/session', $cookies, ['email' => $account->email, 'password' => $password])->assertOk(); + + return cookiesFrom($response, $cookies); +} diff --git a/tests/Pest.php b/tests/Pest.php index 444310f..96e5e45 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -13,6 +13,7 @@ use Tests\TestCase; require_once __DIR__.'/Helpers/releases.php'; +require_once __DIR__.'/Helpers/session.php'; pest()->extend(TestCase::class) ->use(RefreshDatabase::class) @@ -20,16 +21,21 @@ pest()->extend(TestCase::class)->in('Unit'); +// Website sessions use the database store, as deployed (the `sessions` table). +pest()->beforeEach(function () { + config(['session.driver' => 'database']); +})->in('Feature/Auth', 'Feature/Organizations'); + // Package and release tests run against a fake registry disk. pest()->beforeEach(function () { config(['registry.storage.disk' => 'packages']); Storage::fake('packages'); -})->in('Feature/Packages', 'Feature/Releases'); +})->in('Feature/Packages', 'Feature/Releases', 'Feature/Website'); // Release checks never depend on a gitleaks binary on the machine running the tests. pest()->beforeEach(function () { config(['registry.releases.gitleaks_binary' => '']); -})->in('Feature/Releases'); +})->in('Feature/Releases', 'Feature/Website'); /** * Issue a token for the account and authenticate subsequent requests with it. diff --git a/tests/TestCase.php b/tests/TestCase.php index 914839d..5a568c3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -7,11 +7,13 @@ abstract class TestCase extends BaseTestCase { /** - * Each simulated request resolves auth from scratch, as separate HTTP requests would. + * Each simulated request resolves auth and its session from scratch, as separate HTTP + * requests would (the session comes back only through its cookie and the store). */ public function call($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null) { $this->app['auth']->forgetGuards(); + $this->app['session']->forgetDrivers(); return parent::call($method, $uri, $parameters, $cookies, $files, $server, $content); }