From 31359a0af8719ca7d1fce6a3a838c5c9ea2fdba5 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 7 Nov 2026 14:52:42 +0000 Subject: [PATCH 1/6] Manage extension releases through queued directory packages --- README.md | 8 +- openapi/extensions-v2.json | 996 +++++++++++++++++- src/components/ExtensionCard.astro | 22 +- src/components/ExtensionForm.astro | 164 ++- src/components/ExtensionHeader.astro | 25 +- src/components/ExtensionOverviewDetails.astro | 17 +- src/components/ExtensionReleases.astro | 13 +- src/components/ExtensionSectionFields.astro | 13 +- src/components/ExtensionWorkspace.astro | 112 +- src/components/ReleaseChecksum.astro | 53 + src/components/admin/DiffValue.astro | 3 +- src/lib/admin-revisions-client.ts | 27 +- src/lib/api/client.ts | 73 ++ src/lib/api/generated/extensions-v2/index.ts | 39 + .../api/generated/extensions-v2/sdk.gen.ts | 156 ++- .../api/generated/extensions-v2/types.gen.ts | 420 +++++++- src/lib/extension-draft-server.ts | 50 + src/lib/extension-draft.ts | 112 +- src/lib/extension-form.ts | 196 ++-- src/lib/extension-icon.ts | 70 +- src/lib/extension-package.ts | 85 ++ src/lib/image-url.ts | 29 + src/lib/package-download.ts | 11 + src/lib/package-icon.ts | 12 - src/lib/package-job.ts | 46 + src/lib/package-response.ts | 54 + src/lib/package-upload.ts | 65 ++ src/lib/releases.ts | 23 +- src/lib/revision-diff-shared.ts | 14 +- src/middleware.ts | 3 +- .../account/admin/extensions/[id]/edit.astro | 12 +- src/pages/account/extensions/new.astro | 10 +- src/pages/extension/[id].astro | 4 +- src/pages/extension/[id]/draft.ts | 47 +- src/pages/extension/[id]/package.ts | 124 +++ src/pages/package/[packageId].ts | 3 + src/pages/package/[packageId]/icon.ts | 3 + src/scripts/extension-workspace.ts | 394 ++++++- src/types/index.ts | 3 + test/extension-draft.test.ts | 22 +- test/extension-form.test.ts | 101 +- test/extension-icon.test.ts | 106 +- test/extension-package-download.test.ts | 105 ++ test/extension-packages.test.ts | 345 ++++++ test/middleware.test.ts | 16 + test/package-api.test.ts | 111 ++ test/package-job.test.ts | 67 ++ test/releases.test.ts | 35 +- test/revision-diff.test.ts | 1 - 49 files changed, 3705 insertions(+), 715 deletions(-) create mode 100644 src/components/ReleaseChecksum.astro create mode 100644 src/lib/extension-draft-server.ts create mode 100644 src/lib/extension-package.ts create mode 100644 src/lib/package-download.ts delete mode 100644 src/lib/package-icon.ts create mode 100644 src/lib/package-job.ts create mode 100644 src/lib/package-response.ts create mode 100644 src/lib/package-upload.ts create mode 100644 src/pages/extension/[id]/package.ts create mode 100644 src/pages/package/[packageId].ts create mode 100644 src/pages/package/[packageId]/icon.ts create mode 100644 test/extension-package-download.test.ts create mode 100644 test/extension-packages.test.ts create mode 100644 test/package-api.test.ts create mode 100644 test/package-job.test.ts diff --git a/README.md b/README.md index b49020cc..5320682c 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,10 @@ Individual extensions are licensed by their respective authors. ## Manifest API contract -The generated v2 client follows the API's manifest v1 contract: `module`, string licenses, optional `homepage_url`, package authors, and full FOSSBilling/PHP/module requirements. Author/edit/moderator forms preserve these fields; historical revision diffs also accept pre-manifest license objects and `website` fields. +The generated v2 client follows the API's manifest v1 contract: `module`, string licenses, optional `homepage_url`, package authors, and full FOSSBilling/PHP/module requirements. Author/edit/moderator forms preserve these fields. -Package icons are paths relative to the distribution root. For GitHub release assets matching the declared source repository, the site resolves them against the release tag. `FOSSBilling/modules` uses its `Ucfirst(id)/` package directory; other GitHub sources use the repository root. Unsupported sources omit relative icons. Legacy HTTP(S) icons continue to work. +Package preparation is queued. The API returns a private job ID; the release editor shows queued/processing status and saves that reference in browser storage so it can resume after a reload. A completed release joins the local draft and still needs review. Manifest corrections reuse stored source; new submissions and server-side corrections wait for the same job contract with a bounded timeout. -API mutations and site mutations both purge the `extensions` and `developers` tags. The `developers` tag is currently a no-op reserved for future cached surfaces; developer pages are uncached. Deploy this site with the API's manifest migration and cache-tag change before enabling the updated modules publisher. Existing cached pages expire through their cache windows; deployment must coordinate the new contract and tag. Old pending revisions require resubmission using the manifest fields before approval. +Authors upload ready-to-install distribution ZIPs. Icons use `icon.svg`, `icon.png`, `icon.webp` or `icon.avif` at the package root, without a manifest field. The API prepares private display images during processing and publishes approved images through the configured asset CDN. Package downloads and pending previews use permission-checked API routes. + +API mutations and site mutations purge the directory cache. Deploy and smoke-test the configured, package-enabled API before deploying this site or enabling the modules publisher. The Queue and private image-storage bindings must exist before the API's automatic deployment, even when package mode is disabled. diff --git a/openapi/extensions-v2.json b/openapi/extensions-v2.json index aa69be9c..cb50ca38 100644 --- a/openapi/extensions-v2.json +++ b/openapi/extensions-v2.json @@ -96,10 +96,6 @@ "minLength": 1, "maxLength": 1000 }, - "icon_url": { - "type": "string", - "maxLength": 2048 - }, "source": { "$ref": "#/components/schemas/Repository" }, @@ -114,6 +110,10 @@ "format": "uri", "description": "Null when a legacy URL exceeds the card length bound" }, + "icon_url": { + "type": "string", + "maxLength": 2048 + }, "id": { "type": "string" }, @@ -251,10 +251,6 @@ "minLength": 1, "maxLength": 1000 }, - "icon_url": { - "type": "string", - "maxLength": 2048 - }, "source": { "$ref": "#/components/schemas/Repository" }, @@ -268,6 +264,10 @@ "maxLength": 2048, "format": "uri", "description": "Null when a legacy URL exceeds the card length bound" + }, + "icon_url": { + "type": "string", + "maxLength": 2048 } }, "required": [ @@ -437,9 +437,6 @@ "type": "string", "minLength": 1 }, - "icon_url": { - "type": "string" - }, "version": { "type": "string", "minLength": 1, @@ -449,6 +446,9 @@ "type": "string", "minLength": 1 }, + "icon_url": { + "type": "string" + }, "id": { "type": "string" }, @@ -512,6 +512,18 @@ "maxLength": 2048, "format": "uri" }, + "is_latest": { + "type": "boolean", + "description": "Explicitly selected latest release; at most one release may be selected" + }, + "artifact_id": { + "type": "string", + "format": "uuid" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, "requires": { "$ref": "#/components/schemas/ManifestRequirements" } @@ -586,12 +598,6 @@ "maxLength": 1000, "pattern": "^[A-Za-z0-9.\\-+_:() |]+$" }, - "icon_url": { - "type": "string", - "minLength": 1, - "maxLength": 2048, - "pattern": "^(?!\\/)(?![a-zA-Z][a-zA-Z0-9+.-]*:)(?!.*\\.\\.)[^\\\\]+$" - }, "readme": { "type": "string", "minLength": 1, @@ -688,9 +694,6 @@ "type": "string", "minLength": 1 }, - "icon_url": { - "type": "string" - }, "version": { "type": "string", "minLength": 1, @@ -699,6 +702,9 @@ "download_url": { "type": "string", "minLength": 1 + }, + "icon_url": { + "type": "string" } } } @@ -834,21 +840,6 @@ } ] }, - "icon_url": { - "anyOf": [ - { - "type": "string", - "minLength": 1, - "maxLength": 2048, - "pattern": "^(?!\\/)(?![a-zA-Z][a-zA-Z0-9+.-]*:)(?!.*\\.\\.)[^\\\\]+$" - }, - { - "type": "string", - "maxLength": 2048, - "format": "uri" - } - ] - }, "readme": { "type": "string", "minLength": 1, @@ -867,6 +858,10 @@ "maxLength": 2048, "format": "uri" }, + "icon_url": { + "type": "string", + "maxLength": 2048 + }, "website": { "type": "string", "maxLength": 2048, @@ -941,12 +936,6 @@ "maxLength": 1000, "pattern": "^[A-Za-z0-9.\\-+_:() |]+$" }, - "icon_url": { - "type": "string", - "minLength": 1, - "maxLength": 2048, - "pattern": "^(?!\\/)(?![a-zA-Z][a-zA-Z0-9+.-]*:)(?!.*\\.\\.)[^\\\\]+$" - }, "readme": { "type": "string", "minLength": 1, @@ -1056,12 +1045,6 @@ "maxLength": 1000, "pattern": "^[A-Za-z0-9.\\-+_:() |]+$" }, - "icon_url": { - "type": "string", - "minLength": 1, - "maxLength": 2048, - "pattern": "^(?!\\/)(?![a-zA-Z][a-zA-Z0-9+.-]*:)(?!.*\\.\\.)[^\\\\]+$" - }, "readme": { "type": "string", "minLength": 1, @@ -1627,12 +1610,6 @@ "maxLength": 1000, "pattern": "^[A-Za-z0-9.\\-+_:() |]+$" }, - "icon_url": { - "type": "string", - "minLength": 1, - "maxLength": 2048, - "pattern": "^(?!\\/)(?![a-zA-Z][a-zA-Z0-9+.-]*:)(?!.*\\.\\.)[^\\\\]+$" - }, "readme": { "type": "string", "minLength": 1, @@ -1769,6 +1746,185 @@ "required": ["developer_id"], "additionalProperties": false }, + "PackageJob": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "extension_id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["queued", "processing", "ready", "failed"] + }, + "artifact": { + "anyOf": [ + { + "$ref": "#/components/schemas/PackageArtifact" + }, + { + "type": "null" + } + ] + }, + "error": { + "type": ["object", "null"], + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "integer" + } + }, + "required": ["code", "message", "status"] + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "extension_id", + "status", + "artifact", + "error", + "created_at", + "updated_at" + ] + }, + "PackageArtifact": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "source_id": { + "type": "string", + "format": "uuid" + }, + "manifest": { + "$ref": "#/components/schemas/Manifest" + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "download_url": { + "type": "string", + "format": "uri" + }, + "published_at": { + "type": ["string", "null"] + }, + "created_at": { + "type": "string" + } + }, + "required": [ + "id", + "source_id", + "manifest", + "sha256", + "download_url", + "published_at", + "created_at" + ] + }, + "Manifest": { + "type": "object", + "properties": { + "manifest_version": { + "type": "number", + "enum": [1] + }, + "type": { + "type": "string", + "enum": [ + "module", + "theme", + "payment-gateway", + "server-manager", + "domain-registrar", + "translation" + ] + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9_-]+$" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "version": { + "type": "string", + "maxLength": 100, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "author": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "url": { + "type": "string", + "maxLength": 2048, + "format": "uri" + } + }, + "required": ["name"], + "additionalProperties": false + }, + "license": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "pattern": "^[A-Za-z0-9.\\-+_:() |]+$" + }, + "homepage_url": { + "type": "string", + "maxLength": 2048, + "format": "uri" + }, + "requires": { + "$ref": "#/components/schemas/ManifestRequirements" + } + }, + "required": [ + "manifest_version", + "type", + "id", + "name", + "version", + "description", + "author", + "license", + "requires" + ], + "additionalProperties": false + }, "OwnedDeveloperProfile": { "anyOf": [ { @@ -5599,6 +5755,740 @@ } } }, + "/packages": { + "post": { + "tags": ["Packages"], + "summary": "Queue package preparation from a distribution ZIP or stored source", + "security": [ + { + "Bearer": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "manifest": { + "type": "string", + "minLength": 1, + "maxLength": 65536, + "description": "JSON manifest v1" + }, + "source": { + "type": "string", + "format": "binary" + }, + "source_id": { + "type": "string", + "format": "uuid" + } + }, + "required": ["manifest"], + "additionalProperties": false + } + } + } + }, + "responses": { + "202": { + "description": "Private processing job; identical requests reuse queued or completed work", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/components/schemas/PackageJob" + } + }, + "required": ["result"] + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The bearer is valid but the account is inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Package not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Source version conflict, ownership changed, or storage quota exhausted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Upload exceeds size limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Invalid manifest or source ZIP", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Write allowance exhausted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Package storage is not configured or is unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/package-jobs/{jobId}": { + "get": { + "tags": ["Packages"], + "summary": "Read an owning developer's private package-processing job", + "security": [ + { + "Bearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "jobId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Package processing state and completed artifact", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/components/schemas/PackageJob" + } + }, + "required": ["result"] + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The bearer is valid but the account is inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Package not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Source version conflict, ownership changed, or storage quota exhausted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Upload exceeds size limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Invalid manifest or source ZIP", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Write allowance exhausted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Package storage is not configured or is unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/packages/cleanup": { + "post": { + "tags": ["Packages"], + "summary": "Reclaim abandoned local package objects older than one day", + "security": [ + { + "Bearer": [] + } + ], + "responses": { + "200": { + "description": "Cleanup completed", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "object", + "properties": { + "deleted": { + "type": "number" + } + }, + "required": ["deleted"] + } + }, + "required": ["result"] + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The bearer is valid but the account is inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Package not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Source version conflict, ownership changed, or storage quota exhausted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Upload exceeds size limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Invalid manifest or source ZIP", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Write allowance exhausted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Package storage is not configured or is unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/packages/{packageId}": { + "get": { + "tags": ["Packages"], + "summary": "Read an approved package or an authorized private preview", + "security": [ + { + "Bearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "packageId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Package manifest and checksum", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "$ref": "#/components/schemas/PackageArtifact" + } + }, + "required": ["result"] + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The bearer is valid but the account is inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Package not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Source version conflict, ownership changed, or storage quota exhausted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Upload exceeds size limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Invalid manifest or source ZIP", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Write allowance exhausted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Package storage is not configured or is unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/packages/{packageId}/download": { + "get": { + "tags": ["Packages"], + "summary": "Download an approved package, or preview as owner/moderator", + "security": [ + { + "Bearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "packageId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Verified immutable ZIP", + "content": { + "application/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The bearer is valid but the account is inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Package not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Source version conflict, ownership changed, or storage quota exhausted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Upload exceeds size limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Invalid manifest or source ZIP", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Write allowance exhausted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Package storage is not configured or is unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/packages/{packageId}/icon": { + "get": { + "tags": ["Packages"], + "summary": "Read an approved package or an authorized private preview", + "security": [ + { + "Bearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true, + "name": "packageId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Sanitized SVG, optimized AVIF/PNG, or generic fallback", + "content": { + "image/svg+xml": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/avif": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The bearer is valid but the account is inactive", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Package not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Source version conflict, ownership changed, or storage quota exhausted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Upload exceeds size limit", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Invalid manifest or source ZIP", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Write allowance exhausted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "503": { + "description": "Package storage is not configured or is unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/developers": { "get": { "tags": ["Developers"], diff --git a/src/components/ExtensionCard.astro b/src/components/ExtensionCard.astro index d1e67bf1..1b40d843 100644 --- a/src/components/ExtensionCard.astro +++ b/src/components/ExtensionCard.astro @@ -2,6 +2,7 @@ import type { ExtensionListItem } from '@/lib/api/client'; import { extensionIconUrl } from '@/lib/extension-icon'; import { getSignedImageUrl } from '@/lib/signed-image-url'; +import { directoryAssetAvifUrl } from '@/lib/image-url'; interface Props { ext: ExtensionListItem; @@ -28,15 +29,18 @@ const iconUrl = await getSignedImageUrl(
{iconUrl && ( - {`${ext.name} + + + {`${ext.name} + )} {!iconUrl &&
diff --git a/src/components/ExtensionForm.astro b/src/components/ExtensionForm.astro index ca6a6f33..4a6393ac 100644 --- a/src/components/ExtensionForm.astro +++ b/src/components/ExtensionForm.astro @@ -46,11 +46,6 @@ const isEdit = Boolean(extension); // Redisplay values: a failed submission's echo wins over the extension being // edited, so a rejected edit shows what the user typed. const shown = echo ?? extension; -// A release is required, same as a brand new extension, whenever there's -// nothing already published to carry through — an extension can reach this -// form with no releases yet (rejected and never resubmitted). -const requireRelease = !isEdit || extension!.releases.length === 0; - const existingSpdxId = shown?.license; // An existing license only lands in the custom bucket when it truly isn't // one of the ids this form offers — covers both genuinely custom/proprietary @@ -96,7 +91,7 @@ const otherSpdxIds = SPDX_LICENSE_IDS.filter((id) => !popularSpdxIds.has(id));
-
+
Publisher @@ -201,22 +196,6 @@ const otherSpdxIds = SPDX_LICENSE_IDS.filter((id) => !popularSpdxIds.has(id)); />
-
- -
- -
-
{correctionNote !== undefined && ( diff --git a/src/components/ExtensionHeader.astro b/src/components/ExtensionHeader.astro index f3b33c89..86fcf995 100644 --- a/src/components/ExtensionHeader.astro +++ b/src/components/ExtensionHeader.astro @@ -5,7 +5,9 @@ import { latestRelease } from '@/lib/releases'; import { ArrowLeft, Download, Package, User } from '@lucide/astro'; import { getSignedImageUrl } from '@/lib/signed-image-url'; import { extensionIconUrl } from '@/lib/extension-icon'; +import { releaseDownloadHref } from '@/lib/extension-package'; import { isSafeHttpUrl } from '@/lib/safe-url'; +import { directoryAssetAvifUrl } from '@/lib/image-url'; interface Props { id: string; content: StoredExtensionFields; @@ -18,7 +20,7 @@ const { id, content, developer, - versionLabel = 'Latest Version', + versionLabel = 'Latest Release', backHref = '/', backLabel = 'Extensions', } = Astro.props; @@ -27,13 +29,13 @@ const version = latest?.tag ?? content.version; const iconUrl = await getSignedImageUrl( extensionIconUrl({ ...content, - id, - source: content.source ?? { type: 'custom', repo: '' }, }), 'icon', Astro.locals.env.sessionSecret, ); -const downloadUrl = latest?.download_url ?? content.download_url; +const downloadUrl = latest + ? new URL(releaseDownloadHref(latest), Astro.url).href + : content.download_url; ---
- {iconUrl ? ( + + - ) : ( + +
-
diff --git a/src/components/ExtensionReleases.astro b/src/components/ExtensionReleases.astro index 560a164d..506e531a 100644 --- a/src/components/ExtensionReleases.astro +++ b/src/components/ExtensionReleases.astro @@ -1,7 +1,10 @@ --- +import { Copy } from '@lucide/astro'; +import ReleaseChecksum from './ReleaseChecksum.astro'; import { compatibilityLabel } from '@/lib/requirements'; import type { Release } from '@/types'; import { latestRelease, releasesDescending } from '@/lib/releases'; +import { releaseDownloadHref } from '@/lib/extension-package'; import { isSafeHttpUrl } from '@/lib/safe-url'; interface Props { releases: Release[]; @@ -13,13 +16,18 @@ const { releases, editable, disabledReason, showActions = true } = Astro.props; const latest = latestRelease(releases); --- +{editable && ( + +)}
    {releasesDescending(releases).map((release) => (
  • {release.tag} - {release.tag === latest?.tag ? ' · Latest Version' : ''} + {release.tag === latest?.tag ? ' · Latest Release' : ''}

    {release.date} · FOSSBilling{' '} @@ -32,7 +40,7 @@ const latest = latestRelease(releases); Download + {release.changelog_url && isSafeHttpUrl(release.changelog_url) && ( diff --git a/src/components/ExtensionSectionFields.astro b/src/components/ExtensionSectionFields.astro index a75e741b..6e5009c7 100644 --- a/src/components/ExtensionSectionFields.astro +++ b/src/components/ExtensionSectionFields.astro @@ -23,7 +23,7 @@ const { section, content } = Astro.props; class="input w-full" name="name" required - maxlength="120" + maxlength="100" value={content.name} /> @@ -34,7 +34,7 @@ const { section, content } = Astro.props; name="description" rows="3" required - maxlength="4000" + maxlength="500" >{content.description} - )} {section === 'listing' && ( diff --git a/src/components/ExtensionWorkspace.astro b/src/components/ExtensionWorkspace.astro index 47c59419..f3e89df8 100644 --- a/src/components/ExtensionWorkspace.astro +++ b/src/components/ExtensionWorkspace.astro @@ -39,6 +39,7 @@ const config = editorContent && editor ? serializeDraftConfig({ base: editorContent, + packageUrl: `${baseUrl}/package`, developerName, pendingRevisionId, submitLabel, @@ -73,7 +74,7 @@ const labels: Record = {

    Save changes across sections, then submit them together. Drafts stay - in this browser. + in this browser. Package metadata is rebuilt when needed.

    @@ -117,8 +118,8 @@ const labels: Record = { {editor && (

    - Manage versions, downloads, and compatibility independently of - your listing. + Manage packages, versions, and compatibility independently of your + listing.

    )}
    @@ -143,57 +144,77 @@ const labels: Record = { {editorContent && (
@@ -321,7 +342,6 @@ const labels: Record = { ) : (
+ + + +)} + + diff --git a/src/components/admin/DiffValue.astro b/src/components/admin/DiffValue.astro index 615087e5..bae5497c 100644 --- a/src/components/admin/DiffValue.astro +++ b/src/components/admin/DiffValue.astro @@ -1,6 +1,7 @@ --- import MarkdownValue from './MarkdownValue.astro'; import TruncatedText from './TruncatedText.astro'; +import { isPackageDownloadPath } from '@/lib/package-download'; import { isSafeHttpUrl } from '@/lib/safe-url'; import { URL_FIELDS } from '@/lib/revision-diff-shared'; @@ -19,7 +20,7 @@ interface Props { const { field, value, emptyLabel = '—', href = null, id } = Astro.props; const link = - href && isSafeHttpUrl(href) + href && (isSafeHttpUrl(href) || isPackageDownloadPath(href)) ? href : URL_FIELDS.has(field) && value !== null && isSafeHttpUrl(value) ? value diff --git a/src/lib/admin-revisions-client.ts b/src/lib/admin-revisions-client.ts index c790975e..6d905baf 100644 --- a/src/lib/admin-revisions-client.ts +++ b/src/lib/admin-revisions-client.ts @@ -1,3 +1,4 @@ +import { isPackageDownloadPath } from './package-download'; // Client-side behaviour for /account/admin/revisions: on-demand // "compare with live" (one GET /api/admin/revision-detail per expanded row, // cached per revision id) plus wiring the row buttons to the single shared @@ -75,7 +76,8 @@ function valueCell( // text below. Links are never truncated: a 400+ char URL is vanishingly // rare, and clipping an href's visible text while keeping the full target // would mislead reviewers. - const link = href && isSafeHttpUrl(href) ? href : null; + const link = + href && (isSafeHttpUrl(href) || isPackageDownloadPath(href)) ? href : null; if (link) { const anchor = document.createElement('a'); anchor.href = link; @@ -139,18 +141,10 @@ function renderDiff( // Source, license and author rows link via their own URL (type-aware for // sources); URL fields link via their own value. Everything else // renders as text. - const linkedField = - field === 'source' || field === 'license' || field === 'author'; - const oldHref = linkedField - ? fieldUrl(field, published) - : URL_FIELDS.has(field) - ? oldValue - : null; - const newHref = linkedField - ? fieldUrl(field, revision) - : URL_FIELDS.has(field) - ? newValue - : null; + const oldHref = + fieldUrl(field, published) ?? (URL_FIELDS.has(field) ? oldValue : null); + const newHref = + fieldUrl(field, revision) ?? (URL_FIELDS.has(field) ? newValue : null); const tdOld = field === 'readme' && oldValue !== null ? markdownCell( @@ -246,11 +240,8 @@ export function initRevisionQueue(): void { label.textContent = FIELD_LABELS[field]; const value = fieldDisplay(field, revision); const href = - field === 'source' || field === 'license' || field === 'author' - ? fieldUrl(field, revision) - : URL_FIELDS.has(field) - ? value - : null; + fieldUrl(field, revision) ?? + (URL_FIELDS.has(field) ? value : null); tr.appendChild(label); tr.appendChild( field === 'readme' && value !== null diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 9ded9687..1d1da742 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -1,4 +1,13 @@ +import { waitForPackageJob, PackageJobError } from '../package-job'; import { + postPackages, + getPackageJobsByJobId, + type PackageJob, + getPackagesByPackageId, + getPackagesByPackageIdDownload, + getPackagesByPackageIdIcon, + type PackageArtifact, + type Manifest, deleteDevelopersMe, deleteExtensionsById, getDevelopers, @@ -122,6 +131,9 @@ export type ExtensionRevision = Omit & { }; export type { + PackageJob, + PackageArtifact, + Manifest, Developer, DeveloperApproval, DeveloperHistoryEntry, @@ -486,6 +498,25 @@ export function createApiClient(env: ApplicationEnv, subject: string) { ), ); + const queuePackage = ( + manifest: Manifest, + source: File | { sourceId: string }, + ): Promise => + callResult( + postPackages({ + client, + body: { + manifest: JSON.stringify(manifest), + ...(source instanceof File + ? { source } + : { source_id: source.sourceId }), + }, + }), + ); + + const getPackageJob = (jobId: string): Promise => + callResult(getPackageJobsByJobId({ client, path: { jobId } })); + return { // Identity sync carries a dedicated body-bound proof instead of the // shared bearer assertion: the API requires purpose 'identity-sync' plus @@ -554,6 +585,26 @@ export function createApiClient(env: ApplicationEnv, subject: string) { id, ), + queuePackage, + getPackageJob, + + buildPackage: async ( + manifest: Manifest, + source: File | { sourceId: string }, + ): Promise => { + const job = await queuePackage(manifest, source); + try { + return await waitForPackageJob(job, getPackageJob, { attempts: 60 }); + } catch (error) { + if (error instanceof PackageJobError) + throw new ApiRequestError(error.status, error.code, error.message); + throw error; + } + }, + + getPackage: (packageId: string): Promise => + callResult(getPackagesByPackageId({ client, path: { packageId } })), + createExtension: (payload: ExtensionCreate) => callResult(postExtensions({ client, body: payload })), @@ -767,3 +818,25 @@ export function createApiClient(env: ApplicationEnv, subject: string) { ), }; } + +export async function downloadPackage( + env: ApplicationEnv, + packageId: string, + subject?: string, + resource: 'download' | 'icon' = 'download', + accept?: string, +): Promise { + const read = + resource === 'icon' + ? getPackagesByPackageIdIcon + : getPackagesByPackageIdDownload; + const result = await read({ + client: createApiTransport(env, subject), + path: { packageId }, + headers: accept ? { Accept: accept } : undefined, + parseAs: 'stream', + }); + if (result.error || !result.response?.ok) + throw apiErrorFrom(result.error, result.response?.status); + return result.response; +} diff --git a/src/lib/api/generated/extensions-v2/index.ts b/src/lib/api/generated/extensions-v2/index.ts index c89cb983..8cb57bc9 100644 --- a/src/lib/api/generated/extensions-v2/index.ts +++ b/src/lib/api/generated/extensions-v2/index.ts @@ -17,6 +17,10 @@ export { getExtensionsByIdRevisionsByRevisionId, getM2mClients, getModerationCounts, + getPackageJobsByJobId, + getPackagesByPackageId, + getPackagesByPackageIdDownload, + getPackagesByPackageIdIcon, getRevisions, getUsersMe, type Options, @@ -36,6 +40,8 @@ export { postExtensionsByIdRelist, postExtensionsByIdRevisionsByRevisionIdApprove, postExtensionsByIdRevisionsByRevisionIdReject, + postPackages, + postPackagesCleanup, putDevelopersMe, putExtensionsById, putExtensionsByIdRevisionsByRevisionId, @@ -144,6 +150,26 @@ export type { GetModerationCountsErrors, GetModerationCountsResponse, GetModerationCountsResponses, + GetPackageJobsByJobIdData, + GetPackageJobsByJobIdError, + GetPackageJobsByJobIdErrors, + GetPackageJobsByJobIdResponse, + GetPackageJobsByJobIdResponses, + GetPackagesByPackageIdData, + GetPackagesByPackageIdDownloadData, + GetPackagesByPackageIdDownloadError, + GetPackagesByPackageIdDownloadErrors, + GetPackagesByPackageIdDownloadResponse, + GetPackagesByPackageIdDownloadResponses, + GetPackagesByPackageIdError, + GetPackagesByPackageIdErrors, + GetPackagesByPackageIdIconData, + GetPackagesByPackageIdIconError, + GetPackagesByPackageIdIconErrors, + GetPackagesByPackageIdIconResponse, + GetPackagesByPackageIdIconResponses, + GetPackagesByPackageIdResponse, + GetPackagesByPackageIdResponses, GetRevisionsData, GetRevisionsError, GetRevisionsErrors, @@ -154,11 +180,14 @@ export type { GetUsersMeErrors, GetUsersMeResponse, GetUsersMeResponses, + Manifest, ManifestRequirements, ModeratorCorrect, OwnedDeveloperProfile, OwnedExtension, OwnedExtensionListItem, + PackageArtifact, + PackageJob, Pagination, PatchUsersMeData, PatchUsersMeError, @@ -242,6 +271,16 @@ export type { PostExtensionsErrors, PostExtensionsResponse, PostExtensionsResponses, + PostPackagesCleanupData, + PostPackagesCleanupError, + PostPackagesCleanupErrors, + PostPackagesCleanupResponse, + PostPackagesCleanupResponses, + PostPackagesData, + PostPackagesError, + PostPackagesErrors, + PostPackagesResponse, + PostPackagesResponses, PublicDeveloper, PublisherClient, PublisherRegister, diff --git a/src/lib/api/generated/extensions-v2/sdk.gen.ts b/src/lib/api/generated/extensions-v2/sdk.gen.ts index c7e3f0f3..995b94e9 100644 --- a/src/lib/api/generated/extensions-v2/sdk.gen.ts +++ b/src/lib/api/generated/extensions-v2/sdk.gen.ts @@ -1,11 +1,12 @@ // This file is auto-generated by @hey-api/openapi-ts -import type { - Client, - ClientMeta, - Options as Options2, - RequestResult, - TDataShape, +import { + type Client, + type ClientMeta, + formDataBodySerializer, + type Options as Options2, + type RequestResult, + type TDataShape, } from './client'; import { client } from './client.gen'; import type { @@ -57,6 +58,18 @@ import type { GetModerationCountsData, GetModerationCountsErrors, GetModerationCountsResponses, + GetPackageJobsByJobIdData, + GetPackageJobsByJobIdErrors, + GetPackageJobsByJobIdResponses, + GetPackagesByPackageIdData, + GetPackagesByPackageIdDownloadData, + GetPackagesByPackageIdDownloadErrors, + GetPackagesByPackageIdDownloadResponses, + GetPackagesByPackageIdErrors, + GetPackagesByPackageIdIconData, + GetPackagesByPackageIdIconErrors, + GetPackagesByPackageIdIconResponses, + GetPackagesByPackageIdResponses, GetRevisionsData, GetRevisionsErrors, GetRevisionsResponses, @@ -111,6 +124,12 @@ import type { PostExtensionsData, PostExtensionsErrors, PostExtensionsResponses, + PostPackagesCleanupData, + PostPackagesCleanupErrors, + PostPackagesCleanupResponses, + PostPackagesData, + PostPackagesErrors, + PostPackagesResponses, PutDevelopersMeData, PutDevelopersMeErrors, PutDevelopersMeResponses, @@ -870,6 +889,131 @@ export const getRevisions = ( ...options, }); +/** + * Queue package preparation from a distribution ZIP or stored source + */ +export const postPackages = ( + options: Options, +): RequestResult => + (options.client ?? client).post< + PostPackagesResponses, + PostPackagesErrors, + ThrowOnError + >({ + ...formDataBodySerializer, + security: [{ scheme: 'bearer', type: 'http' }], + url: '/packages', + ...options, + headers: { + 'Content-Type': null, + ...options.headers, + }, + }); + +/** + * Read an owning developer's private package-processing job + */ +export const getPackageJobsByJobId = ( + options: Options, +): RequestResult< + GetPackageJobsByJobIdResponses, + GetPackageJobsByJobIdErrors, + ThrowOnError +> => + (options.client ?? client).get< + GetPackageJobsByJobIdResponses, + GetPackageJobsByJobIdErrors, + ThrowOnError + >({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/package-jobs/{jobId}', + ...options, + }); + +/** + * Reclaim abandoned local package objects older than one day + */ +export const postPackagesCleanup = ( + options?: Options, +): RequestResult< + PostPackagesCleanupResponses, + PostPackagesCleanupErrors, + ThrowOnError +> => + (options?.client ?? client).post< + PostPackagesCleanupResponses, + PostPackagesCleanupErrors, + ThrowOnError + >({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/packages/cleanup', + ...options, + }); + +/** + * Read an approved package or an authorized private preview + */ +export const getPackagesByPackageId = ( + options: Options, +): RequestResult< + GetPackagesByPackageIdResponses, + GetPackagesByPackageIdErrors, + ThrowOnError +> => + (options.client ?? client).get< + GetPackagesByPackageIdResponses, + GetPackagesByPackageIdErrors, + ThrowOnError + >({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/packages/{packageId}', + ...options, + }); + +/** + * Download an approved package, or preview as owner/moderator + */ +export const getPackagesByPackageIdDownload = < + ThrowOnError extends boolean = false, +>( + options: Options, +): RequestResult< + GetPackagesByPackageIdDownloadResponses, + GetPackagesByPackageIdDownloadErrors, + ThrowOnError +> => + (options.client ?? client).get< + GetPackagesByPackageIdDownloadResponses, + GetPackagesByPackageIdDownloadErrors, + ThrowOnError + >({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/packages/{packageId}/download', + ...options, + }); + +/** + * Read an approved package or an authorized private preview + */ +export const getPackagesByPackageIdIcon = < + ThrowOnError extends boolean = false, +>( + options: Options, +): RequestResult< + GetPackagesByPackageIdIconResponses, + GetPackagesByPackageIdIconErrors, + ThrowOnError +> => + (options.client ?? client).get< + GetPackagesByPackageIdIconResponses, + GetPackagesByPackageIdIconErrors, + ThrowOnError + >({ + security: [{ scheme: 'bearer', type: 'http' }], + url: '/packages/{packageId}/icon', + ...options, + }); + /** * List developer profiles: every profile (scope=all) or awaiting review (scope=unapproved) */ diff --git a/src/lib/api/generated/extensions-v2/types.gen.ts b/src/lib/api/generated/extensions-v2/types.gen.ts index ac2a3c67..5fec5d55 100644 --- a/src/lib/api/generated/extensions-v2/types.gen.ts +++ b/src/lib/api/generated/extensions-v2/types.gen.ts @@ -29,13 +29,13 @@ export type ExtensionListItem = { */ homepage_url?: string | null; license: string; - icon_url?: string; source: Repository; version: string; /** * Null when a legacy URL exceeds the card length bound */ download_url: string | null; + icon_url?: string; id: string; developer: PublicDeveloper; }; @@ -78,13 +78,13 @@ export type OwnedExtensionListItem = { */ homepage_url?: string | null; license: string; - icon_url?: string; source: Repository; version: string; /** * Null when a legacy URL exceeds the card length bound */ download_url: string | null; + icon_url?: string; } | null; pending_revision: PendingRevisionRef; last_review: RevisionReview; @@ -141,9 +141,9 @@ export type Extension = ExtensionContent & { description?: string; homepage_url?: string; license?: string; - icon_url?: string; version?: string; download_url?: string; + icon_url?: string; id: string; developer: PublicDeveloper; }; @@ -164,6 +164,12 @@ export type Release = { date: string; download_url: string; changelog_url?: string; + /** + * Explicitly selected latest release; at most one release may be selected + */ + is_latest?: boolean; + artifact_id?: string; + sha256?: string; requires: ManifestRequirements; }; @@ -186,7 +192,6 @@ export type ExtensionContent = { releases: Array; homepage_url?: string; license: string; - icon_url?: string; readme: string; source: Repository; version: string; @@ -209,9 +214,9 @@ export type OwnedExtension = OwnedExtensionListItem & { >; homepage_url?: string; license?: string; - icon_url?: string; version?: string; download_url?: string; + icon_url?: string; } | null); pending_revision?: PendingRevisionRef & ({ @@ -254,11 +259,11 @@ export type StoredExtensionContent = { spdx_id?: string; URL?: string; }; - icon_url?: string | string; readme?: string; source?: Repository; version?: string; download_url?: string; + icon_url?: string; website?: string; }; @@ -281,7 +286,6 @@ export type ExtensionCreate = { releases: Array; homepage_url?: string; license: string; - icon_url?: string; readme: string; source: Repository; version: string; @@ -308,7 +312,6 @@ export type ExtensionUpdate = { releases: Array; homepage_url?: string; license: string; - icon_url?: string; readme: string; source: Repository; version: string; @@ -465,7 +468,6 @@ export type ModeratorCorrect = { releases: Array; homepage_url?: string; license: string; - icon_url?: string; readme: string; source: Repository; version: string; @@ -501,6 +503,52 @@ export type PublisherRegister = { can_publish?: boolean; }; +export type PackageJob = { + id: string; + extension_id: string; + status: 'queued' | 'processing' | 'ready' | 'failed'; + artifact: PackageArtifact | null; + error: { + code: string; + message: string; + status: number; + } | null; + created_at: string; + updated_at: string; +}; + +export type PackageArtifact = { + id: string; + source_id: string; + manifest: Manifest; + sha256: string; + download_url: string; + published_at: string | null; + created_at: string; +}; + +export type Manifest = { + manifest_version: 1; + type: + | 'module' + | 'theme' + | 'payment-gateway' + | 'server-manager' + | 'domain-registrar' + | 'translation'; + id: string; + name: string; + version: string; + description: string; + author: { + name: string; + url?: string; + }; + license: string; + homepage_url?: string; + requires: ManifestRequirements; +}; + export type OwnedDeveloperProfile = | (DeveloperProfile & { has_pending_transfer: boolean; @@ -2300,6 +2348,360 @@ export type GetRevisionsResponses = { export type GetRevisionsResponse = GetRevisionsResponses[keyof GetRevisionsResponses]; +export type PostPackagesData = { + body: { + /** + * JSON manifest v1 + */ + manifest: string; + source?: Blob | File; + source_id?: string; + }; + path?: never; + query?: never; + url: '/packages'; +}; + +export type PostPackagesErrors = { + /** + * Missing or invalid bearer token + */ + 401: Error; + /** + * The bearer is valid but the account is inactive + */ + 403: Error; + /** + * Package not found + */ + 404: Error; + /** + * Source version conflict, ownership changed, or storage quota exhausted + */ + 409: Error; + /** + * Upload exceeds size limit + */ + 413: Error; + /** + * Invalid manifest or source ZIP + */ + 422: Error; + /** + * Write allowance exhausted + */ + 429: Error; + /** + * Package storage is not configured or is unavailable + */ + 503: Error; +}; + +export type PostPackagesError = PostPackagesErrors[keyof PostPackagesErrors]; + +export type PostPackagesResponses = { + /** + * Private processing job; identical requests reuse queued or completed work + */ + 202: { + result: PackageJob; + }; +}; + +export type PostPackagesResponse = + PostPackagesResponses[keyof PostPackagesResponses]; + +export type GetPackageJobsByJobIdData = { + body?: never; + path: { + jobId: string; + }; + query?: never; + url: '/package-jobs/{jobId}'; +}; + +export type GetPackageJobsByJobIdErrors = { + /** + * Missing or invalid bearer token + */ + 401: Error; + /** + * The bearer is valid but the account is inactive + */ + 403: Error; + /** + * Package not found + */ + 404: Error; + /** + * Source version conflict, ownership changed, or storage quota exhausted + */ + 409: Error; + /** + * Upload exceeds size limit + */ + 413: Error; + /** + * Invalid manifest or source ZIP + */ + 422: Error; + /** + * Write allowance exhausted + */ + 429: Error; + /** + * Package storage is not configured or is unavailable + */ + 503: Error; +}; + +export type GetPackageJobsByJobIdError = + GetPackageJobsByJobIdErrors[keyof GetPackageJobsByJobIdErrors]; + +export type GetPackageJobsByJobIdResponses = { + /** + * Package processing state and completed artifact + */ + 200: { + result: PackageJob; + }; +}; + +export type GetPackageJobsByJobIdResponse = + GetPackageJobsByJobIdResponses[keyof GetPackageJobsByJobIdResponses]; + +export type PostPackagesCleanupData = { + body?: never; + path?: never; + query?: never; + url: '/packages/cleanup'; +}; + +export type PostPackagesCleanupErrors = { + /** + * Missing or invalid bearer token + */ + 401: Error; + /** + * The bearer is valid but the account is inactive + */ + 403: Error; + /** + * Package not found + */ + 404: Error; + /** + * Source version conflict, ownership changed, or storage quota exhausted + */ + 409: Error; + /** + * Upload exceeds size limit + */ + 413: Error; + /** + * Invalid manifest or source ZIP + */ + 422: Error; + /** + * Write allowance exhausted + */ + 429: Error; + /** + * Package storage is not configured or is unavailable + */ + 503: Error; +}; + +export type PostPackagesCleanupError = + PostPackagesCleanupErrors[keyof PostPackagesCleanupErrors]; + +export type PostPackagesCleanupResponses = { + /** + * Cleanup completed + */ + 200: { + result: { + deleted: number; + }; + }; +}; + +export type PostPackagesCleanupResponse = + PostPackagesCleanupResponses[keyof PostPackagesCleanupResponses]; + +export type GetPackagesByPackageIdData = { + body?: never; + path: { + packageId: string; + }; + query?: never; + url: '/packages/{packageId}'; +}; + +export type GetPackagesByPackageIdErrors = { + /** + * Missing or invalid bearer token + */ + 401: Error; + /** + * The bearer is valid but the account is inactive + */ + 403: Error; + /** + * Package not found + */ + 404: Error; + /** + * Source version conflict, ownership changed, or storage quota exhausted + */ + 409: Error; + /** + * Upload exceeds size limit + */ + 413: Error; + /** + * Invalid manifest or source ZIP + */ + 422: Error; + /** + * Write allowance exhausted + */ + 429: Error; + /** + * Package storage is not configured or is unavailable + */ + 503: Error; +}; + +export type GetPackagesByPackageIdError = + GetPackagesByPackageIdErrors[keyof GetPackagesByPackageIdErrors]; + +export type GetPackagesByPackageIdResponses = { + /** + * Package manifest and checksum + */ + 200: { + result: PackageArtifact; + }; +}; + +export type GetPackagesByPackageIdResponse = + GetPackagesByPackageIdResponses[keyof GetPackagesByPackageIdResponses]; + +export type GetPackagesByPackageIdDownloadData = { + body?: never; + path: { + packageId: string; + }; + query?: never; + url: '/packages/{packageId}/download'; +}; + +export type GetPackagesByPackageIdDownloadErrors = { + /** + * Missing or invalid bearer token + */ + 401: Error; + /** + * The bearer is valid but the account is inactive + */ + 403: Error; + /** + * Package not found + */ + 404: Error; + /** + * Source version conflict, ownership changed, or storage quota exhausted + */ + 409: Error; + /** + * Upload exceeds size limit + */ + 413: Error; + /** + * Invalid manifest or source ZIP + */ + 422: Error; + /** + * Write allowance exhausted + */ + 429: Error; + /** + * Package storage is not configured or is unavailable + */ + 503: Error; +}; + +export type GetPackagesByPackageIdDownloadError = + GetPackagesByPackageIdDownloadErrors[keyof GetPackagesByPackageIdDownloadErrors]; + +export type GetPackagesByPackageIdDownloadResponses = { + /** + * Verified immutable ZIP + */ + 200: Blob | File; +}; + +export type GetPackagesByPackageIdDownloadResponse = + GetPackagesByPackageIdDownloadResponses[keyof GetPackagesByPackageIdDownloadResponses]; + +export type GetPackagesByPackageIdIconData = { + body?: never; + path: { + packageId: string; + }; + query?: never; + url: '/packages/{packageId}/icon'; +}; + +export type GetPackagesByPackageIdIconErrors = { + /** + * Missing or invalid bearer token + */ + 401: Error; + /** + * The bearer is valid but the account is inactive + */ + 403: Error; + /** + * Package not found + */ + 404: Error; + /** + * Source version conflict, ownership changed, or storage quota exhausted + */ + 409: Error; + /** + * Upload exceeds size limit + */ + 413: Error; + /** + * Invalid manifest or source ZIP + */ + 422: Error; + /** + * Write allowance exhausted + */ + 429: Error; + /** + * Package storage is not configured or is unavailable + */ + 503: Error; +}; + +export type GetPackagesByPackageIdIconError = + GetPackagesByPackageIdIconErrors[keyof GetPackagesByPackageIdIconErrors]; + +export type GetPackagesByPackageIdIconResponses = { + /** + * Sanitized SVG, optimized AVIF/PNG, or generic fallback + */ + 200: Blob | File; +}; + +export type GetPackagesByPackageIdIconResponse = + GetPackagesByPackageIdIconResponses[keyof GetPackagesByPackageIdIconResponses]; + export type GetDevelopersData = { body?: never; path?: never; diff --git a/src/lib/extension-draft-server.ts b/src/lib/extension-draft-server.ts new file mode 100644 index 00000000..2b7c9bb0 --- /dev/null +++ b/src/lib/extension-draft-server.ts @@ -0,0 +1,50 @@ +import type { createApiClient } from './api/client'; +import { ApiRequestError } from './api/client'; +import { + applyExtensionDraft, + editableContent, + reconstructionContent, +} from './extension-draft'; +import { sha256Hex } from './hash'; + +export async function prepareExtensionDraft( + api: Pick< + ReturnType, + 'getMyExtension' | 'getRevision' + >, + id: string, + draft: unknown, +) { + const value = draft as { + pendingRevisionId?: string; + baselineHash?: string; + } | null; + const owned = await api.getMyExtension(id); + const pending = owned.pending_revision; + const conflict = (message: string): never => { + throw new ApiRequestError(409, 'DRAFT_CONFLICT', message); + }; + if ((value?.pendingRevisionId ?? null) !== (pending?.id ?? null)) + conflict( + 'The pending submission changed or was reviewed. Your local draft has been kept. Reload the overview before editing again.', + ); + const previous = + !pending && !owned.published && owned.last_review + ? await api.getRevision(id, owned.last_review.revision_id) + : null; + const stored = pending?.content ?? owned.published ?? previous?.content; + const base = + editableContent(stored) ?? + (pending || !owned.published + ? editableContent(reconstructionContent(stored)) + : null); + if (!base) + return conflict( + 'The original content is unavailable. Your local draft has been kept.', + ); + if (value?.baselineHash !== (await sha256Hex(JSON.stringify(base)))) + conflict( + 'This extension changed since you started editing. Your local draft has been kept. Reload the overview to review it.', + ); + return { base, payload: applyExtensionDraft(base, draft), pending }; +} diff --git a/src/lib/extension-draft.ts b/src/lib/extension-draft.ts index ff82ae15..20f44548 100644 --- a/src/lib/extension-draft.ts +++ b/src/lib/extension-draft.ts @@ -5,7 +5,6 @@ import { buildRequirements, ExtensionValidationError, } from './extension-form'; -import { isPackageIconPath } from './package-icon'; import { latestRelease } from './releases'; import { repositoryFromFields } from './repository'; import { withHttpsScheme } from './url-prefix'; @@ -75,7 +74,6 @@ export function editableContent(value: unknown): ExtensionUpdate | null { name: content.name, description: content.description, homepage_url: content.homepage_url, - icon_url: content.icon_url, readme: content.readme, license: content.license, source: content.source, @@ -85,8 +83,7 @@ export function editableContent(value: unknown): ExtensionUpdate | null { }; } -// Compacted or pre-v2 rejected submissions can lack required fields. Owners -// can rebuild these sections in a draft rather than losing the extension ID. +// A compacted rejected submission may need its missing fields filled again. export function reconstructionContent(value: unknown): ExtensionUpdate { const fields = record(value) ? value : {}; const string = (key: string) => @@ -104,8 +101,7 @@ export function reconstructionContent(value: unknown): ExtensionUpdate { type: isExtensionType(type) ? type : 'module', name: string('name'), description: string('description'), - homepage_url: string('homepage_url') || string('website') || undefined, - icon_url: string('icon_url') || undefined, + homepage_url: string('homepage_url') || undefined, readme: string('readme'), source: record(fields.source) && @@ -113,12 +109,7 @@ export function reconstructionContent(value: unknown): ExtensionUpdate { typeof fields.source.repo === 'string' ? (fields.source as ExtensionUpdate['source']) : { type: 'github', repo: '' }, - license: - typeof fields.license === 'string' - ? fields.license - : record(fields.license) - ? String(fields.license.spdx_id ?? fields.license.name ?? '') - : '', + license: string('license'), releases: Array.isArray(fields.releases) ? fields.releases.map((value) => { const release = record(value) ? value : {}; @@ -130,6 +121,13 @@ export function reconstructionContent(value: unknown): ExtensionUpdate { typeof release.changelog_url === 'string' ? release.changelog_url : undefined, + is_latest: release.is_latest === true ? true : undefined, + artifact_id: + typeof release.artifact_id === 'string' + ? release.artifact_id + : undefined, + sha256: + typeof release.sha256 === 'string' ? release.sha256 : undefined, requires: record(release.requires) ? (release.requires as ExtensionUpdate['requires']) : { fossbilling: '', php: '' }, @@ -176,7 +174,13 @@ function url( throw new ExtensionValidationError(`Enter a valid ${label}.`); } -export function releaseFromFields(fields: Record): Release { +export function releaseFromFields( + fields: Record, + packageFields?: Pick< + Release, + 'download_url' | 'artifact_id' | 'sha256' | 'is_latest' + >, +): Release { const date = required(fields, 'release_date', 'Release date'); if ( !/^\d{4}-\d{2}-\d{2}$/.test(date) || @@ -187,7 +191,13 @@ export function releaseFromFields(fields: Record): Release { return { tag: required(fields, 'version_tag', 'Version tag'), date, - download_url: url(fields, 'download_url', 'download URL')!, + download_url: packageFields + ? packageFields.download_url + : url(fields, 'download_url', 'download URL')!, + ...(packageFields?.artifact_id + ? { artifact_id: packageFields.artifact_id, sha256: packageFields.sha256 } + : {}), + ...(packageFields?.is_latest ? { is_latest: true } : {}), changelog_url: url(fields, 'changelog_url', 'changelog URL', true), requires: buildRequirements(toFormData(fields)), }; @@ -228,25 +238,11 @@ export function applyExtensionDraft( result.description = required(raw, 'description', 'Description'); result.type = type; result.homepage_url = url(raw, 'homepage_url', 'homepage URL', true); - const icon = text(raw, 'icon_url'); - if (icon && !isPackageIconPath(icon)) - throw new ExtensionValidationError( - 'Enter an icon path relative to the package root.', - ); - result.icon_url = icon || undefined; result.author = { name: required(raw, 'author_name', 'Author name'), url: url(raw, 'author_url', 'author URL', true), }; - if ( - [ - 'source_url', - 'license_spdx_id', - 'license_name_custom', - 'license_url', - ].some((key) => key in raw) - ) - applySourceAndLicense(result, raw); + applySourceAndLicense(result, raw); } else if (section === 'readme') { result.readme = required(raw, 'readme', 'README'); } else @@ -267,28 +263,36 @@ export function applyExtensionDraft( throw new ExtensionValidationError( 'This draft contains an invalid release.', ); - // Preserve untouched historical releases, including timestamp dates and - // HTTP URLs; only changed release fields need input normalization. - const original = base.releases.find((release) => release.tag === raw.tag); - const release = - original && JSON.stringify(original) === JSON.stringify(raw) - ? structuredClone(original) - : releaseFromFields({ - version_tag: raw.tag, - release_date: raw.date, - download_url: raw.download_url, - changelog_url: raw.changelog_url, - requires_fossbilling: record(raw.requires) - ? raw.requires.fossbilling - : undefined, - requires_php: record(raw.requires) ? raw.requires.php : undefined, - requires_modules: - record(raw.requires) && record(raw.requires.modules) - ? Object.entries(raw.requires.modules) - .map(([id, constraint]) => `${id}: ${constraint}`) - .join('\n') - : '', - }); + const release = releaseFromFields( + { + version_tag: raw.tag, + release_date: raw.date, + download_url: raw.download_url, + changelog_url: raw.changelog_url, + requires_fossbilling: record(raw.requires) + ? raw.requires.fossbilling + : undefined, + requires_php: record(raw.requires) ? raw.requires.php : undefined, + requires_modules: + record(raw.requires) && record(raw.requires.modules) + ? Object.entries(raw.requires.modules) + .map(([id, constraint]) => `${id}: ${constraint}`) + .join('\n') + : '', + }, + typeof raw.artifact_id === 'string' + ? { + artifact_id: raw.artifact_id, + sha256: typeof raw.sha256 === 'string' ? raw.sha256 : undefined, + download_url: String(raw.download_url ?? ''), + } + : undefined, + ); + if (raw.is_latest !== undefined && typeof raw.is_latest !== 'boolean') + throw new ExtensionValidationError( + 'This draft contains an invalid latest release selection.', + ); + if (raw.is_latest === true) release.is_latest = true; if (tags.has(release.tag.replace(/^v/, ''))) throw new ExtensionValidationError( `A release tagged "${release.tag}" already exists.`, @@ -297,7 +301,11 @@ export function applyExtensionDraft( return release; }); } - // Keep the primary download aligned with the latest release. + if ( + result.releases.filter((release) => release.is_latest === true).length > 1 + ) + throw new ExtensionValidationError('Select only one latest release.'); + // Keep the main download and manifest aligned with the selected release. if (Object.keys(value.sections).length || value.releases !== undefined) { const latest = latestRelease(result.releases); if (latest) { diff --git a/src/lib/extension-form.ts b/src/lib/extension-form.ts index 7bd472ad..1660e317 100644 --- a/src/lib/extension-form.ts +++ b/src/lib/extension-form.ts @@ -1,7 +1,11 @@ -import { isPackageIconPath } from './package-icon'; import { isExtensionType, SPDX_LICENSE_IDS } from '@/types'; -import type { Extension, Release } from '@/types'; -import type { ExtensionCreate, ExtensionUpdate } from '@/lib/api/client'; +import type { Extension } from '@/types'; +import type { + ExtensionCreate, + ExtensionUpdate, + Manifest, + PackageArtifact, +} from '@/lib/api/client'; import { latestRelease } from './releases'; import { repositoryFromFields, repositoryInputUrl } from './repository'; import { formString } from './form'; @@ -12,7 +16,14 @@ export type ExtensionFormEcho = Extension & { moduleRequirementsInput: string }; // Thrown for form-level validation failures that should be shown to the // user, distinct from ApiRequestError (the api rejecting an otherwise // well-formed payload) — see the try/catch in new.astro/edit.astro. -export class ExtensionValidationError extends Error {} +export class ExtensionValidationError extends Error { + constructor( + message: string, + readonly status = 422, + ) { + super(message); + } +} // The combobox offers SPDX identifiers; Other also accepts compound expressions. export const OTHER_LICENSE = 'other'; @@ -78,141 +89,99 @@ export function buildRequirements(form: FormData) { }; } -// Shared by both builders below. A new release is only appended when -// version_tag is filled — required when there are no existing releases to -// carry through (a brand new extension, or one resubmitting after rejection -// with nothing ever published), optional otherwise, since the api replaces -// the whole releases array on approval and existing releases are always -// carried through unchanged. -function buildContent( - form: FormData, - existing: { releases: Release[]; version: string; download_url: string }, - requireRelease: boolean, -) { - const str = (name: string) => formString(form, name); - const requires = buildRequirements(form); - - const releases = [...existing.releases]; - let version = existing.version; - let downloadUrl = existing.download_url; - - const newReleaseTag = str('version_tag'); - if (requireRelease && !newReleaseTag) { - throw new ExtensionValidationError( - 'An initial release (version, release date, and download URL) is required.', - ); - } - if (newReleaseTag) { - // The api stores at most 100 releases, and doesn't itself reject a tag - // collision — since it replaces the whole array verbatim on approval, a - // collision would silently duplicate the entry. Catch both here instead - // of only one of them surfacing as a 422 from the api. - if ( - releases.some( - (r) => r.tag.replace(/^v/, '') === newReleaseTag.replace(/^v/, ''), - ) - ) { - throw new ExtensionValidationError( - `A release tagged "${newReleaseTag}" already exists — use a different version tag.`, - ); - } - if (releases.length >= 100) { - throw new ExtensionValidationError( - 'This extension already has the maximum of 100 releases.', - ); - } - - const releaseDate = str('release_date'); - const releaseDownloadUrl = withHttpsScheme(str('download_url')); - if (!releaseDate || !releaseDownloadUrl) { - throw new ExtensionValidationError( - 'To add a new release, fill in the release date and download URL.', - ); - } - - const release: Release = { - tag: newReleaseTag, - date: releaseDate, - download_url: releaseDownloadUrl, - changelog_url: withHttpsScheme(str('changelog_url')), - requires, - }; - releases.push(release); - version = release.tag; - downloadUrl = release.download_url; - } - - const latest = latestRelease(releases); - if (latest) { - version = latest.tag.replace(/^v(?=\d)/, ''); - downloadUrl = latest.download_url; - } - - const typeInput = str('type'); - const sourceFields = Object.fromEntries(form); - const source = repositoryFromFields(sourceFields); +function listingFields(form: FormData) { + const source = repositoryFromFields(Object.fromEntries(form)); if (!source) throw new ExtensionValidationError( 'Enter a valid Source URL. Repository paths and custom URLs must be at most 500 characters.', ); + return { + ...manifestFields(form), + readme: formString(form, 'readme'), + source, + }; +} + +function manifestFields(form: FormData): Omit { + const str = (name: string) => formString(form, name); + const typeInput = str('type'); if (!isExtensionType(typeInput)) { throw new ExtensionValidationError('Choose a valid extension type.'); } - const icon = str('icon_url'); - if (icon && !isPackageIconPath(icon)) - throw new ExtensionValidationError( - 'Enter an icon path relative to the package root, such as icon.svg.', - ); if (!str('author_name')) throw new ExtensionValidationError('Enter the package author name.'); return { - manifest_version: 1 as const, + manifest_version: 1, author: { name: str('author_name'), url: withHttpsScheme(str('author_url')), }, - requires, + requires: buildRequirements(form), type: typeInput, name: str('name'), description: str('description'), - releases, homepage_url: withHttpsScheme(str('homepage_url')), license: buildLicense(form), - icon_url: icon || undefined, - readme: str('readme'), - source, - version, - download_url: downloadUrl, }; } -// Builds the payload for POST /extensions. The id is only ever set here — -// PUT can't rename, since the id comes from the path and is immutable. -export function buildExtensionCreatePayload(form: FormData): ExtensionCreate { +export function manifestFromCreateForm(form: FormData): Manifest { return { + ...manifestFields(form), id: formString(form, 'extension_id').toLowerCase(), - ...buildContent( - form, - { releases: [], version: '', download_url: '' }, - true, - ), + version: formString(form, 'version_tag').replace(/^v(?=\d)/, ''), }; } -// Builds the payload for PUT /extensions/{id}. `existing` is the extension's -// current published content, when it has any — an extension can be edited -// with nothing published yet (rejected and never resubmitted), in which case -// a release is required same as creation. +export function buildExtensionCreatePayload( + form: FormData, + artifact: PackageArtifact, +): ExtensionCreate { + if (!formString(form, 'version_tag') || !formString(form, 'release_date')) + throw new ExtensionValidationError( + 'An initial release and release date are required.', + ); + return { + id: formString(form, 'extension_id').toLowerCase(), + ...listingFields(form), + version: artifact.manifest.version, + requires: artifact.manifest.requires, + download_url: artifact.download_url, + releases: [ + { + tag: artifact.manifest.version, + date: formString(form, 'release_date'), + changelog_url: withHttpsScheme(formString(form, 'changelog_url')), + requires: artifact.manifest.requires, + artifact_id: artifact.id, + sha256: artifact.sha256, + download_url: artifact.download_url, + is_latest: true, + }, + ], + }; +} + +// Moderator corrections change listing metadata; release creation uses the release editor. export function buildExtensionUpdatePayload( form: FormData, - existing: Extension | null, + existing: Extension, ): ExtensionUpdate { - return buildContent( - form, - existing ?? { releases: [], version: '', download_url: '' }, - !existing?.releases.length, - ); + const fields = listingFields(form); + const latest = latestRelease(existing.releases); + if (!latest?.artifact_id) + throw new ExtensionValidationError( + 'The latest release needs a stored package before its metadata can be corrected.', + ); + return { + ...fields, + releases: existing.releases.map((release) => + release === latest ? { ...release, requires: fields.requires } : release, + ), + version: latest.tag.replace(/^v(?=\d)/, ''), + download_url: latest.download_url, + }; } // Best-effort echo of what the user submitted, spread over the extension's @@ -235,7 +204,7 @@ export function echoExtensionFromForm( name: formString(form, 'name'), description: formString(form, 'description'), homepage_url: formString(form, 'homepage_url'), - icon_url: formString(form, 'icon_url') || undefined, + readme: formString(form, 'readme'), manifest_version: 1, author: { @@ -258,16 +227,10 @@ export function echoExtensionFromForm( }; } -// The release-section input values as submitted, echoed alongside -// echoExtensionFromForm: the Extension model has nowhere to hold a pending -// release draft (version tag, date, URLs, minimum version), so without this -// a failed submission would redisplay that fieldset blank and discard it. -// Values are echoed exactly as typed — no scheme normalization — matching -// the best-effort redisplay contract above. +// Values needed to redisplay a failed initial-release submission. export interface ReleaseDraft { version_tag: string; release_date: string; - download_url: string; changelog_url: string; } @@ -275,7 +238,6 @@ export function echoReleaseDraftFromForm(form: FormData): ReleaseDraft { return { version_tag: formString(form, 'version_tag'), release_date: formString(form, 'release_date'), - download_url: formString(form, 'download_url'), changelog_url: formString(form, 'changelog_url'), }; } diff --git a/src/lib/extension-icon.ts b/src/lib/extension-icon.ts index 5870ff86..00d93df2 100644 --- a/src/lib/extension-icon.ts +++ b/src/lib/extension-icon.ts @@ -1,62 +1,26 @@ -import { isPackageIconPath } from './package-icon'; +import { latestRelease } from './releases'; +import { packageDownloadPath } from './package-download'; +import type { Release } from '@/types'; +import { isDirectoryAssetUrl } from './image-url'; -/** Resolve manifest paths from supported GitHub release assets; retain legacy URLs. */ +/** Package icons are discovered from fixed filenames by the package service. */ export function extensionIconUrl(extension: { - id: string; icon_url?: string; - download_url?: string | null; - source: { type: string; repo: string }; + releases?: Release[]; }): string | undefined { - const icon = extension.icon_url; - if (!icon || /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(icon)) - return undefined; + if (extension.icon_url && isDirectoryAssetUrl(extension.icon_url)) + return extension.icon_url; + const packagePath = packageDownloadPath( + latestRelease(extension.releases ?? [])?.artifact_id, + ); + if (packagePath) return `${packagePath}/icon`; try { - const absolute = new URL(icon); - return /^https?:$/.test(absolute.protocol) && - !absolute.username && - !absolute.password - ? absolute.href + const url = new URL(extension.icon_url ?? ''); + return ['http:', 'https:'].includes(url.protocol) && + !url.username && + !url.password + ? url.href : undefined; - } catch { - // Manifest icons are package-relative; do not resolve them against the site. - } - if (!isPackageIconPath(icon)) return undefined; - if ( - extension.source.type !== 'github' || - !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(extension.source.repo) - ) - return undefined; - try { - const download = new URL(extension.download_url ?? ''); - if ( - download.origin !== 'https://github.com' || - download.username || - download.password - ) - return undefined; - const [owner, repo] = extension.source.repo.split('/'); - const parts = download.pathname.split('/').slice(1); - if ( - parts[0].toLowerCase() !== owner.toLowerCase() || - parts[1].toLowerCase() !== repo.toLowerCase() || - parts[2] !== 'releases' || - parts[3] !== 'download' || - parts.length !== 6 - ) - return undefined; - const ref = decodeURIComponent(parts[4]); - // GitHub refs that look like paths need a separate explicit API contract. - if (!ref || ref === '.' || ref === '..' || /[/\\]/.test(ref)) - return undefined; - const packageRoot = - extension.source.repo.toLowerCase() === 'fossbilling/modules' - ? extension.id.charAt(0).toUpperCase() + extension.id.slice(1) - : ''; - const path = [packageRoot, ...icon.split('/')] - .filter(Boolean) - .map(encodeURIComponent) - .join('/'); - return `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(ref)}/${path}`; } catch { return undefined; } diff --git a/src/lib/extension-package.ts b/src/lib/extension-package.ts new file mode 100644 index 00000000..9651e799 --- /dev/null +++ b/src/lib/extension-package.ts @@ -0,0 +1,85 @@ +import type { + ExtensionUpdate, + Manifest, + PackageArtifact, + createApiClient, +} from '@/lib/api/client'; +import { packageDownloadPath } from './package-download'; +import { latestRelease } from './releases'; +import type { Release } from '@/types'; + +export const MAX_SOURCE_BYTES = 5 * 1024 * 1024; +export const MAX_PACKAGE_REQUEST_BYTES = 6 * 1024 * 1024; + +export function packageManifest( + id: string, + content: ExtensionUpdate, + release = latestRelease(content.releases), +): Manifest { + return { + manifest_version: 1, + id: id.toLowerCase(), + type: content.type, + name: content.name, + description: content.description, + author: content.author, + license: content.license, + homepage_url: content.homepage_url, + version: (release?.tag ?? content.version).replace(/^v(?=\d)/, ''), + requires: release?.requires ?? content.requires, + }; +} + +function canonical(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') + return `{${Object.entries(value) + .filter(([, value]) => value !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + .map(([key, value]) => `${JSON.stringify(key)}:${canonical(value)}`) + .join(',')}}`; + return JSON.stringify(value); +} + +export function artifactRelease( + release: Release, + artifact: PackageArtifact, +): Release { + return { + ...release, + artifact_id: artifact.id, + sha256: artifact.sha256, + download_url: artifact.download_url, + requires: artifact.manifest.requires, + }; +} + +export function releaseDownloadHref( + release: Pick, +): string { + return packageDownloadPath(release.artifact_id) ?? release.download_url; +} + +// Only manifest-backed changes rebuild the latest artifact. Directory-only +// edits (README/source links/dates/changelogs) preserve the original package. +export async function rebuildLatestPackage( + api: Pick, 'getPackage' | 'buildPackage'>, + id: string, + content: ExtensionUpdate, +): Promise { + const latest = latestRelease(content.releases); + if (!latest?.artifact_id) return content; + const previous = await api.getPackage(latest.artifact_id); + const manifest = packageManifest(id, content, latest); + if (canonical(previous.manifest) === canonical(manifest)) return content; + const artifact = await api.buildPackage(manifest, { + sourceId: previous.source_id, + }); + return { + ...content, + releases: content.releases.map((release) => + release === latest ? artifactRelease(release, artifact) : release, + ), + download_url: artifact.download_url, + }; +} diff --git a/src/lib/image-url.ts b/src/lib/image-url.ts index 473f2508..ec65fcce 100644 --- a/src/lib/image-url.ts +++ b/src/lib/image-url.ts @@ -21,6 +21,28 @@ const IMAGE_SOURCE_HOSTS = [ export type ImageVariant = keyof typeof IMAGE_VARIANTS; +export function isDirectoryAssetUrl(source: string): boolean { + try { + const url = new URL(source); + return ( + url.origin === 'https://extensions-assets.fossbilling.net' && + !url.username && + !url.password && + !url.search && + !url.hash && + /^\/[\w-]+\/icons\/[a-f0-9]{64}\.(svg|png|avif)$/.test(url.pathname) + ); + } catch { + return false; + } +} + +export function directoryAssetAvifUrl(source?: string): string | undefined { + return source && isDirectoryAssetUrl(source) && source.endsWith('.png') + ? source.replace(/\.png$/, '.avif') + : undefined; +} + export function isImageVariant( value: string | undefined, ): value is ImageVariant { @@ -38,6 +60,7 @@ function isAllowedImageHost(hostname: string): boolean { } export function isTransformableImageSource(sourceUrl: string): boolean { + if (isDirectoryAssetUrl(sourceUrl)) return false; try { const url = new URL(sourceUrl); return ( @@ -61,6 +84,12 @@ export function getOptimizedImageUrl( if (!source) { return undefined; } + if ( + /^\/package\/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\/icon$/i.test( + source, + ) + ) + return source; try { const url = new URL(source); diff --git a/src/lib/package-download.ts b/src/lib/package-download.ts new file mode 100644 index 00000000..4d33ac64 --- /dev/null +++ b/src/lib/package-download.ts @@ -0,0 +1,11 @@ +// Only these exact same-origin paths bypass HTTP URL checks in review links. +const artifactIdPattern = + '[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}'; +const idPattern = new RegExp(`^${artifactIdPattern}$`, 'i'); +const pathPattern = new RegExp(`^/package/${artifactIdPattern}$`, 'i'); +export function packageDownloadPath(id: unknown): string | null { + return typeof id === 'string' && idPattern.test(id) ? `/package/${id}` : null; +} +export function isPackageDownloadPath(value: string): boolean { + return pathPattern.test(value); +} diff --git a/src/lib/package-icon.ts b/src/lib/package-icon.ts deleted file mode 100644 index 7116ae5a..00000000 --- a/src/lib/package-icon.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** The manifest icon must stay within the package root. */ -export function isPackageIconPath(path: string): boolean { - return ( - path.length > 0 && - path.length <= 2048 && - !path.startsWith('/') && - !path.includes('\\') && - !path.includes('..') && - !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path) && - !/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(path) - ); -} diff --git a/src/lib/package-job.ts b/src/lib/package-job.ts new file mode 100644 index 00000000..09d2abca --- /dev/null +++ b/src/lib/package-job.ts @@ -0,0 +1,46 @@ +import type { + PackageJob, + PackageArtifact, +} from '@/lib/api/generated/extensions-v2'; + +export class PackageJobError extends Error { + constructor( + public status: number, + public code: string, + message: string, + ) { + super(message); + } +} + +export async function waitForPackageJob( + initial: PackageJob, + read: (id: string) => Promise, + options: { + attempts?: number; + pause?: () => Promise; + progress?: (job: PackageJob) => void; + } = {}, +): Promise { + let job = initial; + const attempts = options.attempts ?? 120; + for (let attempt = 0; attempt <= attempts; attempt++) { + options.progress?.(job); + if (job.status === 'ready' && job.artifact) return job.artifact; + if (job.status === 'failed') + throw new PackageJobError( + job.error?.status ?? 422, + job.error?.code ?? 'PACKAGE_FAILED', + job.error?.message ?? 'Package preparation failed.', + ); + if (attempt === attempts) break; + await (options.pause?.() ?? + new Promise((resolve) => setTimeout(resolve, 1000))); + job = await read(job.id); + } + throw new PackageJobError( + 503, + 'PACKAGE_PROCESSING', + 'Your package is still processing. Your edits have been kept. Resume processing in a moment.', + ); +} diff --git a/src/lib/package-response.ts b/src/lib/package-response.ts new file mode 100644 index 00000000..d6850cd8 --- /dev/null +++ b/src/lib/package-response.ts @@ -0,0 +1,54 @@ +import type { APIContext } from 'astro'; +import { getSessionUser } from '@/lib/session'; +import { ApiRequestError, downloadPackage } from '@/lib/api/client'; + +export async function packageResponse( + context: APIContext, + resource: 'download' | 'icon' = 'download', +): Promise { + const { packageId } = context.params; + if (!packageId) return new Response(null, { status: 404 }); + const env = context.locals.env; + try { + const user = await getSessionUser(context.cookies, env.sessionSecret); + const upstream = await downloadPackage( + env, + packageId, + user?.sub, + resource, + resource === 'icon' + ? (context.request.headers.get('Accept') ?? 'image/png') + : undefined, + ); + const headers = new Headers({ + 'Cache-Control': 'private, no-store', + 'Cloudflare-CDN-Cache-Control': 'no-store', + Vary: resource === 'icon' ? 'Cookie, Accept' : 'Cookie', + 'X-Content-Type-Options': 'nosniff', + }); + for (const name of [ + 'Content-Type', + 'Content-Disposition', + 'ETag', + 'Content-Length', + 'Content-Security-Policy', + ]) { + const value = upstream.headers.get(name); + if (value) headers.set(name, value); + } + return new Response(upstream.body, { headers }); + } catch (error) { + return new Response( + error instanceof ApiRequestError && error.status === 404 + ? 'Package not found.' + : 'This package is temporarily unavailable. Please try again.', + { + status: error instanceof ApiRequestError ? error.status : 503, + headers: { + 'Cache-Control': 'private, no-store', + 'Cloudflare-CDN-Cache-Control': 'no-store', + }, + }, + ); + } +} diff --git a/src/lib/package-upload.ts b/src/lib/package-upload.ts new file mode 100644 index 00000000..437cdc13 --- /dev/null +++ b/src/lib/package-upload.ts @@ -0,0 +1,65 @@ +import { ExtensionValidationError } from './extension-form'; +import { + MAX_PACKAGE_REQUEST_BYTES, + MAX_SOURCE_BYTES, +} from './extension-package'; + +export async function packageFormData(request: Request): Promise { + const reader = request.body?.getReader(); + if (!reader) throw new ExtensionValidationError('The upload is empty.'); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_PACKAGE_REQUEST_BYTES) { + await reader.cancel(); + throw new ExtensionValidationError( + 'The upload is too large. Source ZIPs must be 5 MiB or smaller.', + 413, + ); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + try { + return await new Response(bytes, { + headers: { 'Content-Type': request.headers.get('Content-Type') ?? '' }, + }).formData(); + } catch { + throw new ExtensionValidationError( + 'The upload could not be read. Please try again.', + ); + } +} + +export function sourceFile(form: FormData, required = false): File | undefined { + const source = form.get('source'); + if (!(source instanceof File) || !source.size) { + if (required) + throw new ExtensionValidationError( + 'Choose a source ZIP for this release.', + ); + return undefined; + } + if (source.size > MAX_SOURCE_BYTES) + throw new ExtensionValidationError( + 'Source ZIPs must be 5 MiB or smaller.', + 413, + ); + if (!source.name.toLowerCase().endsWith('.zip')) + throw new ExtensionValidationError( + 'Choose a ZIP file containing the extension source.', + ); + return source; +} diff --git a/src/lib/releases.ts b/src/lib/releases.ts index 0c2b7a6d..87603c95 100644 --- a/src/lib/releases.ts +++ b/src/lib/releases.ts @@ -1,3 +1,4 @@ +import prerelease from 'semver/functions/prerelease.js'; import valid from 'semver/functions/valid.js'; import rcompare from 'semver/functions/rcompare.js'; import type { Release } from '@/types'; @@ -24,7 +25,12 @@ function compareReleases(a: Release, b: Release): number { export function latestRelease( releases: readonly Release[], ): Release | undefined { - return releases.reduce( + const selected = releases.find((release) => release.is_latest === true); + if (selected) return selected; + const stable = releases.filter( + (release) => valid(release.tag) && prerelease(release.tag) === null, + ); + return (stable.length ? stable : releases).reduce( (latest, release) => !latest || compareReleases(release, latest) < 0 ? release : latest, undefined, @@ -34,3 +40,18 @@ export function latestRelease( export function releasesDescending(releases: readonly Release[]): Release[] { return [...releases].sort(compareReleases); } + +export function selectLatestRelease( + releases: readonly Release[], + tag: string, +): Release[] { + if (!releases.some((release) => release.tag === tag)) + throw new Error('Choose an existing release.'); + return releases.map(({ is_latest: _selected, ...release }) => + release.tag === tag ? { ...release, is_latest: true } : release, + ); +} + +export function defaultLatestForVersion(tag: string): boolean { + return !valid(tag) || prerelease(tag) === null; +} diff --git a/src/lib/revision-diff-shared.ts b/src/lib/revision-diff-shared.ts index 5104b672..97b16232 100644 --- a/src/lib/revision-diff-shared.ts +++ b/src/lib/revision-diff-shared.ts @@ -1,3 +1,4 @@ +import { packageDownloadPath } from './package-download'; // Pure field registry + label/compare helpers shared by the server diff // (revision-diff.ts) and the browser queue client // (admin-revisions-client.ts). Zero runtime dependencies by design: the @@ -23,6 +24,8 @@ interface DiffLicense { interface DiffRelease { tag?: string; + download_url?: string; + artifact_id?: string; } interface DiffContent { @@ -46,7 +49,6 @@ export const DIFF_FIELDS = [ 'requires', 'homepage_url', 'download_url', - 'icon_url', 'source', 'license', 'releases', @@ -60,7 +62,6 @@ export type DiffField = (typeof DIFF_FIELDS)[number]; export const URL_FIELDS: ReadonlySet = new Set([ 'homepage_url', 'download_url', - 'icon_url', ]); // Plain-text diff values longer than this render truncated behind a @@ -86,7 +87,6 @@ export const FIELD_LABELS: Record = { requires: 'Requirements', homepage_url: 'Website', download_url: 'Download URL', - icon_url: 'Icon URL', source: 'Source Repo', license: 'License', releases: 'Releases', @@ -234,6 +234,14 @@ export function fieldUrl( ): string | null { if (!content) return null; switch (field) { + case 'download_url': { + const release = content.releases?.find( + (release) => + release?.download_url === content.download_url && + release?.artifact_id, + ); + return packageDownloadPath(release?.artifact_id); + } case 'author': return scalarLabel(content.author?.url); case 'license': diff --git a/src/middleware.ts b/src/middleware.ts index 242c568f..873f4e27 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -12,7 +12,8 @@ function applySecurityHeaders(response: Response): Response { target.headers.set('X-Content-Type-Options', 'nosniff'); target.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); target.headers.set('X-Frame-Options', 'DENY'); - target.headers.set('Content-Security-Policy', "frame-ancestors 'none'"); + // Keep resource-specific restrictions, including the package icon sandbox. + target.headers.append('Content-Security-Policy', "frame-ancestors 'none'"); }; try { apply(response); diff --git a/src/pages/account/admin/extensions/[id]/edit.astro b/src/pages/account/admin/extensions/[id]/edit.astro index f02fc4ad..6a9b1feb 100644 --- a/src/pages/account/admin/extensions/[id]/edit.astro +++ b/src/pages/account/admin/extensions/[id]/edit.astro @@ -14,11 +14,10 @@ import { import { buildExtensionUpdatePayload, echoExtensionFromForm, - echoReleaseDraftFromForm, ExtensionValidationError, - type ReleaseDraft, } from '@/lib/extension-form'; import { formString } from '@/lib/form'; +import { rebuildLatestPackage } from '@/lib/extension-package'; import { formatDate } from '@/lib/format-date'; import { setFlash } from '@/lib/flash'; import { purgeDirectory } from '@/lib/cache-invalidate'; @@ -69,7 +68,6 @@ let error: string | null = null; // submitted echo as the author edit page. let submitted: ReturnType | null = null; let submittedNote = ''; -let submittedReleaseDraft: ReleaseDraft | null = null; if ( !loadError && @@ -80,14 +78,17 @@ if ( ) { const form = await Astro.request.formData(); submitted = echoExtensionFromForm(form, base); - submittedReleaseDraft = echoReleaseDraftFromForm(form); submittedNote = formString(form, 'correction_note'); try { if (!submittedNote) { throw new ExtensionValidationError('A correction note is required.'); } - const payload = buildExtensionUpdatePayload(form, base); + const payload = await rebuildLatestPackage( + api, + owned.id, + buildExtensionUpdatePayload(form, base), + ); await api.correctExtension(owned.id, payload, submittedNote); purgeDirectory(Astro); await setFlash(Astro, env.sessionSecret, { @@ -193,7 +194,6 @@ const title = base?.name || owned?.id || id; developer={owned.developer} extension={base} echo={submitted ?? undefined} - releaseDraft={submittedReleaseDraft ?? undefined} error={error} submitLabel="Correct Live Listing" cancelHref={`/account/admin/extensions/${owned.id}`} diff --git a/src/pages/account/extensions/new.astro b/src/pages/account/extensions/new.astro index 07838d7a..2a12337a 100644 --- a/src/pages/account/extensions/new.astro +++ b/src/pages/account/extensions/new.astro @@ -10,9 +10,11 @@ import { import { getDeveloperByOwner } from '@/lib/extensions-data'; import { buildExtensionCreatePayload, + manifestFromCreateForm, echoNewExtensionFromForm, ExtensionValidationError, } from '@/lib/extension-form'; +import { packageFormData, sourceFile } from '@/lib/package-upload'; import { setFlash } from '@/lib/flash'; const env = Astro.locals.env; @@ -31,11 +33,13 @@ let error: string | null = null; let submitted: ReturnType | null = null; if (Astro.request.method === 'POST') { - const form = await Astro.request.formData(); - submitted = echoNewExtensionFromForm(form, developer); const api = createApiClient(env, user.sub); try { - const payload = buildExtensionCreatePayload(form); + const form = await packageFormData(Astro.request); + submitted = echoNewExtensionFromForm(form, developer); + const file = sourceFile(form, true)!; + const artifact = await api.buildPackage(manifestFromCreateForm(form), file); + const payload = buildExtensionCreatePayload(form, artifact); await api.createExtension(payload); await setFlash(Astro, Astro.locals.env.sessionSecret, { title: 'Submitted for Review.', diff --git a/src/pages/extension/[id].astro b/src/pages/extension/[id].astro index d6006b00..cb36d6fe 100644 --- a/src/pages/extension/[id].astro +++ b/src/pages/extension/[id].astro @@ -53,13 +53,11 @@ let shareImage: string | undefined; const metadataIcon = metadata ? extensionIconUrl({ ...metadata, - id, - source: metadata.source ?? { type: 'custom', repo: '' }, }) : undefined; if (metadataIcon) { try { - shareImage = new URL(metadataIcon).href; + shareImage = new URL(metadataIcon, Astro.url.origin).href; } catch { /* Omit malformed stored URLs. */ } diff --git a/src/pages/extension/[id]/draft.ts b/src/pages/extension/[id]/draft.ts index 06b8f95e..12e379b8 100644 --- a/src/pages/extension/[id]/draft.ts +++ b/src/pages/extension/[id]/draft.ts @@ -5,13 +5,9 @@ import { ApiRequestError, getApiErrorMessage, } from '@/lib/api/client'; -import { - applyExtensionDraft, - editableContent, - reconstructionContent, -} from '@/lib/extension-draft'; +import { prepareExtensionDraft } from '@/lib/extension-draft-server'; +import { rebuildLatestPackage } from '@/lib/extension-package'; import { ExtensionValidationError } from '@/lib/extension-form'; -import { sha256Hex } from '@/lib/hash'; import { formString } from '@/lib/form'; import { setFlash } from '@/lib/flash'; @@ -36,36 +32,14 @@ export const POST: APIRoute = async (context) => { return fail('This draft could not be read.'); } const api = createApiClient(env, guard.sub); - const owned = await api.getMyExtension(id); - const pending = owned.pending_revision; - if ((draft?.pendingRevisionId ?? null) !== (pending?.id ?? null)) - return fail( - 'The pending submission changed or was reviewed. Your local draft has been kept. Reload the overview before editing again.', - 409, - ); - const previous = - !pending && !owned.published && owned.last_review - ? await api.getRevision(id, owned.last_review.revision_id) - : null; - const stored = pending?.content ?? owned.published ?? previous?.content; - const base = - editableContent(stored) ?? - (pending || !owned.published - ? editableContent(reconstructionContent(stored)) - : null); - if (!base) - return fail( - 'The original content is unavailable. Your local draft has been kept.', - 409, - ); - if (draft?.baselineHash !== (await sha256Hex(JSON.stringify(base)))) - return fail( - 'This extension changed since you started editing. Your local draft has been kept. Reload the overview to review it.', - 409, - ); - const payload = applyExtensionDraft(base, draft); - if (JSON.stringify(payload) === JSON.stringify(base)) + const { + base, + payload: changed, + pending, + } = await prepareExtensionDraft(api, id, draft); + if (JSON.stringify(changed) === JSON.stringify(base)) return fail('There are no changes to submit.'); + const payload = await rebuildLatestPackage(api, id, changed); if (pending) await api.replacePendingRevision(id, pending.id, payload); else await api.updateExtension(id, payload); await setFlash(context, env.sessionSecret, { @@ -78,7 +52,8 @@ export const POST: APIRoute = async (context) => { { headers: { 'Cache-Control': 'no-store' } }, ); } catch (error) { - if (error instanceof ExtensionValidationError) return fail(error.message); + if (error instanceof ExtensionValidationError) + return fail(error.message, error.status); if (error instanceof ApiRequestError) return fail(getApiErrorMessage(error), error.status); return fail( diff --git a/src/pages/extension/[id]/package.ts b/src/pages/extension/[id]/package.ts new file mode 100644 index 00000000..b47a014c --- /dev/null +++ b/src/pages/extension/[id]/package.ts @@ -0,0 +1,124 @@ +import type { APIRoute } from 'astro'; +import { requireUser } from '@/lib/auth-guard'; +import { + createApiClient, + ApiRequestError, + getApiErrorMessage, +} from '@/lib/api/client'; +import { prepareExtensionDraft } from '@/lib/extension-draft-server'; +import { releaseFromFields } from '@/lib/extension-draft'; +import { packageManifest } from '@/lib/extension-package'; +import { ExtensionValidationError } from '@/lib/extension-form'; +import { packageFormData, sourceFile } from '@/lib/package-upload'; +import { latestRelease } from '@/lib/releases'; +import { formString } from '@/lib/form'; + +export const POST: APIRoute = async (context) => { + const env = context.locals.env; + const guard = await requireUser(context, env); + if (guard instanceof Response) return guard; + const id = context.params.id; + if (!id) return new Response(null, { status: 404 }); + const headers = { 'Cache-Control': 'private, no-store' }; + try { + const form = await packageFormData(context.request); + let draft: unknown, fields: Record; + try { + draft = JSON.parse(formString(form, 'draft')); + fields = JSON.parse(formString(form, 'release')); + } catch { + throw new ExtensionValidationError('The release could not be read.'); + } + if (!fields || typeof fields !== 'object' || Array.isArray(fields)) + throw new ExtensionValidationError('The release could not be read.'); + const api = createApiClient(env, guard.sub); + const { payload } = await prepareExtensionDraft(api, id, draft); + const original = payload.releases.find( + (release) => release.tag === fields.original_tag, + ); + if (fields.original_tag && !original) + throw new ExtensionValidationError( + 'This release was removed. Close the editor and choose another release.', + 409, + ); + if (original && fields.version_tag !== original.tag) + throw new ExtensionValidationError( + 'Add a new release to use a different version.', + ); + const release = releaseFromFields(fields, original ?? { download_url: '' }); + if ( + payload.releases.some( + (item) => + item.tag.replace(/^v/, '') === release.tag.replace(/^v/, '') && + item !== original, + ) + ) + throw new ExtensionValidationError( + 'A release with this version already exists.', + ); + if (!original && payload.releases.length >= 100) + throw new ExtensionValidationError('Keep at most 100 releases.'); + const source = sourceFile(form, !original?.artifact_id); + const stored = + !source && original?.artifact_id + ? await api.getPackage(original.artifact_id) + : undefined; + const manifest = + stored && original?.tag !== latestRelease(payload.releases)?.tag + ? { ...stored.manifest, requires: release.requires } + : packageManifest(id, payload, release); + const job = await api.queuePackage( + manifest, + source ?? { sourceId: stored!.source_id }, + ); + return Response.json({ release, job }, { headers, status: 202 }); + } catch (error) { + const message = + error instanceof ApiRequestError + ? getApiErrorMessage(error) + : error instanceof ExtensionValidationError + ? error.message + : 'Unable to prepare this release. Your edits have been kept. Please try again.'; + const status = + error instanceof ApiRequestError || + error instanceof ExtensionValidationError + ? error.status + : 503; + return Response.json({ error: message }, { status, headers }); + } +}; + +export const GET: APIRoute = async (context) => { + const env = context.locals.env; + const guard = await requireUser(context, env); + if (guard instanceof Response) return guard; + const jobId = context.url.searchParams.get('jobId'); + const headers = { 'Cache-Control': 'private, no-store' }; + if (!jobId || !/^[a-f0-9-]{36}$/.test(jobId)) + return Response.json( + { error: 'Invalid package job.' }, + { status: 400, headers }, + ); + try { + const job = await createApiClient(env, guard.sub).getPackageJob(jobId); + if (job.extension_id !== context.params.id) + return Response.json( + { error: 'Package job not found.' }, + { status: 404, headers }, + ); + return Response.json({ job }, { headers }); + } catch (error) { + return Response.json( + { + error: + error instanceof ApiRequestError + ? getApiErrorMessage(error) + : 'Unable to read package processing status.', + }, + { + status: error instanceof ApiRequestError ? error.status : 503, + headers, + }, + ); + } +}; diff --git a/src/pages/package/[packageId].ts b/src/pages/package/[packageId].ts new file mode 100644 index 00000000..ad2056b3 --- /dev/null +++ b/src/pages/package/[packageId].ts @@ -0,0 +1,3 @@ +import type { APIRoute } from 'astro'; +import { packageResponse } from '@/lib/package-response'; +export const GET: APIRoute = (context) => packageResponse(context); diff --git a/src/pages/package/[packageId]/icon.ts b/src/pages/package/[packageId]/icon.ts new file mode 100644 index 00000000..3a36f598 --- /dev/null +++ b/src/pages/package/[packageId]/icon.ts @@ -0,0 +1,3 @@ +import type { APIRoute } from 'astro'; +import { packageResponse } from '@/lib/package-response'; +export const GET: APIRoute = (context) => packageResponse(context, 'icon'); diff --git a/src/scripts/extension-workspace.ts b/src/scripts/extension-workspace.ts index 5a2b0cd6..6126881a 100644 --- a/src/scripts/extension-workspace.ts +++ b/src/scripts/extension-workspace.ts @@ -1,12 +1,27 @@ +import { waitForPackageJob, PackageJobError } from '@/lib/package-job'; +import type { PackageJob } from '@/lib/api/client'; +import type { Release } from '@/types'; +import { extensionIconUrl } from '@/lib/extension-icon'; +import { directoryAssetAvifUrl } from '@/lib/image-url'; import { applyExtensionDraft, releaseFromFields, type ExtensionDraft, type DraftSection, } from '@/lib/extension-draft'; +import { + artifactRelease, + releaseDownloadHref, + MAX_SOURCE_BYTES, +} from '@/lib/extension-package'; import { formatModuleRequirements } from '@/lib/extension-form'; import { compatibilityLabel } from '@/lib/requirements'; -import { latestRelease, releasesDescending } from '@/lib/releases'; +import { + defaultLatestForVersion, + latestRelease, + releasesDescending, + selectLatestRelease, +} from '@/lib/releases'; import type { ExtensionUpdate } from '@/lib/api/client'; import { EXTENSION_TYPE_LABELS, repositoryURL } from '@/types'; import { licenseUrl } from '@/lib/license'; @@ -23,6 +38,7 @@ export function initExtensionWorkspace() { if (!configNode) return; const config = JSON.parse(configNode.textContent!) as { base: ExtensionUpdate; + packageUrl: string; developerName?: string; baselineHash: string; pendingRevisionId?: string; @@ -51,11 +67,34 @@ export function initExtensionWorkspace() { let stored = false; let blocked = false; let busy = false; + type PendingPackage = { + job: PackageJob; + release: Release; + input: Record; + baselineHash: string; + pendingRevisionId?: string; + }; + let pendingPackage: PendingPackage | null = null; + let packagePolling = false; + const packageKey = config.key + ':package'; const error = (text: string) => { message.textContent = text; message.hidden = !text; }; try { + const packageRaw = localStorage.getItem(packageKey); + if (packageRaw) { + pendingPackage = JSON.parse(packageRaw); + if ( + pendingPackage?.baselineHash !== config.baselineHash || + pendingPackage?.pendingRevisionId !== config.pendingRevisionId + ) { + blocked = true; + error( + 'The extension changed while a package was processing. Your saved package reference has been kept. Discard the old draft before editing again.', + ); + } + } const raw = localStorage.getItem(config.key); if (raw) { stored = true; @@ -95,16 +134,30 @@ export function initExtensionWorkspace() { } catch { /* Invalid stored draft stays blocked. */ } - submit.disabled = busy || blocked || !changed || unsaved.size > 0; - discard.disabled = busy || (!stored && !unsaved.size && !blocked); - draftBar.hidden = !changed && !unsaved.size && !blocked && !busy; + submit.disabled = + busy || blocked || !!pendingPackage || !changed || unsaved.size > 0; + discard.disabled = + busy || + packagePolling || + (!stored && !unsaved.size && !blocked && !pendingPackage); + draftBar.hidden = + !changed && !unsaved.size && !blocked && !busy && !pendingPackage; + root + .querySelectorAll( + '[data-release-action],[data-add-release],[data-cancel-release]', + ) + .forEach((button) => { + button.disabled = busy || !!pendingPackage; + }); status.textContent = blocked ? 'Draft Needs Attention' - : unsaved.size - ? 'Unsaved Section Edits' - : changed - ? 'Draft Saved in This Browser' - : 'No Local Changes'; + : pendingPackage + ? 'Package Processing' + : unsaved.size + ? 'Unsaved Section Edits' + : changed + ? 'Draft Saved in This Browser' + : 'No Local Changes'; } function save(next: ExtensionDraft) { if (blocked || busy) @@ -134,6 +187,27 @@ export function initExtensionWorkspace() { const current = content(); if (headerName) headerName.textContent = current.name || fallbackName; const latest = latestRelease(current.releases); + const headerIcon = document.querySelector( + '[data-extension-icon]', + ); + const iconFallback = document.querySelector( + '[data-extension-icon-fallback]', + ); + if (headerIcon && iconFallback) { + const source = + extensionIconUrl(current) ?? headerIcon.dataset.extensionIconSource; + headerIcon.hidden = !source; + iconFallback.hidden = Boolean(source); + if (source) headerIcon.src = source; + else headerIcon.removeAttribute('src'); + const avif = document.querySelector( + '[data-extension-icon-avif]', + ); + const avifUrl = directoryAssetAvifUrl(source); + if (avifUrl) avif?.setAttribute('srcset', avifUrl); + else avif?.removeAttribute('srcset'); + } + const requires = latest?.requires ?? current.requires; const version = latest?.tag ?? current.version; const headerVersion = document.querySelector( @@ -147,7 +221,9 @@ export function initExtensionWorkspace() { if (value) value.textContent = /^\d/.test(version) ? `v${version}` : version; } - const downloadUrl = latest?.download_url ?? current.download_url; + const downloadUrl = latest + ? new URL(releaseDownloadHref(latest), window.location.href).href + : current.download_url; const headerDownload = document.querySelector( '[data-extension-download]', ); @@ -166,10 +242,6 @@ export function initExtensionWorkspace() { else websiteLink.removeAttribute('href'); websiteLink.classList.toggle('underline', safe); } - text('[data-preview-icon]', current.icon_url || ''); - const iconUrlDetail = find('[data-icon-url-detail]'); - if (iconUrlDetail) iconUrlDetail.hidden = !current.icon_url; - const sourceUrl = repositoryURL(current.source); const github = current.source.type === 'github'; text('[data-preview-source]', github ? current.source.repo : sourceUrl); @@ -337,6 +409,22 @@ export function initExtensionWorkspace() { const releaseForm = find('[data-release-form]'); const releaseEditor = find('[data-release-editor]'); + const releaseFields = find('[data-release-fields]'); + let latestChoiceTouched = false; + const latestCheckbox = releaseForm?.elements.namedItem( + 'is_latest', + ) as HTMLInputElement | null; + latestCheckbox?.addEventListener('change', () => { + latestChoiceTouched = true; + }); + ( + releaseForm?.elements.namedItem('version_tag') as HTMLInputElement | null + )?.addEventListener('input', (event) => { + if (latestCheckbox && !latestChoiceTouched) + latestCheckbox.checked = defaultLatestForVersion( + (event.target as HTMLInputElement).value, + ); + }); function editRelease(tag?: string) { if (!releaseForm) return; if ( @@ -345,18 +433,46 @@ export function initExtensionWorkspace() { ) return; releaseForm.reset(); + latestChoiceTouched = false; const current = content(); const release = current.releases.find((item) => item.tag === tag); fill(releaseForm, { original_tag: tag ?? '', version_tag: release?.tag ?? '', release_date: release?.date.slice(0, 10) ?? '', - download_url: release?.download_url ?? '', changelog_url: release?.changelog_url ?? '', requires_fossbilling: release?.requires.fossbilling ?? '', requires_php: release?.requires.php ?? '', requires_modules: formatModuleRequirements(release?.requires.modules), }); + if (latestCheckbox) { + latestCheckbox.checked = + !release || release.tag === latestRelease(current.releases)?.tag; + const hasPackages = current.releases.some((item) => item.artifact_id); + const selectable = current.releases.filter( + (item) => !hasPackages || item.artifact_id, + ); + latestCheckbox.disabled = Boolean( + release && latestCheckbox.checked && selectable.length <= 1, + ); + latestCheckbox.title = latestCheckbox.disabled + ? 'Keep one latest release. Add another package before changing this selection.' + : ''; + } + const versionField = releaseForm.elements.namedItem( + 'version_tag', + ) as HTMLInputElement; + versionField.readOnly = Boolean(release); + const file = releaseForm.elements.namedItem('source') as HTMLInputElement; + file.required = !release?.artifact_id; + file.disabled = Boolean(release?.artifact_id); + find('[data-source-control]').hidden = Boolean(release?.artifact_id); + find('[data-source-help]').textContent = release?.artifact_id + ? 'The stored source will be reused. Add a new release to change files.' + : 'Upload ready-to-install files, without a wrapping directory. Maximum 5 MiB.'; + if (!release?.artifact_id) + find('[data-source-help]').textContent += + ' Optional icon: include one of icon.svg, icon.png, icon.webp, or icon.avif at the package root.'; find('[data-release-form-title]').textContent = tag ? 'Edit Release' : 'Add Release'; @@ -381,7 +497,7 @@ export function initExtensionWorkspace() { info.className = 'min-w-0 max-w-full'; const title = document.createElement('p'); title.className = 'text-sm font-semibold'; - title.textContent = `${release.tag}${release.tag === latest?.tag ? ' · Latest Version' : ''}`; + title.textContent = `${release.tag}${release.tag === latest?.tag ? ' · Latest Release' : ''}`; const detail = document.createElement('p'); detail.className = 'mt-1 text-xs text-muted-foreground'; detail.textContent = `${release.date} · FOSSBilling ${compatibilityLabel(release.requires.fossbilling)}${release.requires.php ? ` · PHP ${compatibilityLabel(release.requires.php)}` : ''}`; @@ -392,7 +508,7 @@ export function initExtensionWorkspace() { download.textContent = 'Download'; download.setAttribute('aria-label', `Download Release ${release.tag}`); if (isSafeHttpUrl(release.download_url)) { - download.href = release.download_url; + download.href = releaseDownloadHref(release); download.target = '_blank'; download.rel = 'noopener noreferrer'; download.classList.add('underline'); @@ -400,6 +516,32 @@ export function initExtensionWorkspace() { info.appendChild(title); info.appendChild(detail); links.appendChild(download); + if (release.sha256) { + const checksum = document.createElement('button'); + checksum.type = 'button'; + checksum.dataset.releaseChecksum = release.sha256; + checksum.className = + 'inline-flex cursor-pointer items-center gap-1 align-middle font-mono text-muted-foreground/80 hover:text-foreground focus-visible:text-foreground'; + const hash = document.createElement('span'); + hash.dataset.checksumLabel = ''; + hash.textContent = release.sha256.slice(-8); + checksum.appendChild(hash); + checksum.appendChild( + find('[data-checksum-icon]').content.cloneNode( + true, + ), + ); + checksum.title = `sha256:${release.sha256}`; + checksum.setAttribute( + 'aria-label', + `Copy SHA-256 for Release ${release.tag}`, + ); + const separator = document.createElement('span'); + separator.setAttribute('aria-hidden', 'true'); + separator.textContent = ' · '; + links.appendChild(separator); + links.appendChild(checksum); + } if (release.changelog_url && isSafeHttpUrl(release.changelog_url)) { const changelog = document.createElement('a'); changelog.className = 'underline underline-offset-4'; @@ -421,6 +563,7 @@ export function initExtensionWorkspace() { const actions = document.createElement('div'); actions.className = 'flex flex-wrap gap-2'; const edit = document.createElement('button'); + edit.dataset.releaseAction = ''; edit.type = 'button'; edit.className = 'btn'; edit.dataset.variant = 'outline'; @@ -430,6 +573,7 @@ export function initExtensionWorkspace() { edit.addEventListener('click', () => editRelease(release.tag)); actions.appendChild(edit); const remove = document.createElement('button'); + remove.dataset.releaseAction = ''; remove.type = 'button'; remove.className = 'btn'; remove.dataset.variant = 'destructive'; @@ -481,12 +625,126 @@ export function initExtensionWorkspace() { unsaved.add(releaseForm); updateButtons(); }); - releaseForm?.addEventListener('submit', (event) => { + function savePreparedRelease( + release: Release, + input: Record, + ) { + const current = content(); + busy = false; + let releases = input.original_tag + ? current.releases.map((item) => + item.tag === input.original_tag ? release : item, + ) + : [...current.releases, release]; + let selected = + input.is_latest === 'on' + ? release.tag + : latestRelease(current.releases)?.tag; + if (input.is_latest !== 'on' && input.original_tag === selected) { + const hasPackages = releases.some((item) => item.artifact_id); + const alternatives = releases.filter( + (item) => + item.tag !== input.original_tag && (!hasPackages || item.artifact_id), + ); + selected = latestRelease(alternatives)?.tag ?? selected; + } + if (selected) releases = selectLatestRelease(releases, selected); + save({ ...draft, releases }); + releaseEditor.hidden = true; + unsaved.delete(releaseForm); + find('[data-release-error]').hidden = true; + refresh(); + localStorage.removeItem(packageKey); + pendingPackage = null; + } + + async function resumePackage() { + if (!pendingPackage || packagePolling) return; + const pending = pendingPackage; + packagePolling = true; + busy = false; + releaseEditor.hidden = false; + updateButtons(); + root.inert = false; + root.removeAttribute('aria-busy'); + releaseEditor.setAttribute('aria-busy', 'true'); + const button = + releaseForm.querySelector('[type="submit"]')!; + button.textContent = 'Processing Release…'; + button.disabled = true; + releaseFields.disabled = true; + try { + const artifact = await waitForPackageJob( + pending.job, + async (id) => { + const url = new URL(config.packageUrl, window.location.origin); + url.searchParams.set('jobId', id); + const response = await fetch(url, { redirect: 'manual' }); + const body = (await response.json()) as { + job?: PackageJob; + error?: string; + }; + if (!response.ok || !body.job) + throw new Error( + body.error ?? + 'Unable to read package status. Your processing job has been kept.', + ); + return body.job; + }, + { + progress: (job) => { + button.textContent = + job.status === 'queued' + ? 'Release Queued…' + : 'Processing Release…'; + }, + }, + ); + savePreparedRelease( + artifactRelease(pending.release, artifact), + pending.input, + ); + } catch (cause) { + if ( + cause instanceof PackageJobError && + cause.code !== 'PACKAGE_PROCESSING' + ) { + localStorage.removeItem(packageKey); + pendingPackage = null; + } + throw cause; + } finally { + packagePolling = false; + button.disabled = false; + releaseEditor.removeAttribute('aria-busy'); + releaseFields.disabled = !!pendingPackage; + button.textContent = pendingPackage + ? 'Resume Package Processing' + : 'Save Release to Draft'; + updateButtons(); + } + } + + releaseForm?.addEventListener('submit', async (event) => { event.preventDefault(); + if (busy || blocked || packagePolling) return; + const button = + releaseForm.querySelector('[type="submit"]')!; try { + if (pendingPackage) { + await resumePackage(); + return; + } const input = fields(releaseForm); - const release = releaseFromFields(input); + input.is_latest = latestCheckbox?.checked ? 'on' : ''; const current = content(); + const original = current.releases.find( + (item) => item.tag === input.original_tag, + ); + const release = releaseFromFields( + input, + original ?? { download_url: '' }, + ); if ( current.releases.some( (item) => @@ -495,35 +753,72 @@ export function initExtensionWorkspace() { ) ) throw new Error(`A release tagged "${release.tag}" already exists.`); - if ( - input.original_tag && - !current.releases.some((item) => item.tag === input.original_tag) - ) + if (input.original_tag && !original) throw new Error( 'This release was removed from the draft. Close the editor and choose another release.', ); - const releases = input.original_tag - ? current.releases.map((item) => - item.tag === input.original_tag ? release : item, - ) - : [...current.releases, release]; - save({ - ...draft, - releases, + const body = new FormData(); + const source = new FormData(releaseForm).get('source'); + if (source instanceof File && source.size) { + if (source.size > MAX_SOURCE_BYTES) + throw new Error('Source ZIPs must be 5 MiB or smaller.'); + body.set('source', source); + } else if (!original?.artifact_id) + throw new Error('Choose a source ZIP for this release.'); + body.set('draft', JSON.stringify(draft)); + body.set('release', JSON.stringify(input)); + busy = true; + updateButtons(); + button.textContent = 'Preparing Release…'; + root.inert = true; + root.setAttribute('aria-busy', 'true'); + const response = await fetch(config.packageUrl, { + method: 'POST', + body, + redirect: 'manual', }); - releaseEditor.hidden = true; - unsaved.delete(releaseForm); - refresh(); + if (response.status === 0 || response.status === 302) + throw new Error( + 'Your session has expired. Sign in again; your edits have been kept.', + ); + const result = (await response.json()) as { + release?: typeof release; + job?: PackageJob; + error?: string; + }; + if (!response.ok || !result.release || !result.job) + throw new Error( + result.error || 'Unable to prepare this release. Please try again.', + ); + pendingPackage = { + job: result.job, + release: result.release, + input, + baselineHash: config.baselineHash, + pendingRevisionId: config.pendingRevisionId, + }; + localStorage.setItem(packageKey, JSON.stringify(pendingPackage)); + await resumePackage(); } catch (cause) { const node = find('[data-release-error]'); node.textContent = cause instanceof Error ? cause.message : 'Unable to save release.'; node.hidden = false; + } finally { + busy = false; + root.inert = false; + root.removeAttribute('aria-busy'); + button.textContent = pendingPackage + ? 'Resume Package Processing' + : 'Save Release to Draft'; + updateButtons(); } }); discard.addEventListener('click', () => { if (!confirm('Discard all local draft changes for this extension?')) return; try { + localStorage.removeItem(packageKey); + pendingPackage = null; localStorage.removeItem(config.key); } catch { error('The draft could not be removed from browser storage.'); @@ -539,6 +834,7 @@ export function initExtensionWorkspace() { setSectionEditing(form, form.dataset.sectionForm!, false); }); if (releaseEditor) releaseEditor.hidden = true; + releaseFields.disabled = false; error(''); refresh(); }); @@ -608,4 +904,34 @@ export function initExtensionWorkspace() { } }); refresh(); + if (pendingPackage && !blocked) { + const input = pendingPackage.input; + releaseForm + ?.querySelectorAll('input,select,textarea') + .forEach((field) => { + if (!( + field instanceof HTMLInputElement || + field instanceof HTMLSelectElement || + field instanceof HTMLTextAreaElement + )) + return; + if (field instanceof HTMLInputElement && field.type === 'file') return; + if (field instanceof HTMLInputElement && field.type === 'checkbox') + field.checked = input[field.name] === 'on'; + else if (field.name in input) field.value = input[field.name]; + }); + void resumePackage().catch((cause) => { + error( + cause instanceof Error + ? cause.message + : 'Unable to resume package processing.', + ); + const node = find('[data-release-error]'); + node.textContent = + cause instanceof Error + ? cause.message + : 'Unable to resume package processing.'; + node.hidden = false; + }); + } } diff --git a/src/types/index.ts b/src/types/index.ts index 6a6021ca..ae28b037 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -59,6 +59,9 @@ export type Release = { date: string; download_url: string; changelog_url?: string; + is_latest?: boolean; + artifact_id?: string; + sha256?: string; requires: Requirements; }; export type Repository = { type: SourceType; repo: string }; diff --git a/test/extension-draft.test.ts b/test/extension-draft.test.ts index 8142630a..eaa84868 100644 --- a/test/extension-draft.test.ts +++ b/test/extension-draft.test.ts @@ -18,7 +18,7 @@ export const base: ExtensionUpdate = { name: 'Original', description: 'Original description', homepage_url: 'http://example.test', - icon_url: 'icon.svg', + readme: '# Original', license: 'MIT', source: { type: 'github', repo: 'example/original' }, @@ -51,7 +51,7 @@ describe('extension drafts', () => { description: 'Updated description', type: 'module', homepage_url: 'https://example.test', - icon_url: '', + source_url: 'https://gitlab.com/example/updated', license_spdx_id: 'Apache-2.0', license_url: 'https://example.test/license', @@ -78,7 +78,8 @@ describe('extension drafts', () => { description: 'Revised description', type: 'theme', homepage_url: 'https://example.test/new', - icon_url: '', + source_url: 'https://github.com/example/original', + license_spdx_id: 'MIT', }, }), ); @@ -87,7 +88,6 @@ describe('extension drafts', () => { type: 'theme', readme: '# Revised', }); - expect(result.icon_url).toBeUndefined(); expect(result.releases).toEqual(base.releases); expect(result.version).toBe(base.version); expect(result.download_url).toBe(base.download_url); @@ -201,18 +201,18 @@ describe('extension drafts', () => { expect(result.version).toBe('2.0.0'); expect(result.download_url).toBe(newer.download_url); }); - it('keeps the version label and untouched historic release values when editing another release', () => { + it('keeps the selected version when adding another release', () => { const published = { ...base, releases: [ { ...base.releases[0], tag: 'v1.0.0', - date: '2026-01-01T12:00:00Z', - download_url: 'http://example.test/1.zip', + date: '2026-01-01', + download_url: 'https://example.test/1.zip', }, ], - download_url: 'http://example.test/1.zip', + download_url: 'https://example.test/1.zip', }; const next = { ...base.releases[0], @@ -225,8 +225,8 @@ describe('extension drafts', () => { currentRelease: 'v1.0.0', }); expect(result.version).toBe('1.0.0'); - expect(result.releases[0]).toEqual(published.releases[0]); - expect(result.download_url).toBe('http://example.test/1.zip'); + expect(result.releases[0]).toMatchObject(published.releases[0]); + expect(result.download_url).toBe('https://example.test/1.zip'); }); it.each([ { releases: [], currentRelease: '' }, @@ -260,6 +260,8 @@ describe('extension drafts', () => { listing: { name: base.name, description: 'Updated listing', + source_url: 'https://github.com/example/original', + license_spdx_id: 'MIT', type: base.type, author_name: base.author.name, requires_fossbilling: requires.fossbilling, diff --git a/test/extension-form.test.ts b/test/extension-form.test.ts index ca66cb39..a3e1a20e 100644 --- a/test/extension-form.test.ts +++ b/test/extension-form.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { - buildExtensionCreatePayload, + buildExtensionCreatePayload as createPayload, + manifestFromCreateForm, buildExtensionUpdatePayload, echoExtensionFromForm, echoNewExtensionFromForm, @@ -28,13 +29,25 @@ function extensionForm(overrides: Record = {}) { version_tag: '1.0.0', release_date: '2026-01-01', download_url: 'example.test/example.zip', - icon_url: 'assets/icon.svg', + ...overrides, }; for (const [key, value] of Object.entries(values)) if (typeof value === 'string') form.set(key, value); return form; } +function buildExtensionCreatePayload(form: FormData) { + const manifest = manifestFromCreateForm(form); + return createPayload(form, { + id: '11111111-1111-4111-8111-111111111111', + source_id: '22222222-2222-4222-8222-222222222222', + manifest, + download_url: 'https://api.test/packages/download', + sha256: 'a'.repeat(64), + created_at: '2026-01-01', + published_at: null, + }); +} const developer = { id: 'owner', type: 'organization' as const, @@ -60,6 +73,8 @@ const published: Extension = { releases: [ { tag: '1.0.0', + artifact_id: '11111111-1111-4111-8111-111111111111', + sha256: 'a'.repeat(64), date: '2026-01-01', download_url: 'https://example.test/example.zip', requires: { @@ -82,7 +97,7 @@ describe('manifest publishing forms', () => { type: 'module', license: 'MIT', homepage_url: 'https://example.test', - icon_url: 'assets/icon.svg', + author: { name: 'Package Author', url: 'https://author.example.test' }, requires: { fossbilling: '^0.8.9 || ^1.0', @@ -102,64 +117,42 @@ describe('manifest publishing forms', () => { expect(body.id).toBe('my_module'); expect(body.homepage_url).toBeUndefined(); }); - it('carries published releases unchanged when editing metadata', () => { + it('aligns the latest release requirements with corrected metadata', () => { const body = buildExtensionUpdatePayload( - extensionForm({ - version_tag: '', - release_date: '', - download_url: '', - requires_php: '>=8.4', - }), + extensionForm({ requires_php: '>=8.4' }), published, ); - expect(body).not.toHaveProperty('id'); - expect(body.releases).toEqual(published.releases); - expect(body.version).toBe(published.version); expect(body.requires.php).toBe('>=8.4'); - expect(body.releases[0].requires.php).toBe('>=8.3 <9'); + expect(body.releases[0].requires.php).toBe('>=8.4'); + expect(body.releases[0].artifact_id).toBe( + published.releases[0].artifact_id, + ); + expect(published.releases[0].requires.php).toBe('>=8.3 <9'); }); - it('appends a release with its own requirements while preserving history', () => { + it('keeps releases and download identity out of moderator form inputs', () => { const body = buildExtensionUpdatePayload( - extensionForm({ version_tag: '1.1.0', requires_php: '>=8.4' }), + extensionForm({ + version_tag: '9.0.0', + download_url: 'https://other.test/zip', + }), published, ); - expect(body.releases).toHaveLength(2); - expect(body.releases[0]).toEqual(published.releases[0]); - expect(body.releases[1].requires.php).toBe('>=8.4'); - expect(body.version).toBe('1.1.0'); - }); - it('normalizes v-prefixed latest versions and rejects duplicate tag spellings', () => { - expect( - buildExtensionUpdatePayload( - extensionForm({ version_tag: 'v2.0.0' }), - published, - ).version, - ).toBe('2.0.0'); - expect(() => - buildExtensionUpdatePayload( - extensionForm({ version_tag: 'v1.0.0' }), - published, - ), - ).toThrow(ExtensionValidationError); + expect(body.version).toBe(published.version); + expect(body.download_url).toBe(published.download_url); + expect(body.releases).toHaveLength(1); }); - it('rejects duplicate tags and overflowing release history', () => { - expect(() => - buildExtensionUpdatePayload(extensionForm(), published), - ).toThrow(/already exists/); + it('requires a stored package for metadata corrections', () => { expect(() => - buildExtensionUpdatePayload(extensionForm({ version_tag: '2.0.0' }), { + buildExtensionUpdatePayload(extensionForm(), { ...published, - releases: Array.from({ length: 100 }, () => published.releases[0]), + releases: [], }), - ).toThrow(/100 releases/); + ).toThrow(/stored package/); }); - it('requires an initial release for creation and unpublished resubmission', () => { + it('requires an initial release and date for creation', () => { expect(() => buildExtensionCreatePayload(extensionForm({ version_tag: '' })), ).toThrow(ExtensionValidationError); - expect(() => - buildExtensionUpdatePayload(extensionForm({ version_tag: '' }), null), - ).toThrow(ExtensionValidationError); expect(() => buildExtensionCreatePayload(extensionForm({ release_date: '' })), ).toThrow(ExtensionValidationError); @@ -188,22 +181,7 @@ describe('manifest publishing forms', () => { ), ).toThrow(ExtensionValidationError); }); - it.each([ - '../icon.svg', - 'x\n../icon.svg', - 'a\n/../../icon.svg', - 'x\r../icon.svg', - 'x\u2028../icon.svg', - 'x\u2029../icon.svg', - 'assets/\u0000icon.svg', - '/icon.svg', - 'https://example.com/icon.svg', - 'a\\icon.svg', - ])('rejects unsafe package icon %s', (icon_url) => { - expect(() => - buildExtensionCreatePayload(extensionForm({ icon_url })), - ).toThrow(ExtensionValidationError); - }); + it('inherits FOSSBilling PHP requirements when no additional constraint is set', () => { const body = buildExtensionCreatePayload( extensionForm({ requires_php: '' }), @@ -268,7 +246,6 @@ it('safely echoes blank new-extension and release submissions', () => { expect(echoReleaseDraftFromForm(form)).toEqual({ version_tag: '', release_date: '', - download_url: '', changelog_url: '', }); const echo = echoNewExtensionFromForm(form, developer); diff --git a/test/extension-icon.test.ts b/test/extension-icon.test.ts index 3d5ed9bb..79ffd26a 100644 --- a/test/extension-icon.test.ts +++ b/test/extension-icon.test.ts @@ -1,65 +1,69 @@ import { describe, expect, it } from 'vitest'; import { extensionIconUrl } from '@/lib/extension-icon'; -const extension = { - id: 'branding', - icon_url: 'assets/icon.svg', - download_url: - 'https://github.com/FOSSBilling/modules/releases/download/branding-v0.8.9/branding.zip', - source: { type: 'github', repo: 'FOSSBilling/modules' }, -}; -describe('package icons', () => { - it('resolves first-party package paths against the published release tag', () => { - expect(extensionIconUrl(extension)).toBe( - 'https://raw.githubusercontent.com/FOSSBilling/modules/branding-v0.8.9/Branding/assets/icon.svg', - ); - }); - it('resolves single-repository package paths at the repository root', () => { +import { + directoryAssetAvifUrl, + isTransformableImageSource, +} from '@/lib/image-url'; +import { getSignedImageUrl } from '@/lib/signed-image-url'; + +describe('extension icons', () => { + it('serves prepared published images directly from the CDN with AVIF/PNG fallback', async () => { + const url = `https://extensions-assets.fossbilling.net/local/icons/${'a'.repeat(64)}.png`; expect( extensionIconUrl({ - ...extension, - source: { type: 'github', repo: 'owner/module' }, - download_url: - 'https://github.com/owner/module/releases/download/v1.0.0/module.zip', + icon_url: url, + releases: [ + { + tag: '1.0.0', + date: '2026-11-05', + requires: { fossbilling: '^0.8' }, + download_url: 'https://example.test/zip', + artifact_id: '11111111-1111-4111-8111-111111111111', + }, + ], }), - ).toBe( - 'https://raw.githubusercontent.com/owner/module/v1.0.0/assets/icon.svg', - ); + ).toBe(url); + expect(isTransformableImageSource(url)).toBe(false); + expect(await getSignedImageUrl(url, 'icon', 'test')).toBe(url); + expect(directoryAssetAvifUrl(url)).toBe(url.replace(/\.png$/, '.avif')); + expect( + directoryAssetAvifUrl(url.replace(/\.png$/, '.svg')), + ).toBeUndefined(); + expect( + directoryAssetAvifUrl( + url.replace('extensions-assets.fossbilling.net', 'untrusted.example'), + ), + ).toBeUndefined(); }); - it('retains legacy absolute HTTP(S) icons', () => { + it('uses the permission-aware package icon endpoint for the latest release', () => { + const id = '11111111-1111-4111-8111-111111111111'; expect( extensionIconUrl({ - ...extension, - icon_url: 'https://example.test/icon.svg', + releases: [ + { + tag: '1.0.0', + date: '2026-11-05', + download_url: 'https://example.test/package', + requires: { fossbilling: '^0.8' }, + artifact_id: id, + }, + ], }), - ).toBe('https://example.test/icon.svg'); + ).toBe(`/package/${id}/icon`); }); - it.each([ - '../icon.svg', - 'x\n../icon.svg', - 'a\n/../../icon.svg', - 'x\r../icon.svg', - 'x\u2028../icon.svg', - 'x\u2029../icon.svg', - 'assets/\u0000icon.svg', - '/icon.svg', - 'javascript:alert(1)', - 'a\\icon.svg', - 'https://user:password@example.com/icon.svg', - ])('rejects unsafe icon %s', (icon_url) => { - expect(extensionIconUrl({ ...extension, icon_url })).toBeUndefined(); + it('retains read-only image URLs for existing public records', () => { + expect( + extensionIconUrl({ icon_url: 'https://example.test/icon.png' }), + ).toBe('https://example.test/icon.png'); }); - it('rejects mismatched asset sources, unsupported hosts, and refs containing paths', () => { - for (const download_url of [ - 'https://github.com/other/repo/releases/download/v1.0.0/a.zip', - 'https://example.com/icon.zip', - 'https://github.com/FOSSBilling/modules/releases/download/a%2Fb/a.zip', + it('does not interpret old paths or unsafe URLs as public images', () => { + for (const icon_url of [ + 'icon.svg', + '../icon.svg', + 'javascript:alert(1)', + 'https://user:secret@example.test/icon.svg', ]) - expect(extensionIconUrl({ ...extension, download_url })).toBeUndefined(); - expect( - extensionIconUrl({ - ...extension, - source: { type: 'custom', repo: 'https://example.com' }, - }), - ).toBeUndefined(); + expect(extensionIconUrl({ icon_url })).toBeUndefined(); + expect(extensionIconUrl({})).toBeUndefined(); }); }); diff --git a/test/extension-package-download.test.ts b/test/extension-package-download.test.ts new file mode 100644 index 00000000..da973ceb --- /dev/null +++ b/test/extension-package-download.test.ts @@ -0,0 +1,105 @@ +import type { APIContext } from 'astro'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ + session: vi.fn(), + getPackage: vi.fn(), + download: vi.fn(), +})); +vi.mock('@/lib/session', () => ({ getSessionUser: mocks.session })); +vi.mock('@/lib/api/client', async (importOriginal) => ({ + ...(await importOriginal()), + createApiClient: () => ({ getPackage: mocks.getPackage }), + downloadPackage: mocks.download, +})); +import { GET } from '@/pages/package/[packageId]'; +import { GET as GET_ICON } from '@/pages/package/[packageId]/icon'; +import { ApiRequestError } from '@/lib/api/client'; +const context = () => + ({ + params: { id: 'example', packageId: 'artifact' }, + locals: { env: { sessionSecret: 'test' } }, + cookies: {}, + request: new Request('http://localhost/package/artifact/icon', { + headers: { Accept: 'image/avif,image/png' }, + }), + }) as unknown as APIContext; +beforeEach(() => { + vi.clearAllMocks(); + mocks.session.mockResolvedValue({ sub: 'owner' }); + mocks.getPackage.mockResolvedValue({ manifest: { id: 'example' } }); + mocks.download.mockImplementation(() => + Promise.resolve( + new Response('zip bytes', { + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="example.zip"', + ETag: '"checksum"', + 'Set-Cookie': 'must-not-forward=1', + }, + }), + ), + ); +}); +describe('permission-aware package downloads', () => { + it('streams a private preview with server-side authentication and private cache headers', async () => { + const response = await GET(context()); + expect(response.status).toBe(200); + expect(await response.text()).toBe('zip bytes'); + expect(mocks.download).toHaveBeenCalledWith( + expect.anything(), + 'artifact', + 'owner', + 'download', + undefined, + ); + expect(response.headers.get('Cache-Control')).toContain('no-store'); + expect(response.headers.get('Content-Disposition')).toContain( + 'example.zip', + ); + expect(response.headers.has('Set-Cookie')).toBe(false); + }); + it('lets the API authorize an anonymous published download', async () => { + mocks.session.mockResolvedValue(null); + expect((await GET(context())).status).toBe(200); + expect(mocks.download).toHaveBeenCalledWith( + expect.anything(), + 'artifact', + undefined, + 'download', + undefined, + ); + }); + it('keeps unapproved downloads private when the API denies access', async () => { + mocks.download.mockRejectedValueOnce( + new ApiRequestError(404, 'NOT_FOUND', 'Private'), + ); + expect((await GET(context())).status).toBe(404); + }); +}); + +describe('permission-aware package icons', () => { + it('forwards icon reads with the owner assertion and sandbox headers', async () => { + mocks.download.mockResolvedValueOnce( + new Response('', { + headers: { + 'Content-Type': 'image/svg+xml', + 'Content-Security-Policy': "sandbox; default-src 'none'", + }, + }), + ); + const response = await GET_ICON(context()); + expect(mocks.download).toHaveBeenCalledWith( + expect.anything(), + 'artifact', + 'owner', + 'icon', + 'image/avif,image/png', + ); + expect(response.headers.get('Content-Security-Policy')).toContain( + 'sandbox', + ); + expect(response.headers.get('Cache-Control')).toContain('no-store'); + expect(response.headers.get('Content-Type')).toBe('image/svg+xml'); + expect(response.headers.get('Vary')).toContain('Accept'); + }); +}); diff --git a/test/extension-packages.test.ts b/test/extension-packages.test.ts new file mode 100644 index 00000000..8d71a703 --- /dev/null +++ b/test/extension-packages.test.ts @@ -0,0 +1,345 @@ +import type { APIContext } from 'astro'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ExtensionUpdate, PackageArtifact } from '@/lib/api/client'; +import { packageManifest, rebuildLatestPackage } from '@/lib/extension-package'; +import { editableContent, applyExtensionDraft } from '@/lib/extension-draft'; +import { sha256Hex } from '@/lib/hash'; +const mocks = vi.hoisted(() => ({ + guard: vi.fn(), + getMyExtension: vi.fn(), + getRevision: vi.fn(), + getPackage: vi.fn(), + buildPackage: vi.fn(), + queuePackage: vi.fn(), + getPackageJob: vi.fn(), +})); +vi.mock('@/lib/auth-guard', () => ({ requireUser: mocks.guard })); +vi.mock('@/lib/api/client', async (importOriginal) => ({ + ...(await importOriginal()), + createApiClient: () => mocks, +})); +import { GET, POST } from '@/pages/extension/[id]/package'; + +const content: ExtensionUpdate = { + manifest_version: 1, + name: 'Example', + description: 'Original description', + type: 'module', + author: { name: 'Author' }, + license: 'MIT', + requires: { fossbilling: '>=0.8.0' }, + readme: '# Example', + source: { type: 'github', repo: 'example/module' }, + version: '1.0.0', + download_url: 'https://api.test/original.zip', + releases: [ + { + tag: '1.0.0', + date: '2026-11-05', + download_url: 'https://api.test/original.zip', + requires: { fossbilling: '>=0.8.0' }, + artifact_id: '11111111-1111-4111-8111-111111111111', + sha256: 'a'.repeat(64), + }, + ], +}; +const original: PackageArtifact = { + id: content.releases[0].artifact_id!, + source_id: '22222222-2222-4222-8222-222222222222', + manifest: packageManifest('example', content), + download_url: content.download_url, + sha256: 'a'.repeat(64), + created_at: '2026-11-05', + published_at: '2026-11-05', +}; +const artifact: PackageArtifact = { + ...original, + id: '33333333-3333-4333-8333-333333333333', + manifest: { ...original.manifest, version: '1.1.0' }, + sha256: 'b'.repeat(64), + download_url: 'https://api.test/built.zip', + published_at: null, +}; +async function context( + options: { + source?: File; + fields?: Record; + baselineHash?: string; + } = {}, +) { + const form = new FormData(); + form.set( + 'draft', + JSON.stringify({ + baselineHash: + options.baselineHash ?? + (await sha256Hex(JSON.stringify(editableContent(content)))), + sections: {}, + }), + ); + form.set( + 'release', + JSON.stringify( + options.fields ?? { + version_tag: '1.1.0', + release_date: '2026-11-05', + requires_fossbilling: '>=0.8.0', + }, + ), + ); + if (options.source) form.set('source', options.source); + return { + params: { id: 'example' }, + locals: { env: {} }, + request: new Request('https://site.test/extension/example/package', { + method: 'POST', + body: form, + }), + } as unknown as APIContext; +} +beforeEach(() => { + vi.clearAllMocks(); + mocks.guard.mockResolvedValue({ sub: 'owner' }); + mocks.getMyExtension.mockResolvedValue({ + published: content, + pending_revision: null, + }); + mocks.getPackage.mockResolvedValue(original); + mocks.buildPackage.mockResolvedValue(artifact); + mocks.queuePackage.mockResolvedValue({ + id: '44444444-4444-4444-8444-444444444444', + extension_id: 'example', + status: 'queued', + artifact: null, + error: null, + created_at: 'now', + updated_at: 'now', + }); +}); + +describe('release package editor', () => { + it('builds an uploaded ZIP with the current draft manifest and returns a queued job', async () => { + const file = new File(['synthetic source'], 'module.zip'); + const response = await POST(await context({ source: file })); + expect(response.status).toBe(202); + expect(mocks.queuePackage).toHaveBeenCalledWith( + { ...original.manifest, version: '1.1.0' }, + expect.any(File), + ); + expect(await response.json()).toMatchObject({ + job: { status: 'queued' }, + release: { + tag: '1.1.0', + }, + }); + }); + it('reuses the authorized source for compatibility edits', async () => { + const response = await POST( + await context({ + fields: { + original_tag: '1.0.0', + version_tag: '1.0.0', + release_date: '2026-11-05', + requires_fossbilling: '>=0.9.0', + }, + }), + ); + expect(response.status).toBe(202); + expect(mocks.getPackage).toHaveBeenCalledWith(original.id); + expect(mocks.queuePackage).toHaveBeenCalledWith( + { ...original.manifest, requires: { fossbilling: '>=0.9.0' } }, + { sourceId: original.source_id }, + ); + }); + it('keeps an older package’s own metadata when only its compatibility changes', async () => { + const newer = { + ...content, + name: 'Newer Name', + version: '2.0.0', + releases: [ + { ...content.releases[0], tag: '2.0.0', artifact_id: artifact.id }, + ...content.releases, + ], + }; + mocks.getMyExtension.mockResolvedValue({ + published: newer, + pending_revision: null, + }); + const response = await POST( + await context({ + baselineHash: await sha256Hex(JSON.stringify(editableContent(newer))), + fields: { + original_tag: '1.0.0', + version_tag: '1.0.0', + release_date: '2026-11-05', + requires_fossbilling: '>=0.9.0', + }, + }), + ); + expect(response.status).toBe(202); + expect(mocks.queuePackage).toHaveBeenCalledWith( + { ...original.manifest, requires: { fossbilling: '>=0.9.0' } }, + { sourceId: original.source_id }, + ); + }); + it('rejects stale drafts, missing ZIPs, duplicate versions and oversize files before building', async () => { + expect( + ( + await POST( + await context({ + baselineHash: 'stale', + source: new File(['zip'], 'module.zip'), + }), + ) + ).status, + ).toBe(409); + expect((await POST(await context())).status).toBe(422); + expect( + ( + await POST( + await context({ + fields: { + version_tag: '1.0.0', + release_date: '2026-11-05', + requires_fossbilling: '>=0.8.0', + }, + source: new File(['zip'], 'module.zip'), + }), + ) + ).status, + ).toBe(422); + expect( + ( + await POST( + await context({ + source: new File( + [new Uint8Array(5 * 1024 * 1024 + 1)], + 'module.zip', + ), + }), + ) + ).status, + ).toBe(413); + expect(mocks.queuePackage).not.toHaveBeenCalled(); + }); + it('requires a ZIP when a release has no stored package', async () => { + const unprepared = { + ...content, + releases: content.releases.map( + ({ artifact_id: _id, sha256: _hash, ...release }) => release, + ), + }; + mocks.getMyExtension.mockResolvedValue({ + published: unprepared, + pending_revision: null, + }); + const response = await POST( + await context({ + baselineHash: await sha256Hex( + JSON.stringify(editableContent(unprepared)), + ), + fields: { + original_tag: '1.0.0', + version_tag: '1.0.0', + release_date: '2026-11-05', + requires_fossbilling: '>=0.8.0', + }, + }), + ); + expect(response.status).toBe(422); + expect(mocks.queuePackage).not.toHaveBeenCalled(); + }); + it('proxies private job progress and rejects another extension’s job', async () => { + const ctx = await context(); + const id = '44444444-4444-4444-8444-444444444444'; + ctx.url = new URL( + `https://site.test/extension/example/package?jobId=${id}`, + ); + mocks.getPackageJob.mockResolvedValue({ + id, + extension_id: 'example', + status: 'queued', + }); + const response = await GET(ctx); + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('private, no-store'); + expect(await response.json()).toMatchObject({ + job: { id, status: 'queued' }, + }); + mocks.getPackageJob.mockResolvedValue({ + id, + extension_id: 'another', + status: 'queued', + }); + expect((await GET(ctx)).status).toBe(404); + }); + it('authorizes before reading the request or source', async () => { + mocks.guard.mockResolvedValue(new Response(null, { status: 302 })); + expect((await POST(await context())).status).toBe(302); + expect(mocks.getMyExtension).not.toHaveBeenCalled(); + }); + it('preserves artifact references when a release is changed in a browser draft', () => { + const release = { + ...content.releases[0], + changelog_url: 'https://example.test/changelog', + }; + const result = applyExtensionDraft(content, { + sections: {}, + releases: [release], + }); + expect(result.releases[0]).toMatchObject({ + artifact_id: original.id, + sha256: original.sha256, + changelog_url: release.changelog_url, + }); + }); +}); + +describe('overview package rebuilds', () => { + it('preserves packages for directory-only changes and equivalent dependency maps', async () => { + const changed = { + ...content, + readme: '# Updated', + source: { type: 'custom' as const, repo: 'https://example.test/source' }, + }; + expect(await rebuildLatestPackage(mocks, 'example', changed)).toBe(changed); + expect(mocks.buildPackage).not.toHaveBeenCalled(); + }); + it('rebuilds the latest package for changed manifest metadata without modifying older releases', async () => { + const changed = { + ...content, + description: 'Corrected description', + releases: [ + ...content.releases, + { + tag: '0.9.0', + date: '2026-01-01', + download_url: 'https://old.test/zip', + requires: { fossbilling: '>=0.7.0' }, + }, + ], + }; + mocks.buildPackage.mockResolvedValue({ + ...artifact, + manifest: { ...original.manifest, description: changed.description }, + }); + const result = await rebuildLatestPackage(mocks, 'example', changed); + expect(mocks.buildPackage).toHaveBeenCalledWith( + { ...original.manifest, description: changed.description }, + { sourceId: original.source_id }, + ); + expect(result.releases[0].artifact_id).toBe(artifact.id); + expect(result.releases[1]).toBe(changed.releases[1]); + expect(result.version).toBe('1.0.0'); + expect(result.download_url).toBe(artifact.download_url); + }); + it('retains the draft content on a rebuild failure', async () => { + const changed = { ...content, name: 'Changed' }; + mocks.buildPackage.mockRejectedValue(new Error('Storage unavailable')); + await expect( + rebuildLatestPackage(mocks, 'example', changed), + ).rejects.toThrow('Storage unavailable'); + expect(changed.releases[0].artifact_id).toBe(original.id); + expect(changed.download_url).toBe(original.download_url); + }); +}); diff --git a/test/middleware.test.ts b/test/middleware.test.ts index ff8ea5f5..1ca9452c 100644 --- a/test/middleware.test.ts +++ b/test/middleware.test.ts @@ -68,3 +68,19 @@ describe('personalized extension HTML caching', () => { ); }); }); + +it('retains the package icon sandbox alongside the framing policy', async () => { + const response = await onRequest( + context('/package/example/icon'), + async () => + new Response('', { + headers: { 'Content-Security-Policy': "sandbox; default-src 'none'" }, + }), + ); + expect(response?.headers.get('Content-Security-Policy')).toContain( + "sandbox; default-src 'none'", + ); + expect(response?.headers.get('Content-Security-Policy')).toContain( + "frame-ancestors 'none'", + ); +}); diff --git a/test/package-api.test.ts b/test/package-api.test.ts new file mode 100644 index 00000000..013d4841 --- /dev/null +++ b/test/package-api.test.ts @@ -0,0 +1,111 @@ +import { fieldUrl } from '@/lib/revision-diff-shared'; +import { + packageDownloadPath, + isPackageDownloadPath, +} from '@/lib/package-download'; +import { describe, expect, it, vi } from 'vitest'; +import { createApiClient, downloadPackage } from '@/lib/api/client'; +import { makeEnv } from './helpers/env'; +import type { Manifest } from '@/lib/api/client'; +const manifest: Manifest = { + manifest_version: 1, + id: 'example', + name: 'Example', + type: 'module', + description: 'Example', + version: '1.0.0', + author: { name: 'Developer' }, + license: 'MIT', + requires: { fossbilling: '>=0.8.0' }, +}; +describe('package API transport', () => { + it('sends the source ZIP as multipart data with a bearer assertion', async () => { + const fetcher = vi.fn(async (request: Request) => { + expect(request.headers.get('Authorization')).toMatch(/^Bearer /); + expect(request.headers.get('Content-Type')).toContain( + 'multipart/form-data; boundary=', + ); + const form = await request.formData(); + expect(JSON.parse(String(form.get('manifest')))).toEqual(manifest); + expect(await (form.get('source') as File).text()).toBe('zip'); + return Response.json( + { + result: { + id: 'job', + extension_id: 'example', + status: 'ready', + artifact: { id: 'artifact' }, + error: null, + created_at: 'now', + updated_at: 'now', + }, + }, + { status: 202 }, + ); + }); + const env = makeEnv({ + extensionsApi: { + baseUrl: 'https://api.test', + fetch: fetcher as typeof fetch, + }, + }); + expect( + await createApiClient(env, 'owner').buildPackage( + manifest, + new File(['zip'], 'source.zip'), + ), + ).toMatchObject({ id: 'artifact' }); + }); + it('authenticates metadata and streaming preview reads without consuming the ZIP', async () => { + const fetcher = vi.fn(async (request: Request) => { + expect(request.headers.get('Authorization')).toMatch(/^Bearer /); + return request.url.endsWith('/download') + ? new Response('zip', { + headers: { 'Content-Type': 'application/zip' }, + }) + : Response.json({ result: { id: 'artifact' } }); + }); + const env = makeEnv({ + extensionsApi: { + baseUrl: 'https://api.test', + fetch: fetcher as typeof fetch, + }, + }); + expect( + await createApiClient(env, 'owner').getPackage('artifact'), + ).toMatchObject({ id: 'artifact' }); + const response = await downloadPackage(env, 'artifact', 'owner'); + expect(response.bodyUsed).toBe(false); + expect(await response.text()).toBe('zip'); + }); +}); + +describe('moderator package links', () => { + it('links the reviewed artifact through the permission-aware download endpoint', () => { + const id = '11111111-1111-4111-8111-111111111111'; + expect( + fieldUrl('download_url', { + download_url: 'https://api.test/package.zip', + releases: [ + null, + { + tag: '1.0.0', + download_url: 'https://api.test/package.zip', + artifact_id: id, + }, + ], + }), + ).toBe(`/package/${id}`); + expect(packageDownloadPath('../private')).toBeNull(); + expect( + isPackageDownloadPath( + '//evil.test/package/11111111-1111-4111-8111-111111111111', + ), + ).toBe(false); + expect( + isPackageDownloadPath( + '/package/11111111-1111-4111-8111-111111111111?redirect=evil', + ), + ).toBe(false); + }); +}); diff --git a/test/package-job.test.ts b/test/package-job.test.ts new file mode 100644 index 00000000..9ee41ae9 --- /dev/null +++ b/test/package-job.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PackageJobError, waitForPackageJob } from '@/lib/package-job'; +import type { PackageJob } from '@/lib/api/client'; + +const queued = { + id: 'job', + extension_id: 'example', + status: 'queued', + artifact: null, + error: null, + created_at: 'now', + updated_at: 'now', +} satisfies PackageJob; +describe('package job polling', () => { + it('waits through queued and processing states without repeating the upload', async () => { + const artifact = { id: 'artifact' } as NonNullable; + const read = vi + .fn() + .mockResolvedValueOnce({ ...queued, status: 'processing' }) + .mockResolvedValueOnce({ ...queued, status: 'ready', artifact }); + const pause = vi.fn().mockResolvedValue(undefined); + expect(await waitForPackageJob(queued, read, { pause })).toBe(artifact); + expect(read.mock.calls).toEqual([['job'], ['job']]); + }); + it('accepts completion on the last allowed poll', async () => { + const artifact = { id: 'artifact' } as NonNullable; + expect( + await waitForPackageJob( + queued, + async () => ({ ...queued, status: 'ready', artifact }), + { attempts: 1, pause: async () => {} }, + ), + ).toBe(artifact); + }); + it('reports a failed package without continuing to poll', async () => { + const read = vi.fn(); + await expect( + waitForPackageJob( + { + ...queued, + status: 'failed', + error: { + code: 'INVALID_PACKAGE', + status: 422, + message: 'Invalid archive', + }, + }, + read, + ), + ).rejects.toMatchObject({ + status: 422, + code: 'INVALID_PACKAGE', + message: 'Invalid archive', + }); + expect(read).not.toHaveBeenCalled(); + }); + it('bounds waiting while allowing the saved job to be resumed', async () => { + const read = vi.fn().mockResolvedValue(queued); + await expect( + waitForPackageJob(queued, read, { attempts: 2, pause: async () => {} }), + ).rejects.toBeInstanceOf(PackageJobError); + expect(read).toHaveBeenCalledTimes(2); + await expect( + waitForPackageJob(queued, read, { attempts: 0 }), + ).rejects.toMatchObject({ code: 'PACKAGE_PROCESSING' }); + }); +}); diff --git a/test/releases.test.ts b/test/releases.test.ts index 792944e7..3ea836de 100644 --- a/test/releases.test.ts +++ b/test/releases.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { latestRelease, releasesDescending } from '@/lib/releases'; +import { + defaultLatestForVersion, + latestRelease, + releasesDescending, + selectLatestRelease, +} from '@/lib/releases'; import type { Release } from '@/types'; function release(tag: string, date = '2026-01-01'): Release { @@ -63,3 +68,31 @@ describe('automatic latest release', () => { ).toBe('first'); }); }); + +describe('selected latest release', () => { + it('uses an explicit choice without reordering version history', () => { + const older = { ...release('1.0.0'), is_latest: true }; + const newer = release('2.0.0'); + expect(latestRelease([newer, older])).toBe(older); + expect(releasesDescending([older, newer]).map((item) => item.tag)).toEqual([ + '2.0.0', + '1.0.0', + ]); + }); + it('defaults to stable versions when no release has been selected', () => { + expect(latestRelease([release('3.0.0-rc.1'), release('2.0.0')])?.tag).toBe( + '2.0.0', + ); + }); +}); + +it('moves the sole latest flag and defaults prereleases to unchecked', () => { + const result = selectLatestRelease( + [{ ...release('1.0.0'), is_latest: true }, release('2.0.0')], + '2.0.0', + ); + expect(result.filter((item) => item.is_latest)).toHaveLength(1); + expect(latestRelease(result)?.tag).toBe('2.0.0'); + expect(defaultLatestForVersion('2.1.0')).toBe(true); + expect(defaultLatestForVersion('2.1.0-beta.1')).toBe(false); +}); diff --git a/test/revision-diff.test.ts b/test/revision-diff.test.ts index 3a3f3109..5196ca6a 100644 --- a/test/revision-diff.test.ts +++ b/test/revision-diff.test.ts @@ -17,7 +17,6 @@ const published: ExtensionContent = { license: 'MIT', source: { type: 'github', repo: 'org/old' }, download_url: 'https://example.test/old.zip', - icon_url: 'https://example.test/icon.png', readme: 'Old readme', releases: [ { From a2c16e89124ec2b4030caba6f3c4b64f8aff4f88 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 7 Nov 2026 19:10:04 +0000 Subject: [PATCH 2/6] Preserve local package editor integration --- README.md | 6 + openapi/extensions-v2.json | 129 ++++++++++++++---- src/components/ExtensionCard.astro | 2 +- src/components/ExtensionForm.astro | 19 +-- src/components/ExtensionHeader.astro | 4 +- src/components/OwnerExtensionView.astro | 2 +- src/components/admin/ClaimCard.astro | 6 +- .../admin/DecidedRevisionsTable.astro | 13 +- .../admin/DeveloperProfilesTable.astro | 8 +- .../admin/ExtensionAdminTable.astro | 12 +- .../admin/PendingRevisionsTable.astro | 16 ++- src/lib/api/client.ts | 14 +- .../api/generated/extensions-v2/types.gen.ts | 32 +++-- src/lib/developer-form.ts | 9 +- src/lib/directory-filters.ts | 4 +- src/lib/directory-pagination.ts | 2 +- src/lib/extension-form.ts | 16 ++- src/lib/extension-view.ts | 4 +- src/lib/extensions-data.ts | 12 +- src/lib/image-url.ts | 8 +- .../account/admin/developers/index.astro | 4 +- src/pages/account/admin/extensions/[id].astro | 4 +- .../account/admin/extensions/[id]/edit.astro | 20 +-- src/pages/account/developer/index.astro | 46 ++++--- .../account/developer/transfer/revoke.ts | 2 +- src/pages/account/extensions/new.astro | 2 +- src/pages/account/index.astro | 18 ++- src/pages/api/admin/revision-detail.ts | 2 +- src/pages/developer/[id].astro | 10 +- src/pages/extension/[id].astro | 4 +- src/pages/extension/[id]/package.ts | 2 +- src/pages/index.astro | 6 +- src/scripts/extension-directory.ts | 6 +- src/types/index.ts | 4 +- test/directory-pagination.test.ts | 2 +- test/extension-directory.test.ts | 4 +- test/extension-form.test.ts | 21 +-- test/extension-icon.test.ts | 25 +++- test/extension-packages.test.ts | 6 +- test/extension-view.test.ts | 14 +- test/extensions-api.test.ts | 39 +++--- test/extensions-data.test.ts | 19 ++- test/helpers/directory-fixtures.ts | 2 + test/package-api.test.ts | 2 +- test/package-job.test.ts | 2 +- 45 files changed, 380 insertions(+), 204 deletions(-) diff --git a/README.md b/README.md index 5320682c..66638525 100644 --- a/README.md +++ b/README.md @@ -111,4 +111,10 @@ Package preparation is queued. The API returns a private job ID; the release edi Authors upload ready-to-install distribution ZIPs. Icons use `icon.svg`, `icon.png`, `icon.webp` or `icon.avif` at the package root, without a manifest field. The API prepares private display images during processing and publishes approved images through the configured asset CDN. Package downloads and pending previews use permission-checked API routes. +Published icon URLs use `https://extensions-assets.fossbilling.net/extensions//icons/.`. The extension UUID is independent of its public identifier. SVG or AVIF/PNG variants share the processed icon's immutable asset UUID, so metadata-only edits retain the same icon URL. + API mutations and site mutations purge the directory cache. Deploy and smoke-test the configured, package-enabled API before deploying this site or enabling the modules publisher. The Queue and private image-storage bindings must exist before the API's automatic deployment, even when package mode is disabled. + +## Directory Identity + +Extension and developer records use UUIDv7 internal IDs. Public routes, profile handles and manifest IDs use their separate `identifier` fields. Keep UUIDs for record identity and use identifiers when constructing public links or package manifests. diff --git a/openapi/extensions-v2.json b/openapi/extensions-v2.json index cb50ca38..60e4313b 100644 --- a/openapi/extensions-v2.json +++ b/openapi/extensions-v2.json @@ -115,6 +115,10 @@ "maxLength": 2048 }, "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "developer": { @@ -132,6 +136,7 @@ "version", "download_url", "id", + "identifier", "developer" ] }, @@ -154,7 +159,7 @@ "PublicDeveloper": { "type": "object", "properties": { - "id": { + "identifier": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, @@ -177,6 +182,10 @@ "maxLength": 2048, "format": "uri" }, + "id": { + "type": "string", + "format": "uuid" + }, "approved": { "type": "boolean" }, @@ -184,12 +193,23 @@ "type": "boolean" } }, - "required": ["id", "type", "name", "approved", "unclaimed"] + "required": [ + "identifier", + "type", + "name", + "id", + "approved", + "unclaimed" + ] }, "OwnedExtensionListItem": { "type": "object", "properties": { "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "developer": { @@ -300,6 +320,7 @@ }, "required": [ "id", + "identifier", "developer", "published", "pending_revision", @@ -450,13 +471,17 @@ "type": "string" }, "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "developer": { "$ref": "#/components/schemas/PublicDeveloper" } }, - "required": ["id", "developer"] + "required": ["id", "identifier", "developer"] } ] }, @@ -954,7 +979,7 @@ "maxLength": 2048, "format": "uri" }, - "id": { + "identifier": { "type": "string", "minLength": 1, "maxLength": 64, @@ -974,7 +999,7 @@ "source", "version", "download_url", - "id" + "identifier" ], "additionalProperties": false }, @@ -1087,6 +1112,10 @@ "type": "string" }, "extension_id": { + "type": "string", + "format": "uuid" + }, + "extension_identifier": { "type": "string" }, "developer_id": { @@ -1140,6 +1169,7 @@ "required": [ "id", "extension_id", + "extension_identifier", "developer_id", "submitted_by", "status", @@ -1163,6 +1193,10 @@ "type": "string" }, "extension_id": { + "type": "string", + "format": "uuid" + }, + "extension_identifier": { "type": "string" }, "developer_id": { @@ -1226,6 +1260,7 @@ "required": [ "id", "extension_id", + "extension_identifier", "developer_id", "submitted_by", "status", @@ -1326,7 +1361,8 @@ "type": "string" }, "developer_id": { - "type": "string" + "type": "string", + "format": "uuid" }, "claimant_id": { "type": "string" @@ -1383,6 +1419,9 @@ { "type": "object", "properties": { + "developer_identifier": { + "type": "string" + }, "developer_name": { "type": "string" }, @@ -1398,6 +1437,7 @@ } }, "required": [ + "developer_identifier", "developer_name", "developer_type", "claimant_name", @@ -1414,6 +1454,10 @@ { "type": "object", "properties": { + "id": { + "type": "string", + "format": "uuid" + }, "approved": { "type": "boolean" }, @@ -1447,14 +1491,19 @@ "type": ["string", "null"] } }, - "required": ["approved", "content_revision", "profile_generation"] + "required": [ + "id", + "approved", + "content_revision", + "profile_generation" + ] } ] }, "Developer": { "type": "object", "properties": { - "id": { + "identifier": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, @@ -1483,7 +1532,7 @@ "format": "email" } }, - "required": ["id", "type", "name"] + "required": ["identifier", "type", "name"] }, "ReviewNoteRequired": { "type": "object", @@ -1710,7 +1759,8 @@ "type": "string" }, "developer_id": { - "type": "string" + "type": "string", + "format": "uuid" }, "can_publish": { "type": "boolean" @@ -1735,8 +1785,7 @@ "properties": { "developer_id": { "type": "string", - "minLength": 1, - "maxLength": 200 + "format": "uuid" }, "can_publish": { "type": "boolean", @@ -1753,7 +1802,7 @@ "type": "string", "format": "uuid" }, - "extension_id": { + "extension_identifier": { "type": "string" }, "status": { @@ -1794,7 +1843,7 @@ }, "required": [ "id", - "extension_id", + "extension_identifier", "status", "artifact", "error", @@ -1951,7 +2000,7 @@ "DeveloperInput": { "type": "object", "properties": { - "id": { + "identifier": { "type": "string", "pattern": "^[a-z0-9]+(-[a-z0-9]+)*$" }, @@ -1980,7 +2029,7 @@ "format": "email" } }, - "required": ["id", "type", "name"], + "required": ["identifier", "type", "name"], "additionalProperties": false }, "DeveloperDetailResponse": { @@ -2039,7 +2088,7 @@ }, "required": false, "description": "Public scope only", - "name": "developer_id", + "name": "developer_identifier", "in": "query" }, { @@ -2182,7 +2231,7 @@ "result": { "type": "object", "properties": { - "id": { + "identifier": { "type": "string" }, "revision_id": { @@ -2191,9 +2240,13 @@ "status": { "type": "string", "enum": ["pending"] + }, + "id": { + "type": "string", + "format": "uuid" } }, - "required": ["id", "revision_id", "status"] + "required": ["identifier", "revision_id", "status", "id"] } }, "required": ["result"] @@ -2406,7 +2459,7 @@ "result": { "type": "object", "properties": { - "id": { + "identifier": { "type": "string" }, "revision_id": { @@ -2417,7 +2470,7 @@ "enum": ["pending"] } }, - "required": ["id", "revision_id", "status"] + "required": ["identifier", "revision_id", "status"] } }, "required": ["result"] @@ -2548,6 +2601,10 @@ "type": "object", "properties": { "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "deleted": { @@ -2555,7 +2612,7 @@ "enum": [true] } }, - "required": ["id", "deleted"] + "required": ["id", "identifier", "deleted"] } }, "required": ["result"] @@ -2757,7 +2814,7 @@ "result": { "type": "object", "properties": { - "id": { + "identifier": { "type": "string" }, "revision_id": { @@ -2768,7 +2825,7 @@ "enum": ["pending"] } }, - "required": ["id", "revision_id", "status"] + "required": ["identifier", "revision_id", "status"] } }, "required": ["result"] @@ -4661,6 +4718,10 @@ "type": "object", "properties": { "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "status": { @@ -4673,7 +4734,7 @@ "example": true } }, - "required": ["id", "status", "notified"] + "required": ["id", "identifier", "status", "notified"] } }, "required": ["result"] @@ -4796,6 +4857,10 @@ "type": "object", "properties": { "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "status": { @@ -4808,7 +4873,7 @@ "example": true } }, - "required": ["id", "status", "notified"] + "required": ["id", "identifier", "status", "notified"] } }, "required": ["result"] @@ -4920,6 +4985,10 @@ "type": "object", "properties": { "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "revision_id": { @@ -4930,7 +4999,7 @@ "enum": ["approved"] } }, - "required": ["id", "revision_id", "status"] + "required": ["id", "identifier", "revision_id", "status"] } }, "required": ["result"] @@ -5093,6 +5162,10 @@ "type": "object", "properties": { "id": { + "type": "string", + "format": "uuid" + }, + "identifier": { "type": "string" }, "approved": { @@ -5105,7 +5178,7 @@ "example": true } }, - "required": ["id", "approved", "notified"] + "required": ["id", "identifier", "approved", "notified"] } }, "required": ["result"] diff --git a/src/components/ExtensionCard.astro b/src/components/ExtensionCard.astro index 1b40d843..29447295 100644 --- a/src/components/ExtensionCard.astro +++ b/src/components/ExtensionCard.astro @@ -20,7 +20,7 @@ const iconUrl = await getSignedImageUrl( requests to Worker routes with an empty 503 (cf-speculation-refused), so any prefetch strategy only produces console errors, never speed. --> !popularSpdxIds.has(id)); Publisher

- Publishing as {developer.name} ({developer.id}). + Publishing as {developer.name} ( + {developer.identifier}). !popularSpdxIds.has(id)); />

-

diff --git a/src/components/OwnerExtensionView.astro b/src/components/OwnerExtensionView.astro index ab82d526..70a43abd 100644 --- a/src/components/OwnerExtensionView.astro +++ b/src/components/OwnerExtensionView.astro @@ -12,7 +12,7 @@ interface Props { userId: string; } const { owned, userId } = Astro.props; -const id = owned.id; +const id = owned.identifier; const env = Astro.locals.env; const api = createApiClient(env, userId); // A review decision and publication are independent: rejecting an update diff --git a/src/components/admin/ClaimCard.astro b/src/components/admin/ClaimCard.astro index 0881a864..9cdb06d6 100644 --- a/src/components/admin/ClaimCard.astro +++ b/src/components/admin/ClaimCard.astro @@ -21,7 +21,7 @@ const Title = titleTag;

  • - {c.developer_name} ({c.developer_id}) + {c.developer_name} ({c.developer_identifier}) {c.github_org_verified && <span class="badge">GitHub Verified</span>}

    @@ -61,7 +61,7 @@ const Title = titleTag; { return ( <> - +

    - {r.name ?? r.extension_id} + {r.name ?? r.extension_identifier}

    -

    {r.extension_id}

    +

    + {r.extension_identifier} +

    {!showNote && r.review_note && (

    {d.name}

    -

    {d.id}

    +

    {d.identifier}

    {d.URL && (

    - - {ext.published?.name ?? ext.id} + + {ext.published?.name ?? ext.identifier}

    -

    {ext.id}

    +

    {ext.identifier}

    {ext.developer.name} @@ -66,7 +66,7 @@ function statusOf(ext: OwnedExtensionListItem) {
    Delist diff --git a/src/components/admin/PendingRevisionsTable.astro b/src/components/admin/PendingRevisionsTable.astro index 666c8135..f881c1d2 100644 --- a/src/components/admin/PendingRevisionsTable.astro +++ b/src/components/admin/PendingRevisionsTable.astro @@ -31,23 +31,27 @@ const DETAIL_COLUMNS = 4; {revisions.map((r) => { - const title = r.name ?? r.extension_id; + const title = r.name ?? r.extension_identifier; const version = r.version?.trim() || null; const description = r.description?.trim() || null; return ( <>

    - + {title}

    -

    {r.extension_id}

    +

    + {r.extension_identifier} +

    {version ?? '—'} @@ -79,7 +83,7 @@ const DETAIL_COLUMNS = 4; data-size="sm" disabled={!r.content_available} data-open-approve={r.id} - data-extension-id={r.extension_id} + data-extension-id={r.extension_identifier} data-revision-id={r.id} data-title={title} > @@ -91,7 +95,7 @@ const DETAIL_COLUMNS = 4; data-size="sm" data-variant="destructive" data-open-reject={r.id} - data-extension-id={r.extension_id} + data-extension-id={r.extension_identifier} data-revision-id={r.id} data-title={title} > diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 1d1da742..895c11e3 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -83,12 +83,12 @@ type ExtensionListQuery = NonNullable; type RevisionQueueQuery = NonNullable; type ListFilters = Pick< ExtensionListQuery, - 'type' | 'developer_id' | 'status' | 'q' | 'limit' | 'cursor' + 'type' | 'developer_identifier' | 'status' | 'q' | 'limit' | 'cursor' >; export type ExtensionDirectoryFilters = Pick< ExtensionListQuery, - 'type' | 'developer_id' | 'limit' | 'cursor' + 'type' | 'developer_identifier' | 'limit' | 'cursor' >; type ExtensionMineFilters = Pick; @@ -394,8 +394,8 @@ function listQuery( if (filters.type !== undefined) { query.type = filters.type; } - if (filters.developer_id !== undefined) { - query.developer_id = filters.developer_id; + if (filters.developer_identifier !== undefined) { + query.developer_identifier = filters.developer_identifier; } if (filters.status !== undefined) { query.status = filters.status; @@ -422,7 +422,7 @@ export async function listExtensions( return cachedEdgeRead( dataCacheKey('extensions', { cursor: query.cursor, - developer_id: query.developer_id, + developer_identifier: query.developer_identifier, limit: query.limit, type: query.type, }), @@ -441,7 +441,7 @@ export async function listExtensions( ); } -export async function getExtensionById( +export async function getExtensionByIdentifier( env: ApplicationEnv, id: string, ): Promise { @@ -457,7 +457,7 @@ export async function getExtensionById( ); } -export async function getDeveloperById( +export async function getDeveloperByIdentifier( env: ApplicationEnv, id: string, ): Promise { diff --git a/src/lib/api/generated/extensions-v2/types.gen.ts b/src/lib/api/generated/extensions-v2/types.gen.ts index 5fec5d55..d8a37094 100644 --- a/src/lib/api/generated/extensions-v2/types.gen.ts +++ b/src/lib/api/generated/extensions-v2/types.gen.ts @@ -37,6 +37,7 @@ export type ExtensionListItem = { download_url: string | null; icon_url?: string; id: string; + identifier: string; developer: PublicDeveloper; }; @@ -46,17 +47,19 @@ export type Repository = { }; export type PublicDeveloper = { - id: string; + identifier: string; type: 'user' | 'organization'; name: string; URL?: string; avatar_url?: string; + id: string; approved: boolean; unclaimed: boolean; }; export type OwnedExtensionListItem = { id: string; + identifier: string; developer: PublicDeveloper; published: { manifest_version: 1; @@ -145,6 +148,7 @@ export type Extension = ExtensionContent & { download_url?: string; icon_url?: string; id: string; + identifier: string; developer: PublicDeveloper; }; @@ -290,7 +294,7 @@ export type ExtensionCreate = { source: Repository; version: string; download_url: string; - id: string; + identifier: string; }; export type ExtensionUpdate = { @@ -321,6 +325,7 @@ export type ExtensionUpdate = { export type ExtensionRevisionSummary = { id: string; extension_id: string; + extension_identifier: string; developer_id: string; submitted_by: string; status: 'pending' | 'approved' | 'rejected'; @@ -340,6 +345,7 @@ export type ExtensionRevisionSummary = { export type ExtensionRevision = { id: string; extension_id: string; + extension_identifier: string; developer_id: string; submitted_by: string; status: 'pending' | 'approved' | 'rejected'; @@ -400,6 +406,7 @@ export type ClaimNote = { }; export type PendingDeveloperClaim = DeveloperClaim & { + developer_identifier: string; developer_name: string; developer_type: 'user' | 'organization'; claimant_name: string | null; @@ -407,6 +414,7 @@ export type PendingDeveloperClaim = DeveloperClaim & { }; export type DeveloperProfile = Developer & { + id: string; approved: boolean; content_revision: number; profile_generation: string; @@ -420,7 +428,7 @@ export type DeveloperProfile = Developer & { }; export type Developer = { - id: string; + identifier: string; type: 'user' | 'organization'; name: string; URL?: string; @@ -505,7 +513,7 @@ export type PublisherRegister = { export type PackageJob = { id: string; - extension_id: string; + extension_identifier: string; status: 'queued' | 'processing' | 'ready' | 'failed'; artifact: PackageArtifact | null; error: { @@ -556,7 +564,7 @@ export type OwnedDeveloperProfile = | null; export type DeveloperInput = { - id: string; + identifier: string; type: 'user' | 'organization'; name: string; URL?: string; @@ -582,7 +590,7 @@ export type GetExtensionsData = { /** * Public scope only */ - developer_id?: string; + developer_identifier?: string; limit?: number; /** * Opaque cursor returned by the previous page @@ -686,9 +694,10 @@ export type PostExtensionsResponses = { */ 201: { result: { - id: string; + identifier: string; revision_id: string; status: 'pending'; + id: string; }; }; }; @@ -738,6 +747,7 @@ export type DeleteExtensionsByIdResponses = { 200: { result: { id: string; + identifier: string; deleted: true; }; }; @@ -848,7 +858,7 @@ export type PutExtensionsByIdResponses = { */ 202: { result: { - id: string; + identifier: string; revision_id: string; status: 'pending'; }; @@ -1015,7 +1025,7 @@ export type PutExtensionsByIdRevisionsByRevisionIdResponses = { */ 202: { result: { - id: string; + identifier: string; revision_id: string; status: 'pending'; }; @@ -1862,6 +1872,7 @@ export type PostExtensionsByIdDelistResponses = { 200: { result: { id: string; + identifier: string; status: 'delisted'; /** * Whether a notification email was dispatched - delivery itself is asynchronous @@ -1925,6 +1936,7 @@ export type PostExtensionsByIdRelistResponses = { 200: { result: { id: string; + identifier: string; status: 'relisted'; /** * Whether a notification email was dispatched - delivery itself is asynchronous @@ -1999,6 +2011,7 @@ export type PostExtensionsByIdModeratorCorrectResponses = { 200: { result: { id: string; + identifier: string; revision_id: string; status: 'approved'; }; @@ -2059,6 +2072,7 @@ export type PostDevelopersByIdApproveResponses = { 200: { result: { id: string; + identifier: string; approved: true; /** * Whether a notification email was dispatched - delivery itself is asynchronous diff --git a/src/lib/developer-form.ts b/src/lib/developer-form.ts index 561eff8a..bb5366fd 100644 --- a/src/lib/developer-form.ts +++ b/src/lib/developer-form.ts @@ -18,13 +18,14 @@ export function buildDeveloperProfile( ): DeveloperProfileInput { const str = (name: string) => formString(form, name); const typeInput = str('type'); - const id = existingDeveloper?.id ?? str('id').toLowerCase(); - if (!id) { - throw new DeveloperValidationError('Publisher ID is required.'); + const identifier = + existingDeveloper?.identifier ?? str('identifier').toLowerCase(); + if (!identifier) { + throw new DeveloperValidationError('Publisher Identifier is required.'); } return { - id, + identifier, type: isDeveloperType(typeInput) ? typeInput : 'user', name: str('name'), URL: withHttpsScheme(str('url')), diff --git a/src/lib/directory-filters.ts b/src/lib/directory-filters.ts index 5dc5c754..4c65b1b7 100644 --- a/src/lib/directory-filters.ts +++ b/src/lib/directory-filters.ts @@ -21,9 +21,9 @@ export function parseDirectoryFilters(params: URLSearchParams): if (type) { filters.type = type; } - const developerId = params.get('developer_id'); + const developerId = params.get('developer_identifier'); if (developerId !== null) { - filters.developer_id = developerId; + filters.developer_identifier = developerId; } const limit = params.get('limit'); if (limit !== null) { diff --git a/src/lib/directory-pagination.ts b/src/lib/directory-pagination.ts index 9d54ae9a..d47c4e95 100644 --- a/src/lib/directory-pagination.ts +++ b/src/lib/directory-pagination.ts @@ -11,7 +11,7 @@ type DirectoryPage = { type DirectoryPageFilters = { type?: string; - developer_id?: string; + developer_identifier?: string; limit?: number; }; diff --git a/src/lib/extension-form.ts b/src/lib/extension-form.ts index 1660e317..4ae93fa1 100644 --- a/src/lib/extension-form.ts +++ b/src/lib/extension-form.ts @@ -11,7 +11,9 @@ import { repositoryFromFields, repositoryInputUrl } from './repository'; import { formString } from './form'; import { withHttpsScheme } from './url-prefix'; -export type ExtensionFormEcho = Extension & { moduleRequirementsInput: string }; +export type ExtensionFormEcho = Omit & { + moduleRequirementsInput: string; +}; // Thrown for form-level validation failures that should be shown to the // user, distinct from ApiRequestError (the api rejecting an otherwise @@ -129,7 +131,7 @@ function manifestFields(form: FormData): Omit { export function manifestFromCreateForm(form: FormData): Manifest { return { ...manifestFields(form), - id: formString(form, 'extension_id').toLowerCase(), + id: formString(form, 'extension_identifier').toLowerCase(), version: formString(form, 'version_tag').replace(/^v(?=\d)/, ''), }; } @@ -143,7 +145,7 @@ export function buildExtensionCreatePayload( 'An initial release and release date are required.', ); return { - id: formString(form, 'extension_id').toLowerCase(), + identifier: formString(form, 'extension_identifier').toLowerCase(), ...listingFields(form), version: artifact.manifest.version, requires: artifact.manifest.requires, @@ -192,7 +194,7 @@ export function buildExtensionUpdatePayload( // page so a new form field only needs adding here. export function echoExtensionFromForm( form: FormData, - base: Extension, + base: Omit, ): ExtensionFormEcho { const selectedLicenseId = formString(form, 'license_spdx_id'); const isRecognizedLicense = @@ -243,7 +245,7 @@ export function echoReleaseDraftFromForm(form: FormData): ReleaseDraft { } // Best-effort echo for the create page: there is no existing extension to -// spread over, so the base is a blank, submission-shaped extension — the id +// spread over, so the base is a blank, submission-shaped extension — the identifier // comes from the form (lowercased, matching what a successful submit would // store) and the developer is the submitter's own profile. Returns both the // field echo and the release draft for ExtensionForm's redisplay props. @@ -251,11 +253,11 @@ export function echoNewExtensionFromForm( form: FormData, developer: Extension['developer'], ): { extension: ExtensionFormEcho; releaseDraft: ReleaseDraft } { - const base: Extension = { + const base: Omit = { manifest_version: 1, author: { name: developer.name, url: developer.URL }, requires: { fossbilling: '', php: '' }, - id: formString(form, 'extension_id').toLowerCase(), + identifier: formString(form, 'extension_identifier').toLowerCase(), type: 'module', name: '', description: '', diff --git a/src/lib/extension-view.ts b/src/lib/extension-view.ts index 759314a1..58a69038 100644 --- a/src/lib/extension-view.ts +++ b/src/lib/extension-view.ts @@ -1,7 +1,7 @@ import { ApiRequestError, createApiClient, - getExtensionById, + getExtensionByIdentifier, type OwnedExtension, } from './api/client'; import type { Extension } from '@/types'; @@ -39,5 +39,5 @@ export async function loadExtensionView( throw error; } } - return { kind: 'public', extension: await getExtensionById(env, id) }; + return { kind: 'public', extension: await getExtensionByIdentifier(env, id) }; } diff --git a/src/lib/extensions-data.ts b/src/lib/extensions-data.ts index efef291e..04a45791 100644 --- a/src/lib/extensions-data.ts +++ b/src/lib/extensions-data.ts @@ -3,7 +3,7 @@ // generated Extensions v2 client. import { createApiClient, - getDeveloperById as getDeveloperByIdFromApi, + getDeveloperByIdentifier as getDeveloperByIdentifierFromApi, paginateAll, type DeveloperProfile as ApiDeveloperProfile, type Extension as ApiExtension, @@ -52,6 +52,7 @@ export interface LastReview { // must not collapse them into a single derived status. export interface OwnedExtensionDetail { id: string; + identifier: string; developer: PublicDeveloperProfile; published: Extension | null; pendingRevision: PendingRevision | null; @@ -64,6 +65,7 @@ function toDeveloperProfile( ): DeveloperProfile & { has_pending_transfer?: boolean } { return { id: developer.id, + identifier: developer.identifier, type: developer.type, name: developer.name, URL: developer.URL, @@ -86,6 +88,7 @@ function toDeveloperProfile( function toPublicDeveloper(developer: PublicDeveloper): PublicDeveloperProfile { return { id: developer.id, + identifier: developer.identifier, type: developer.type, name: developer.name, URL: developer.URL, @@ -101,6 +104,7 @@ function toExtension(extension: ApiExtension): Extension { author: extension.author, requires: extension.requires, id: extension.id, + identifier: extension.identifier, type: extension.type, name: extension.name, description: extension.description, @@ -126,11 +130,13 @@ export function toOwnedExtensionDetail( ): OwnedExtensionDetail { return { id: owned.id, + identifier: owned.identifier, developer: toPublicDeveloper(owned.developer), published: owned.published ? toExtension({ ...owned.published, id: owned.id, + identifier: owned.identifier, developer: owned.developer, }) : null, @@ -215,12 +221,12 @@ export async function getDeveloperByOwner( } } -export async function getDeveloperById( +export async function getDeveloperByIdentifier( env: ApplicationEnv, id: string, ): Promise { try { - return toPublicDeveloper(await getDeveloperByIdFromApi(env, id)); + return toPublicDeveloper(await getDeveloperByIdentifierFromApi(env, id)); } catch { // Public pages historically treated any domain read failure as a missing // profile. Keep that fail-soft behavior at the API adapter boundary. diff --git a/src/lib/image-url.ts b/src/lib/image-url.ts index ec65fcce..6cfbd9b1 100644 --- a/src/lib/image-url.ts +++ b/src/lib/image-url.ts @@ -21,6 +21,12 @@ const IMAGE_SOURCE_HOSTS = [ export type ImageVariant = keyof typeof IMAGE_VARIANTS; +const UUID_PATH = + '[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}'; +const DIRECTORY_ASSET_PATH = new RegExp( + `^/extensions/${UUID_PATH}/icons/${UUID_PATH}\\.(svg|png|avif)$`, +); + export function isDirectoryAssetUrl(source: string): boolean { try { const url = new URL(source); @@ -30,7 +36,7 @@ export function isDirectoryAssetUrl(source: string): boolean { !url.password && !url.search && !url.hash && - /^\/[\w-]+\/icons\/[a-f0-9]{64}\.(svg|png|avif)$/.test(url.pathname) + DIRECTORY_ASSET_PATH.test(url.pathname) ); } catch { return false; diff --git a/src/pages/account/admin/developers/index.astro b/src/pages/account/admin/developers/index.astro index 30ba18ab..dc2de63b 100644 --- a/src/pages/account/admin/developers/index.astro +++ b/src/pages/account/admin/developers/index.astro @@ -72,7 +72,7 @@ if (query) { claims = claims.filter((c) => [ c.developer_name, - c.developer_id, + c.developer_identifier, c.claimant_name ?? '', c.claimant_id, c.claimant_github_login ?? '', @@ -80,7 +80,7 @@ if (query) { ); } else { developers = developers.filter((d) => - [d.name, d.id, d.owner_name ?? ''].some((field) => + [d.name, d.identifier, d.owner_name ?? ''].some((field) => field.toLowerCase().includes(needle), ), ); diff --git a/src/pages/account/admin/extensions/[id].astro b/src/pages/account/admin/extensions/[id].astro index 7bd8d2cb..db1285f7 100644 --- a/src/pages/account/admin/extensions/[id].astro +++ b/src/pages/account/admin/extensions/[id].astro @@ -70,7 +70,7 @@ const correctable = Boolean(
    {ext && correctable && ( {ext.developer.name} - ({ext.developer.id}) + ({ext.developer.identifier})

    diff --git a/src/pages/account/admin/extensions/[id]/edit.astro b/src/pages/account/admin/extensions/[id]/edit.astro index 6a9b1feb..8d0f5731 100644 --- a/src/pages/account/admin/extensions/[id]/edit.astro +++ b/src/pages/account/admin/extensions/[id]/edit.astro @@ -86,18 +86,18 @@ if ( } const payload = await rebuildLatestPackage( api, - owned.id, + owned.identifier, buildExtensionUpdatePayload(form, base), ); - await api.correctExtension(owned.id, payload, submittedNote); + await api.correctExtension(owned.identifier, payload, submittedNote); purgeDirectory(Astro); await setFlash(Astro, env.sessionSecret, { category: 'success', - title: `"${owned.id}" corrected.`, + title: `"${owned.identifier}" corrected.`, description: 'Recorded as a moderator revision — the author sees it in history; no email was sent.', }); - return Astro.redirect(`/account/admin/extensions/${owned.id}`); + return Astro.redirect(`/account/admin/extensions/${owned.identifier}`); } catch (e) { error = e instanceof ApiRequestError @@ -120,7 +120,7 @@ const title = base?.name || owned?.id || id;

    - Correct {owned.published?.name ?? owned.id} + Correct {owned.published?.name ?? owned.identifier}

    {!owned.published ? ( @@ -179,8 +179,10 @@ const title = base?.name || owned?.id || id; An edit submitted {formatDate(owned.pendingRevision.createdAt)} is still awaiting review. Approve or reject it first — a correction cannot supersede a pending edit. See the{' '} -
    detail page or - the revision queue. + + detail page + {' '} + or the revision queue.
    ) : ( @@ -196,7 +198,7 @@ const title = base?.name || owned?.id || id; echo={submitted ?? undefined} error={error} submitLabel="Correct Live Listing" - cancelHref={`/account/admin/extensions/${owned.id}`} + cancelHref={`/account/admin/extensions/${owned.identifier}`} correctionNote={submittedNote} /> diff --git a/src/pages/account/developer/index.astro b/src/pages/account/developer/index.astro index 41d85514..41ad15aa 100644 --- a/src/pages/account/developer/index.astro +++ b/src/pages/account/developer/index.astro @@ -8,7 +8,7 @@ import { } from '@/lib/api/client'; import { getDeveloperByOwner, - getDeveloperById, + getDeveloperByIdentifier, getExtensionsByOwner, } from '@/lib/extensions-data'; import { @@ -98,7 +98,7 @@ if (Astro.request.method === 'POST') { // route (transfer/revoke.ts), matching the rest of the app. if (form.get('intent') === 'transfer-initiate' && developer) { try { - transferResult = await api.initiateTransfer(developer.id); + transferResult = await api.initiateTransfer(developer.identifier); pendingTransfer = true; } catch (e) { error = @@ -153,9 +153,12 @@ if (Astro.request.method === 'POST') { !isEdit && e instanceof ApiRequestError && e.code === 'DEVELOPER_ID_TAKEN' && - submitted?.id + submitted?.identifier ) { - const conflicting = await getDeveloperById(env, submitted.id); + const conflicting = await getDeveloperByIdentifier( + env, + submitted.identifier, + ); idTaken = conflicting?.unclaimed === true; } if (e instanceof ApiRequestError && e.code === 'GITHUB_MISMATCH') { @@ -205,7 +208,7 @@ const reconnectUrl = buildGithubReconnectUrl(
      {myClaims.map((c) => (
    • - {c.developer_id} + {c.developer_identifier}
      {c.status === 'pending' && ( @@ -248,12 +251,12 @@ const reconnectUrl = buildGithubReconnectUrl(

      {error}

      - {idTaken && submitted?.id && ( + {idTaken && submitted?.identifier && (

      If this is your existing extension author profile, you can request to claim it instead of creating a new one — visit{' '} - - the public profile page for "{submitted.id}" + + the public profile page for "{submitted.identifier}" {' '} and use the "Claim this profile" option.

      @@ -285,9 +288,9 @@ const reconnectUrl = buildGithubReconnectUrl( ) : !isEdit && pendingClaim ? (

      You have a pending claim on{' '} - {pendingClaim.developer_id}. Cancel it above before - creating a separate profile — a claim can be approved at any time, - and you can only ever own one. + {pendingClaim.developer_identifier}. Cancel it + above before creating a separate profile — a claim can be approved + at any time, and you can only ever own one.

      ) : ( @@ -299,18 +302,18 @@ const reconnectUrl = buildGithubReconnectUrl(
      Publisher Identity
      -
      Shown on your public developer page at {developer - ? ` /developer/${developer.id}` + ? ` /developer/${developer.identifier}` : ' /developer/'} .

      @@ -624,7 +628,7 @@ const reconnectUrl = buildGithubReconnectUrl( id="delete-developer-profile" title="Delete developer profile" description="Permanently deletes your publisher identity and its public page. This can't be undone, and the ID may be used by someone else afterward." - confirmText={developer!.id} + confirmText={developer!.identifier} intentField={{ name: 'intent', value: 'delete' }} submitLabel="Delete Developer Profile" /> diff --git a/src/pages/account/developer/transfer/revoke.ts b/src/pages/account/developer/transfer/revoke.ts index df9c71f6..49c9a728 100644 --- a/src/pages/account/developer/transfer/revoke.ts +++ b/src/pages/account/developer/transfer/revoke.ts @@ -13,7 +13,7 @@ export const POST: APIRoute = formAction({ const developer = await getDeveloperByOwner(env, user.sub); if (!developer) return '/account/developer'; - await createApiClient(env, user.sub).revokeTransfer(developer.id); + await createApiClient(env, user.sub).revokeTransfer(developer.identifier); await setFlash(context, env.sessionSecret, { title: 'Pending transfer link revoked.', }); diff --git a/src/pages/account/extensions/new.astro b/src/pages/account/extensions/new.astro index 2a12337a..5f0591f3 100644 --- a/src/pages/account/extensions/new.astro +++ b/src/pages/account/extensions/new.astro @@ -44,7 +44,7 @@ if (Astro.request.method === 'POST') { await setFlash(Astro, Astro.locals.env.sessionSecret, { title: 'Submitted for Review.', }); - return Astro.redirect(`/extension/${payload.id}`); + return Astro.redirect(`/extension/${payload.identifier}`); } catch (e) { error = e instanceof ApiRequestError diff --git a/src/pages/account/index.astro b/src/pages/account/index.astro index a7ec52b4..0304c701 100644 --- a/src/pages/account/index.astro +++ b/src/pages/account/index.astro @@ -232,12 +232,14 @@ const reconnectUrl = buildGithubReconnectUrl(Astro.url.origin, '/account'); githubVerifiedAt={developer.github_verified_at} />

      -

      {developer.id}

      +

      + {developer.identifier} +

    - Claim on {displayClaim.developer_id} + Claim on {displayClaim.developer_identifier} {displayClaim.status === 'pending' && ( Pending Review @@ -366,13 +368,15 @@ const reconnectUrl = buildGithubReconnectUrl(Astro.url.origin, '/account');

  • - {ext.published?.name ?? ext.id} + {ext.published?.name ?? ext.identifier}

    - {ext.published ? `v${ext.published.version}` : ext.id} + {ext.published + ? `v${ext.published.version}` + : ext.identifier}

    {rejectedEdit && ext.last_review?.review_note && (

    ) : null} { : null; return Response.json({ result: { - extension_id: revision.extension_id, + extension_identifier: revision.extension_identifier, revision: revision.content, published: ext?.published ?? null, }, diff --git a/src/pages/developer/[id].astro b/src/pages/developer/[id].astro index 6c6c2160..a37ba296 100644 --- a/src/pages/developer/[id].astro +++ b/src/pages/developer/[id].astro @@ -1,7 +1,7 @@ --- import Base from '../../layouts/Base.astro'; import DirectorySection from '@/components/DirectorySection.astro'; -import { getDeveloperById } from '@/lib/extensions-data'; +import { getDeveloperByIdentifier } from '@/lib/extensions-data'; import { ApiRequestError, createApiClient, @@ -33,10 +33,10 @@ if (!id) { // on an invalid id, but keeps listExtensions off the critical path for every // valid request — the far more common case — and repeat misses hit the // per-colo data cache. -const extensionsPromise = listExtensions(env, { developer_id: id }); +const extensionsPromise = listExtensions(env, { developer_identifier: id }); extensionsPromise.catch(() => {}); -const developer = await getDeveloperById(env, id); +const developer = await getDeveloperByIdentifier(env, id); if (!developer) { return Astro.rewrite('/404'); @@ -58,7 +58,7 @@ if (Astro.request.method === 'POST' && developer.unclaimed) { const api = createApiClient(env, user.sub); try { - await api.claimDeveloper(developer.id, submittedNote || undefined); + await api.claimDeveloper(developer.identifier, submittedNote || undefined); claimSubmitted = true; } catch (e) { claimError = @@ -189,7 +189,7 @@ const extensions = extensionsPage?.result ?? []; data-extension-directory data-api-url="/api/extensions/cards" data-limit="50" - data-developer-id={developer.id} + data-developer-id={developer.identifier} data-has-more={ extensionsPage ? hasNextPage(extensionsPage.pagination) : false } diff --git a/src/pages/extension/[id].astro b/src/pages/extension/[id].astro index cb36d6fe..67725e58 100644 --- a/src/pages/extension/[id].astro +++ b/src/pages/extension/[id].astro @@ -92,12 +92,12 @@ const releasesView = Astro.url.searchParams.get('view') === 'releases'; ) : ( <> { ); try { const job = await createApiClient(env, guard.sub).getPackageJob(jobId); - if (job.extension_id !== context.params.id) + if (job.extension_identifier !== context.params.id) return Response.json( { error: 'Package job not found.' }, { status: 404, headers }, diff --git a/src/pages/index.astro b/src/pages/index.astro index 6aa19bf5..f6998b2f 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -14,7 +14,7 @@ import { EXTENSION_TYPES, isExtensionType } from '@/types'; const env = Astro.locals.env; const requestedType = Astro.url.searchParams.get('type'); const activeType = isExtensionType(requestedType) ? requestedType : undefined; -const requestedDeveloperId = Astro.url.searchParams.get('developer_id'); +const requestedDeveloperId = Astro.url.searchParams.get('developer_identifier'); const activeDeveloperId = requestedDeveloperId || undefined; const requestedLimit = Astro.url.searchParams.get('limit'); const activeLimit = clampApiPageLimit( @@ -27,7 +27,7 @@ let directoryError: string | null = null; try { directoryPage = await listExtensions(env, { type: activeType, - developer_id: activeDeveloperId, + developer_identifier: activeDeveloperId, limit: activeLimit, }); } catch (error) { @@ -113,7 +113,7 @@ const activeFilterCount = Developer ID ( diff --git a/src/types/index.ts b/src/types/index.ts index ae28b037..2f1aa667 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -69,6 +69,7 @@ export type License = string; export type Developer = { id: string; + identifier: string; type: DeveloperType; name: string; URL?: string; @@ -77,6 +78,7 @@ export type Developer = { }; export type Extension = { + identifier: string; manifest_version: 1; author: PackageAuthor; requires: Requirements; @@ -107,7 +109,7 @@ export type DeveloperProfile = Developer & { unclaimed?: boolean; }; -// What getDeveloperById (the public /developer/[id] read) returns — private +// What getDeveloperByIdentifier (the public /developer/[id] read) returns — private // owner and moderation fields are excluded from the local public view. export type PublicDeveloperProfile = Omit< DeveloperProfile, diff --git a/test/directory-pagination.test.ts b/test/directory-pagination.test.ts index 2f788bdf..b3bff248 100644 --- a/test/directory-pagination.test.ts +++ b/test/directory-pagination.test.ts @@ -12,7 +12,7 @@ import { item, page } from './helpers/directory-fixtures'; describe('directory page accumulation', () => { const filters = { type: 'module' as const, - developer_id: 'developer-id', + developer_identifier: 'developer-id', limit: 100, }; diff --git a/test/extension-directory.test.ts b/test/extension-directory.test.ts index de378200..224b9d88 100644 --- a/test/extension-directory.test.ts +++ b/test/extension-directory.test.ts @@ -16,12 +16,12 @@ describe('application boundary validation', () => { describe('directory filter parsing', () => { it('parses every supported filter and ignores unknown params', () => { const params = new URLSearchParams( - 'type=module&developer_id=dev-1&limit=24&cursor=opaque&q=ignored', + 'type=module&developer_identifier=dev-1&limit=24&cursor=opaque&q=ignored', ); expect(parseDirectoryFilters(params)).toEqual({ filters: { type: 'module', - developer_id: 'dev-1', + developer_identifier: 'dev-1', limit: 24, cursor: 'opaque', }, diff --git a/test/extension-form.test.ts b/test/extension-form.test.ts index a3e1a20e..bf4de60a 100644 --- a/test/extension-form.test.ts +++ b/test/extension-form.test.ts @@ -13,7 +13,7 @@ import type { Extension } from '@/types'; function extensionForm(overrides: Record = {}) { const form = new FormData(); const values = { - extension_id: 'example_module', + extension_identifier: 'example_module', type: 'module', name: 'Example', description: 'Description', @@ -49,7 +49,8 @@ function buildExtensionCreatePayload(form: FormData) { }); } const developer = { - id: 'owner', + id: '019a0000-0000-7000-8000-000000000001', + identifier: 'owner', type: 'organization' as const, name: 'Developer', }; @@ -84,7 +85,8 @@ const published: Extension = { }, }, ], - id: 'example_module', + id: '019a0000-0000-7000-8000-000000000002', + identifier: 'example_module', developer, }; @@ -92,7 +94,7 @@ describe('manifest publishing forms', () => { it('sends the new manifest fields without transforming constraints or attribution', () => { const body = buildExtensionCreatePayload(extensionForm()); expect(body).toMatchObject({ - id: 'example_module', + identifier: 'example_module', manifest_version: 1, type: 'module', license: 'MIT', @@ -112,9 +114,9 @@ describe('manifest publishing forms', () => { }); it('lowercases new ids and accepts an omitted homepage', () => { const body = buildExtensionCreatePayload( - extensionForm({ extension_id: 'My_Module', homepage_url: '' }), + extensionForm({ extension_identifier: 'My_Module', homepage_url: '' }), ); - expect(body.id).toBe('my_module'); + expect(body.identifier).toBe('my_module'); expect(body.homepage_url).toBeUndefined(); }); it('aligns the latest release requirements with corrected metadata', () => { @@ -215,7 +217,10 @@ describe('manifest publishing forms', () => { expect(echo.moduleRequirementsInput).toBe('unfinished dependency'); expect(echo.license).toBe('MIT OR BSD-3-Clause'); expect(echoReleaseDraftFromForm(form).version_tag).toBe('1.0.0'); - expect(echoNewExtensionFromForm(form, developer).extension.id).toBe( + expect( + echoNewExtensionFromForm(form, developer).extension, + ).not.toHaveProperty('id'); + expect(echoNewExtensionFromForm(form, developer).extension.identifier).toBe( 'example_module', ); }); @@ -250,7 +255,7 @@ it('safely echoes blank new-extension and release submissions', () => { }); const echo = echoNewExtensionFromForm(form, developer); expect(echo.extension).toMatchObject({ - id: '', + identifier: '', name: '', license: '', moduleRequirementsInput: '', diff --git a/test/extension-icon.test.ts b/test/extension-icon.test.ts index 79ffd26a..b26ed0ba 100644 --- a/test/extension-icon.test.ts +++ b/test/extension-icon.test.ts @@ -2,13 +2,15 @@ import { describe, expect, it } from 'vitest'; import { extensionIconUrl } from '@/lib/extension-icon'; import { directoryAssetAvifUrl, + isDirectoryAssetUrl, isTransformableImageSource, } from '@/lib/image-url'; import { getSignedImageUrl } from '@/lib/signed-image-url'; describe('extension icons', () => { it('serves prepared published images directly from the CDN with AVIF/PNG fallback', async () => { - const url = `https://extensions-assets.fossbilling.net/local/icons/${'a'.repeat(64)}.png`; + const url = + 'https://extensions-assets.fossbilling.net/extensions/019a0000-0000-7000-8000-000000000001/icons/11111111-1111-4111-8111-111111111111.png'; expect( extensionIconUrl({ icon_url: url, @@ -35,6 +37,27 @@ describe('extension icons', () => { ), ).toBeUndefined(); }); + it('recognizes only the UUID icon layout on the asset origin', () => { + const root = 'https://extensions-assets.fossbilling.net'; + const extensionId = '019a0000-0000-7000-8000-000000000001'; + const assetId = '11111111-1111-4111-8111-111111111111'; + for (const path of [ + `/extensions/${extensionId}/icons/${assetId}.svg`, + `/extensions/${extensionId}/icons/${assetId}.png`, + `/extensions/${extensionId}/icons/${assetId}.avif`, + ]) { + expect(isDirectoryAssetUrl(`${root}${path}`)).toBe(true); + } + for (const path of [ + `/local/icons/${'a'.repeat(64)}.png`, + `/extensions/demo-01/icons/${assetId}.png`, + `/extensions/${extensionId}/icons/${'a'.repeat(64)}.png`, + `/extensions/${extensionId}/icons/${assetId}.webp`, + `/extensions/${extensionId}/icons/${assetId}.png?version=1`, + ]) { + expect(isDirectoryAssetUrl(`${root}${path}`)).toBe(false); + } + }); it('uses the permission-aware package icon endpoint for the latest release', () => { const id = '11111111-1111-4111-8111-111111111111'; expect( diff --git a/test/extension-packages.test.ts b/test/extension-packages.test.ts index 8d71a703..8d626a53 100644 --- a/test/extension-packages.test.ts +++ b/test/extension-packages.test.ts @@ -108,7 +108,7 @@ beforeEach(() => { mocks.buildPackage.mockResolvedValue(artifact); mocks.queuePackage.mockResolvedValue({ id: '44444444-4444-4444-8444-444444444444', - extension_id: 'example', + extension_identifier: 'example', status: 'queued', artifact: null, error: null, @@ -257,7 +257,7 @@ describe('release package editor', () => { ); mocks.getPackageJob.mockResolvedValue({ id, - extension_id: 'example', + extension_identifier: 'example', status: 'queued', }); const response = await GET(ctx); @@ -268,7 +268,7 @@ describe('release package editor', () => { }); mocks.getPackageJob.mockResolvedValue({ id, - extension_id: 'another', + extension_identifier: 'another', status: 'queued', }); expect((await GET(ctx)).status).toBe(404); diff --git a/test/extension-view.test.ts b/test/extension-view.test.ts index 24b43476..1e44fdac 100644 --- a/test/extension-view.test.ts +++ b/test/extension-view.test.ts @@ -2,13 +2,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ getOwnDeveloper: vi.fn(), getMyExtension: vi.fn(), - getExtensionById: vi.fn(), + getExtensionByIdentifier: vi.fn(), createApiClient: vi.fn(), })); vi.mock('@/lib/api/client', async (original) => ({ ...(await original()), createApiClient: mocks.createApiClient, - getExtensionById: mocks.getExtensionById, + getExtensionByIdentifier: mocks.getExtensionByIdentifier, })); import { loadExtensionView } from '@/lib/extension-view'; import { ApiRequestError } from '@/lib/api/client'; @@ -27,7 +27,7 @@ beforeEach(() => { mocks.createApiClient.mockReturnValue(mocks); mocks.getOwnDeveloper.mockResolvedValue({ id: 'my-profile' }); mocks.getMyExtension.mockResolvedValue(owned); - mocks.getExtensionById.mockResolvedValue(published); + mocks.getExtensionByIdentifier.mockResolvedValue(published); }); describe('permission-aware extension view', () => { it('uses only the public read for anonymous visitors', async () => { @@ -44,12 +44,12 @@ describe('permission-aware extension view', () => { userId: user.sub, }); expect(mocks.createApiClient).toHaveBeenCalledWith(env, user.sub); - expect(mocks.getExtensionById).not.toHaveBeenCalled(); + expect(mocks.getExtensionByIdentifier).not.toHaveBeenCalled(); }); it('allows an owner to view a first submission without a public listing', async () => { mocks.getMyExtension.mockResolvedValue({ ...owned, published: null }); expect((await loadExtensionView(env, 'sample', user)).kind).toBe('owner'); - expect(mocks.getExtensionById).not.toHaveBeenCalled(); + expect(mocks.getExtensionByIdentifier).not.toHaveBeenCalled(); }); it('does not grant editing from a moderator cookie flag', async () => { mocks.getOwnDeveloper.mockResolvedValue(null); @@ -90,7 +90,7 @@ describe('permission-aware extension view', () => { }); it('keeps unpublished content inaccessible to unrelated visitors', async () => { mocks.getOwnDeveloper.mockResolvedValue(null); - mocks.getExtensionById.mockRejectedValue( + mocks.getExtensionByIdentifier.mockRejectedValue( new ApiRequestError(404, 'NOT_FOUND', 'Not published'), ); await expect(loadExtensionView(env, 'sample', user)).rejects.toMatchObject({ @@ -106,7 +106,7 @@ describe('permission-aware extension view', () => { await expect( loadExtensionView(env, 'sample', user), ).rejects.toMatchObject({ status: 503 }); - expect(mocks.getExtensionById).not.toHaveBeenCalled(); + expect(mocks.getExtensionByIdentifier).not.toHaveBeenCalled(); }, ); }); diff --git a/test/extensions-api.test.ts b/test/extensions-api.test.ts index a18bdf35..8fd03c98 100644 --- a/test/extensions-api.test.ts +++ b/test/extensions-api.test.ts @@ -19,7 +19,7 @@ import { clampApiPageLimit, createApiClient, getApiErrorMessage, - getExtensionById, + getExtensionByIdentifier, listExtensions, type Extension, type ExtensionCreate, @@ -144,7 +144,7 @@ describe('generated Extensions v2 façade', () => { .mockResolvedValue(apiResponse(page([], null, false))); const request = { type: 'theme' as const, - developer_id: 'developer-id', + developer_identifier: 'developer-id', limit: 25, cursor: 'opaque-cursor', }; @@ -257,14 +257,14 @@ describe('generated Extensions v2 façade', () => { await listExtensions(publicEnv, { type: 'theme', - developer_id: 'developer-id', + developer_identifier: 'developer-id', limit: 100, cursor: opaqueCursor, }); const url = requestUrl(fetchMock); expect(url.searchParams.get('type')).toBe('theme'); - expect(url.searchParams.get('developer_id')).toBe('developer-id'); + expect(url.searchParams.get('developer_identifier')).toBe('developer-id'); expect(url.searchParams.get('cursor')).toBe(opaqueCursor); }); @@ -296,7 +296,10 @@ describe('generated Extensions v2 façade', () => { .mockResolvedValue(apiResponse({ result: detail })); vi.stubGlobal('fetch', fetchMock); - const response = await getExtensionById(publicEnv, 'full-extension'); + const response = await getExtensionByIdentifier( + publicEnv, + 'full-extension', + ); expect(requestFrom(fetchMock).url).toBe( 'https://api.example.test/extensions/v2/extensions/full-extension', @@ -352,17 +355,17 @@ describe('generated Extensions v2 façade', () => { 'opaque cursor', ); // The mine scope forwards a type filter when asked, and never carries - // the public directory's developer_id filter. + // the public directory's developer_identifier filter. expect(requestUrl(fetchMock, 0).searchParams.get('type')).toBe('theme'); - expect(requestUrl(fetchMock, 0).searchParams.has('developer_id')).toBe( - false, - ); + expect( + requestUrl(fetchMock, 0).searchParams.has('developer_identifier'), + ).toBe(false); expect(requestUrl(fetchMock, 1).searchParams.has('cursor')).toBe(false); }); it('serializes a create payload with no developer field and reports the new revision', async () => { const payload = { - id: 'body-extension', + identifier: 'body-extension', manifest_version: 1 as const, author: { name: 'Package Author' }, requires: { fossbilling: '^0.8.9', php: '>=8.3' }, @@ -381,7 +384,7 @@ describe('generated Extensions v2 façade', () => { apiResponse( { result: { - id: 'body-extension', + identifier: 'body-extension', revision_id: 'revision-1', status: 'pending', }, @@ -402,7 +405,7 @@ describe('generated Extensions v2 façade', () => { expect(request.headers.get('content-type')).toBe('application/json'); expect(await request.json()).toEqual(payload); expect(result).toEqual({ - id: 'body-extension', + identifier: 'body-extension', revision_id: 'revision-1', status: 'pending', }); @@ -428,7 +431,7 @@ describe('generated Extensions v2 façade', () => { apiResponse( { result: { - id: 'body-extension', + identifier: 'body-extension', revision_id: 'revision-2', status: 'pending', }, @@ -934,6 +937,7 @@ describe('cursor-paginated moderator lists', () => { function developerProfile(id: string): DeveloperProfile { return { id, + identifier: id, type: 'user', name: id, approved: true, @@ -945,7 +949,8 @@ describe('cursor-paginated moderator lists', () => { function myClaim(id: string): PendingDeveloperClaim { return { id, - developer_id: 'dev-1', + developer_id: '019a0000-0000-7000-8000-000000000001', + developer_identifier: 'dev-1', claimant_id: 'user-1', status: 'pending', created_at: '2026-01-01T00:00:00Z', @@ -1000,7 +1005,7 @@ describe('cursor-paginated moderator lists', () => { it('walks the claims queue and profile history with explicit pages', async () => { const claim = myClaim('claim-1'); const entry: DeveloperHistoryEntry = { - developer_id: 'dev-1', + developer_id: '019a0000-0000-7000-8000-000000000001', type: 'user', name: 'Dev One', changed_by: 'user-1', @@ -1029,7 +1034,9 @@ describe('cursor-paginated moderator lists', () => { ]); expect(claims.map((c) => c.id)).toEqual(['claim-1']); - expect(history.map((h) => h.developer_id)).toEqual(['dev-1']); + expect(history.map((h) => h.developer_id)).toEqual([ + '019a0000-0000-7000-8000-000000000001', + ]); const urls = fetchMock.mock.calls.map( (call) => new URL((call[0] as Request).url), ); diff --git a/test/extensions-data.test.ts b/test/extensions-data.test.ts index dcf9c897..33b3eba9 100644 --- a/test/extensions-data.test.ts +++ b/test/extensions-data.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ createApiClient: vi.fn(), - getDeveloperById: vi.fn(), + getDeveloperByIdentifier: vi.fn(), })); // paginateAll stays real: the adapters under test route their whole-list @@ -10,11 +10,11 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/api/client', async (importOriginal) => ({ ...(await importOriginal()), createApiClient: mocks.createApiClient, - getDeveloperById: mocks.getDeveloperById, + getDeveloperByIdentifier: mocks.getDeveloperByIdentifier, })); import { - getDeveloperById, + getDeveloperByIdentifier, getDeveloperByOwner, getExtensionsByOwner, getOwnedExtension, @@ -26,6 +26,7 @@ const env = makeEnv(); const developer = { id: 'developer', + identifier: 'developer', type: 'organization' as const, name: 'Example developer', approved: true, @@ -34,7 +35,7 @@ const developer = { beforeEach(() => { mocks.createApiClient.mockReset(); - mocks.getDeveloperById.mockReset(); + mocks.getDeveloperByIdentifier.mockReset(); }); describe('API-backed extension data adapters', () => { @@ -52,6 +53,7 @@ describe('API-backed extension data adapters', () => { mocks.createApiClient.mockReturnValue({ getMyExtension: vi.fn().mockResolvedValue({ id: 'extension-id', + identifier: 'example', developer, published: { manifest_version: 1 as const, @@ -115,6 +117,7 @@ describe('API-backed extension data adapters', () => { it('maps either detail view to the same shape, delisted state included', () => { const detail = toOwnedExtensionDetail({ id: 'extension-id', + identifier: 'example', developer, published: { manifest_version: 1 as const, @@ -150,9 +153,13 @@ describe('API-backed extension data adapters', () => { }); it('treats failed public developer reads as missing profiles', async () => { - mocks.getDeveloperById.mockRejectedValue(new Error('API unavailable')); + mocks.getDeveloperByIdentifier.mockRejectedValue( + new Error('API unavailable'), + ); - await expect(getDeveloperById(env, 'developer-id')).resolves.toBeNull(); + await expect( + getDeveloperByIdentifier(env, 'developer-id'), + ).resolves.toBeNull(); }); it('keeps owner pages usable when the API cannot load the profile or list', async () => { diff --git a/test/helpers/directory-fixtures.ts b/test/helpers/directory-fixtures.ts index ee8ece24..e3285287 100644 --- a/test/helpers/directory-fixtures.ts +++ b/test/helpers/directory-fixtures.ts @@ -5,6 +5,7 @@ import type { ExtensionListItem } from '@/lib/api/client'; export function item(id: string, name = id): ExtensionListItem { return { id, + identifier: id, manifest_version: 1 as const, author: { name: 'Package Author' }, type: 'module', @@ -17,6 +18,7 @@ export function item(id: string, name = id): ExtensionListItem { download_url: `https://example.test/${id}.zip`, developer: { id: 'fossbilling', + identifier: 'fossbilling', type: 'organization', name: 'FOSSBilling', approved: true, diff --git a/test/package-api.test.ts b/test/package-api.test.ts index 013d4841..547e0ed9 100644 --- a/test/package-api.test.ts +++ b/test/package-api.test.ts @@ -32,7 +32,7 @@ describe('package API transport', () => { { result: { id: 'job', - extension_id: 'example', + extension_identifier: 'example', status: 'ready', artifact: { id: 'artifact' }, error: null, diff --git a/test/package-job.test.ts b/test/package-job.test.ts index 9ee41ae9..8763892c 100644 --- a/test/package-job.test.ts +++ b/test/package-job.test.ts @@ -4,7 +4,7 @@ import type { PackageJob } from '@/lib/api/client'; const queued = { id: 'job', - extension_id: 'example', + extension_identifier: 'example', status: 'queued', artifact: null, error: null, From 822ec3b92ccd2b3a6ab25527415c885af30217df Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Sat, 7 Nov 2026 20:01:42 +0000 Subject: [PATCH 3/6] Label newly packaged extensions as drafts --- src/components/OwnerExtensionView.astro | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/OwnerExtensionView.astro b/src/components/OwnerExtensionView.astro index 70a43abd..58ddd1c7 100644 --- a/src/components/OwnerExtensionView.astro +++ b/src/components/OwnerExtensionView.astro @@ -20,6 +20,7 @@ const api = createApiClient(env, userId); const pending = owned.pending_revision; const review = owned.last_review; const published = owned.published; +const draft = !published && !pending && !review; const listed = Boolean(published && !owned.delisted); const showSubmission = Boolean( pending && @@ -49,7 +50,9 @@ const previewLabel = showSubmission ? 'Pending Version' : published ? 'Published Version' - : 'Previous Submission'; + : draft + ? 'Draft' + : 'Previous Submission'; --- )} -{rebuilding && canEdit && ( +{rebuilding && canEdit && !draft && (

    Rebuild Your Submission

    From 67e3af049d094ee352f7ecc5ef6392d4eca4c972 Mon Sep 17 00:00:00 2001 From: Adam Daley Date: Tue, 10 Nov 2026 04:07:48 +0000 Subject: [PATCH 4/6] Validate package drafts and improve release feedback --- README.md | 4 +- src/components/ExtensionForm.astro | 12 ++++ src/components/ExtensionWorkspace.astro | 7 ++- src/components/ReleaseChecksum.astro | 16 +++++- src/components/admin/DiffValue.astro | 7 +-- src/lib/admin-revisions-client.ts | 9 ++- src/lib/extension-draft.ts | 29 +++++++--- src/lib/extension-form.ts | 9 +-- src/lib/package-download.ts | 10 ++++ src/lib/package-job.ts | 29 +++++++++- src/lib/package-response.ts | 13 ++++- src/lib/releases.ts | 2 +- src/lib/revision-diff-shared.ts | 5 +- src/pages/extension/[id].astro | 2 +- src/pages/extension/[id]/draft.ts | 1 + src/pages/extension/[id]/package.ts | 9 +++ src/scripts/extension-workspace.ts | 29 ++++++++-- test/extension-draft-submit.test.ts | 75 ++++++++++++++++++++++++- test/extension-draft.test.ts | 53 +++++++++++++---- test/extension-form.test.ts | 4 +- test/extension-icon.test.ts | 14 +++++ test/extension-package-download.test.ts | 30 ++++++++++ test/extension-packages.test.ts | 58 ++++++++++++++++++- test/package-api.test.ts | 31 ++++++++++ test/package-job.test.ts | 33 +++++++++++ test/package-upload.test.ts | 24 ++++++++ test/releases.test.ts | 8 +++ 27 files changed, 466 insertions(+), 57 deletions(-) create mode 100644 test/package-upload.test.ts diff --git a/README.md b/README.md index 2a53ced0..a306df29 100644 --- a/README.md +++ b/README.md @@ -109,11 +109,11 @@ The generated v2 client follows the API's manifest v1 contract: `module`, string Package preparation is queued. The API returns a private job ID; the release editor shows queued/processing status and saves that reference in browser storage so it can resume after a reload. A completed release joins the local draft and still needs review. Manifest corrections reuse stored source; new submissions and server-side corrections wait for the same job contract with a bounded timeout. -Authors upload ready-to-install distribution ZIPs. Icons use `icon.svg`, `icon.png`, `icon.webp` or `icon.avif` at the package root, without a manifest field. The API prepares private display images during processing and publishes approved images through the configured asset CDN. Package downloads and pending previews use permission-checked API routes. +Authors upload ready-to-install distribution ZIPs. Icons use `icon.svg`, `icon.png`, `icon.webp` or `icon.avif` at the package root, without a manifest field. The API prepares display images in the public asset bucket during processing, including pending icons at unlisted UUID URLs. Existing HTTP(S) icon URLs remain supported for display. Package downloads and pending previews use permission-checked API routes. Published icon URLs use `https://extensions-assets.fossbilling.net/extensions//icons/.`. The extension UUID is independent of its public identifier. SVG or AVIF/PNG variants share the processed icon's immutable asset UUID, so metadata-only edits retain the same icon URL. -API mutations and site mutations purge the directory cache. Deploy and smoke-test the configured, package-enabled API before deploying this site or enabling the modules publisher. The Queue and private image-storage bindings must exist before the API's automatic deployment, even when package mode is disabled. +API mutations and site mutations purge the `extensions` and `developers` cache tags. The `developers` tag is currently a no-op; developer pages are uncached. Deploy and smoke-test the configured, package-enabled API before deploying this site or enabling the modules publisher. The Queue and Images bindings must exist before the API's automatic deployment, even when package mode is disabled. ## Directory Identity diff --git a/src/components/ExtensionForm.astro b/src/components/ExtensionForm.astro index 43c96dd0..b39d0000 100644 --- a/src/components/ExtensionForm.astro +++ b/src/components/ExtensionForm.astro @@ -628,3 +628,15 @@ const otherSpdxIds = SPDX_LICENSE_IDS.filter((id) => !popularSpdxIds.has(id)); customInput.required = isOther; }); + + diff --git a/src/components/ExtensionWorkspace.astro b/src/components/ExtensionWorkspace.astro index f3e89df8..431b22a2 100644 --- a/src/components/ExtensionWorkspace.astro +++ b/src/components/ExtensionWorkspace.astro @@ -185,10 +185,15 @@ const labels: Record = { type="file" name="source" accept=".zip,application/zip" + aria-describedby="release-source-help" data-source-file /> -

    +

    Upload ready-to-install files, without a wrapping directory. Maximum 5 MiB.

    diff --git a/src/components/ReleaseChecksum.astro b/src/components/ReleaseChecksum.astro index 88a6c7b0..169d32e8 100644 --- a/src/components/ReleaseChecksum.astro +++ b/src/components/ReleaseChecksum.astro @@ -20,6 +20,12 @@ const { sha256, tag } = Astro.props; {sha256.slice(-8)}