diff --git a/.dev.vars.example b/.dev.vars.example index 4a174acf..04dd62d5 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -3,7 +3,7 @@ AUTH_CLIENT_SECRET="replace-with-oauth-client-secret-from-auth.fossbilling.net" SESSION_SECRET="replace-with-openssl-rand-base64-32" # Must match the api repo's ASSERTION_SIGNING_SECRET exactly (same shared secret). ASSERTION_SIGNING_SECRET="replace-with-openssl-rand-base64-32" -# Shared secret for POST /api/revalidate (purges CDN-cached catalogue pages). +# Shared secret for POST /api/revalidate (purges CDN-cached extension pages). # The api worker (or other writers) sends it as: Authorization: Bearer . EXTENSIONS_REVALIDATE_SECRET="replace-with-openssl-rand-base64-32" # Local development uses ordinary HTTP unless you run both Workers with a diff --git a/astro.config.ts b/astro.config.ts index 7ae5f4e5..dce9e76d 100644 --- a/astro.config.ts +++ b/astro.config.ts @@ -29,7 +29,6 @@ export default defineConfig({ // served by URL without cookies, so the first visitor's variant would // leak to everyone. '/': { maxAge: 120, swr: 60, tags: [EXTENSIONS_CACHE_TAG] }, - '/extension/[id]': { maxAge: 300, swr: 120, tags: [EXTENSIONS_CACHE_TAG] }, '/404': { maxAge: 60 }, }, // Prefetch is disabled entirely: Cloudflare's edge refuses diff --git a/openapi/extensions-v2.json b/openapi/extensions-v2.json index 0586ae6c..530a62f6 100644 --- a/openapi/extensions-v2.json +++ b/openapi/extensions-v2.json @@ -429,6 +429,10 @@ "minLength": 1, "maxLength": 4000 }, + "homepage_url": { + "type": "string", + "minLength": 1 + }, "license": { "type": "string", "minLength": 1 @@ -441,6 +445,10 @@ "minLength": 1, "maxLength": 100 }, + "download_url": { + "type": "string", + "minLength": 1 + }, "id": { "type": "string" }, @@ -458,23 +466,27 @@ "fossbilling": { "type": "string", "minLength": 1, - "maxLength": 1000 + "maxLength": 1000, + "pattern": "\\S" }, "php": { "type": "string", "minLength": 1, - "maxLength": 1000 + "maxLength": 1000, + "pattern": "\\S", + "description": "Additional PHP constraint; when omitted, FOSSBilling runtime requirements apply" }, "modules": { "type": "object", "additionalProperties": { "type": "string", "minLength": 1, - "maxLength": 1000 + "maxLength": 1000, + "pattern": "\\S" } } }, - "required": ["fossbilling", "php"], + "required": ["fossbilling"], "additionalProperties": false }, "Release": { @@ -657,7 +669,9 @@ "description": "At most 100 Unicode code points in historical content" }, "min_fossbilling_version": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 100 } }, "additionalProperties": false @@ -666,6 +680,10 @@ }, "maxItems": 100 }, + "homepage_url": { + "type": "string", + "minLength": 1 + }, "license": { "type": "string", "minLength": 1 @@ -677,6 +695,10 @@ "type": "string", "minLength": 1, "maxLength": 100 + }, + "download_url": { + "type": "string", + "minLength": 1 } } } @@ -769,7 +791,9 @@ "description": "At most 100 Unicode code points in historical content" }, "min_fossbilling_version": { - "type": "string" + "type": "string", + "minLength": 1, + "maxLength": 100 } }, "additionalProperties": false @@ -1923,7 +1947,7 @@ ], "responses": { "200": { - "description": "Extensions matching the scope: extension cards for scope=public, owned rows for scope=mine/all", + "description": "Extensions matching the scope: extension cards for scope=public, owned rows for scope=mine, moderation rows for scope=all", "content": { "application/json": { "schema": { @@ -2436,10 +2460,11 @@ } } }, - "/extensions/{id}/revisions": { - "get": { + "/extensions/{id}/revisions/{revisionId}": { + "put": { "tags": ["Extensions"], - "summary": "List an extension's revisions, newest first", + "summary": "Replace a pending submission for an extension the caller owns", + "description": "The active owner can replace publisher-created submissions. The replacement records the owner as submitter.", "security": [ { "Bearer": [] @@ -2449,53 +2474,57 @@ { "schema": { "type": "string", - "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" + "example": "acme-gateway" }, "required": true, "name": "id", "in": "path" }, - { - "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 50 - }, - "required": false, - "name": "limit", - "in": "query" - }, { "schema": { "type": "string", - "minLength": 1, - "maxLength": 1000 + "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" }, - "required": false, - "name": "cursor", - "in": "query" + "required": true, + "name": "revisionId", + "in": "path" } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExtensionUpdate" + } + } + } + }, "responses": { - "200": { - "description": "Every version proposed for this extension, with its review outcome", + "202": { + "description": "Replaced with a new pending revision ID. Published content and reviewed history are unchanged.", "content": { "application/json": { "schema": { "type": "object", "properties": { "result": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ExtensionRevisionSummary" - } - }, - "pagination": { - "$ref": "#/components/schemas/Pagination" + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "revision_id": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["pending"] + } + }, + "required": ["id", "revision_id", "status"] } }, - "required": ["result", "pagination"] + "required": ["result"] } } } @@ -2511,7 +2540,7 @@ } }, "403": { - "description": "The account is inactive, or the caller neither owns this extension nor moderates", + "description": "The account is inactive, or the caller does not own this extension", "content": { "application/json": { "schema": { @@ -2530,8 +2559,38 @@ } } }, + "409": { + "description": "The pending revision changed, was reviewed, ownership changed, or the pending-revision limit was reached", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Raw request exceeds 512 KiB", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, "422": { - "description": "Pagination query failed validation", + "description": "Body failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "429": { + "description": "Extension write allowance exhausted; see Retry-After", "content": { "application/json": { "schema": { @@ -2549,11 +2608,19 @@ } } } + }, + "503": { + "description": "Write admission unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } } } - } - }, - "/extensions/{id}/revisions/{revisionId}": { + }, "get": { "tags": ["Extensions"], "summary": "Read one revision; compacted content is null", @@ -2662,6 +2729,123 @@ } } }, + "/extensions/{id}/revisions": { + "get": { + "tags": ["Extensions"], + "summary": "List an extension's revisions, newest first", + "security": [ + { + "Bearer": [] + } + ], + "parameters": [ + { + "schema": { + "type": "string", + "example": "b6e2c9c4-3f1a-4e9b-9c3a-2e4b1a2f9d10" + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 50 + }, + "required": false, + "name": "limit", + "in": "query" + }, + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 1000 + }, + "required": false, + "name": "cursor", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Every version proposed for this extension, with its review outcome", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "result": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ExtensionRevisionSummary" + } + }, + "pagination": { + "$ref": "#/components/schemas/Pagination" + } + }, + "required": ["result", "pagination"] + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "403": { + "description": "The account is inactive, or the caller neither owns this extension nor moderates", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "No extension with that id", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "422": { + "description": "Pagination query failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Database error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, "/users/me/identity": { "put": { "tags": ["Users"], diff --git a/src/components/DetailsCard.astro b/src/components/DetailsCard.astro deleted file mode 100644 index 264e25a7..00000000 --- a/src/components/DetailsCard.astro +++ /dev/null @@ -1,143 +0,0 @@ ---- -import type { Extension } from '@/types'; -import { repositoryURL } from '@/types'; -import { isSafeHttpUrl } from '@/lib/safe-url'; -import { CircleCheck, Download, GitBranch, Scale, User } from '@lucide/astro'; - -interface Props { - ext: Extension; -} - -const { ext } = Astro.props; -const sourceUrl = repositoryURL(ext.source); - -type DetailsItem = { - name: string; - icon: typeof User; - color: 'amber' | 'green' | 'blue' | 'fuchsia'; - text: string; - link?: string; -}; - -const colorClasses: Record<'amber' | 'green' | 'blue' | 'fuchsia', string> = { - amber: 'bg-amber-100 text-amber-700', - green: 'bg-green-100 text-green-700', - blue: 'bg-blue-100 text-blue-700', - fuchsia: 'bg-fuchsia-100 text-fuchsia-700', -}; - -const details: DetailsItem[] = [ - { - name: 'Developer', - icon: User, - color: 'amber', - text: ext.developer.id, - link: `/developer/${ext.developer.id}`, - }, - { - name: 'Current Version', - icon: CircleCheck, - color: 'green', - text: `v${ext.version}`, - }, - { - name: 'FOSSBilling Version', - icon: CircleCheck, - color: 'green', - text: ext.requires.fossbilling, - }, - { - name: 'PHP Version', - icon: CircleCheck, - color: 'green', - text: ext.requires.php, - }, - { - name: 'Package Author', - icon: User, - color: 'amber', - text: ext.author.name, - link: - ext.author.url && isSafeHttpUrl(ext.author.url) - ? ext.author.url - : undefined, - }, - { - name: 'License', - icon: Scale, - color: 'blue', - text: ext.license, - }, - ...Object.entries(ext.requires.modules ?? {}).map( - ([id, range]): DetailsItem => ({ - name: `Module: ${id}`, - icon: CircleCheck, - color: 'green', - text: range, - }), - ), - { - name: 'Source Code', - icon: GitBranch, - color: 'fuchsia', - text: ext.source.repo, - link: isSafeHttpUrl(sourceUrl) ? sourceUrl : undefined, - }, - { - name: 'Download', - icon: Download, - color: 'blue', - text: 'Download extension', - link: isSafeHttpUrl(ext.download_url) ? ext.download_url : undefined, - }, -]; ---- - -
-
-

Extension Details

-
-
-
    - {details.map((detail) => ( -
  • -
    - - -
    -

    {detail.name}

    -
    -
    -
    - {detail.link ? ( - - {detail.text} - - ) : ( - - {detail.text} - - )} -
    -
  • - ))} -
-
-
diff --git a/src/components/ExtensionForm.astro b/src/components/ExtensionForm.astro index b4f80685..ca6a6f33 100644 --- a/src/components/ExtensionForm.astro +++ b/src/components/ExtensionForm.astro @@ -1,10 +1,11 @@ --- -import { EXTENSION_TYPES, SOURCE_TYPES, SPDX_LICENSE_IDS } from '@/types'; +import { EXTENSION_TYPES, SPDX_LICENSE_IDS, repositoryURL } from '@/types'; import type { Developer, Extension } from '@/types'; import { CircleAlert, SquarePen, ChevronDown } from '@lucide/astro'; import { stripUrlScheme } from '@/lib/url-prefix'; import { OTHER_LICENSE, + formatModuleRequirements, type ExtensionFormEcho, type ReleaseDraft, } from '@/lib/extension-form'; @@ -287,15 +288,16 @@ const otherSpdxIds = SPDX_LICENSE_IDS.filter((id) => !popularSpdxIds.has(id)); />
- +
@@ -305,7 +307,7 @@ const otherSpdxIds = SPDX_LICENSE_IDS.filter((id) => !popularSpdxIds.has(id)); id="requires_modules" name="requires_modules" placeholder="branding: ^1.2" - >{echo?.moduleRequirementsInput ?? Object.entries(shown?.requires.modules ?? {}).map(([id, range]) => `${id}: ${range}`).join('\n')} + >{echo?.moduleRequirementsInput ?? formatModuleRequirements(shown?.requires.modules)}

One module-id: constraint per line.

@@ -414,27 +416,18 @@ const otherSpdxIds = SPDX_LICENSE_IDS.filter((id) => !popularSpdxIds.has(id));
Source
- - -
-
-
@@ -555,7 +548,7 @@ const otherSpdxIds = SPDX_LICENSE_IDS.filter((id) => !popularSpdxIds.has(id)); id="correction_note" name="correction_note" required - maxlength="2000" + maxlength="2048" rows="2" placeholder="What was wrong with the live listing" >{correctionNote} @@ -616,7 +609,7 @@ const otherSpdxIds = SPDX_LICENSE_IDS.filter((id) => !popularSpdxIds.has(id)); diff --git a/src/components/HeaderAccount.astro b/src/components/HeaderAccount.astro index fe6b6a6c..58e85af0 100644 --- a/src/components/HeaderAccount.astro +++ b/src/components/HeaderAccount.astro @@ -1,10 +1,8 @@ --- -// Server Island: the only per-user markup in the public page chrome. Every -// page that includes this stays free of session-dependent HTML, which is -// what makes route-level CDN caching (see routeRules in astro.config.mjs) -// safe. The component reads the session cookie itself, so it takes no props -// and needs no ASTRO_KEY. Basecoat's MutationObserver-initialized registry -// wires up the dropdown menu when this fragment is inserted. +// Server Island: personal account chrome stays separate from cached extension directory +// HTML. Permission-aware extension pages render their body privately as well. +// The component reads its own session cookie, so it takes no props and needs +// no ASTRO_KEY. Basecoat's MutationObserver wires up the inserted dropdown. import { CircleUser, LogIn, LogOut, ShieldUser, User } from '@lucide/astro'; import { getSessionUser } from '@/lib/session'; diff --git a/src/components/Overview.astro b/src/components/Overview.astro deleted file mode 100644 index 4ec2ad73..00000000 --- a/src/components/Overview.astro +++ /dev/null @@ -1,13 +0,0 @@ ---- -import type { Extension } from '@/types'; -import { renderMarkdown } from '@/lib/markdown'; - -interface Props { - ext: Extension; -} - -const { ext } = Astro.props; -const readmeHtml = renderMarkdown(ext.readme ?? ''); ---- - -
diff --git a/src/components/OwnerExtensionView.astro b/src/components/OwnerExtensionView.astro new file mode 100644 index 00000000..fe0b3b73 --- /dev/null +++ b/src/components/OwnerExtensionView.astro @@ -0,0 +1,257 @@ +--- +import ConfirmDeleteDialog from './ConfirmDeleteDialog.astro'; +import DateTime from './admin/DateTime.astro'; +import StatusBadge from './admin/StatusBadge.astro'; +import ExtensionWorkspace from './ExtensionWorkspace.astro'; +import ExtensionHeader from './ExtensionHeader.astro'; +import { editableContent, reconstructionContent } from '@/lib/extension-draft'; +import { createApiClient, type OwnedExtension } from '@/lib/api/client'; +import type { StoredExtensionFields } from '@/lib/extensions-data'; +interface Props { + owned: OwnedExtension; + userId: string; +} +const { owned, userId } = Astro.props; +const id = owned.id; +const env = Astro.locals.env; +const api = createApiClient(env, userId); +// A review decision and publication are independent: rejecting an update +// leaves the published listing live. Keep both visible in the overview. +const pending = owned.pending_revision; +const review = owned.last_review; +const published = owned.published; +const listed = Boolean(published && !owned.delisted); +const showSubmission = Boolean( + pending && + (!published || Astro.url.searchParams.get('version') !== 'published'), +); +let content: StoredExtensionFields | null = showSubmission + ? (pending?.content ?? null) + : (published ?? null); +let revisionError: string | null = null; +if (!content && review) { + try { + const revision = await api.getRevision(id, review.revision_id); + content = revision.content; + } catch { + revisionError = + 'Your previous submission could not be loaded. You can still review the feedback below or try again.'; + } +} +const rebuilding = !revisionError && !editableContent(content); +const editable = + editableContent(content) ?? + (rebuilding ? reconstructionContent(content) : null); +const canEdit = (!pending || showSubmission) && Boolean(editable); +const managingReleases = Astro.url.searchParams.get('view') === 'releases'; +const name = content?.name ?? published?.name ?? id; +const previewLabel = showSubmission + ? 'Pending Version' + : published + ? 'Published Version' + : 'Previous Submission'; +--- + + + + + {pending && } + + + +{owned.delisted && ( +
+

Your Extension Has Been Delisted

+
+

{owned.delisted.reason}

+

+ Submitting an edit does not automatically restore the listing. +

+
+
+)} + +{review?.status === 'rejected' && ( +
+

+ Feedback on Your Last Submission +

+ {review.reviewed_at && ( +

+ Reviewed +

+ )} +

+ {review.review_note || + 'A moderator requested changes. Review your extension details before submitting again.'} +

+ {listed && ( +

+ Your published listing is still live. +

+ )} +
+)} + +{!published && ( +
+ + Withdraw Extension + +

+ Permanently remove this unpublished extension and its review history. Its + ID will become available again. +

+ + +
+)} +{rebuilding && canEdit && ( +
+

Rebuild Your Submission

+
+ Some previous submission content is no longer available. Complete the + listing, README, source and license, and at least one release before + submitting your draft. +
+
+)} +{published && pending && ( + +)} +{revisionError ? ( + +) : ( + +)} +
+

+ Activity +

+
    + {pending && ( +
  1. +

    Submitted for Review

    +

    + +

    +
  2. + )} + {review && ( +
  3. +

    + Last Submission{' '} + {review.status === 'approved' ? 'Approved' : 'Rejected'} +

    + {review.reviewed_at && ( +

    + +

    + )} +
  4. + )} +
  5. +

    Extension Created

    +

    + +

    +
  6. +
+
diff --git a/src/components/ReleasesCard.astro b/src/components/ReleasesCard.astro deleted file mode 100644 index 21fed609..00000000 --- a/src/components/ReleasesCard.astro +++ /dev/null @@ -1,59 +0,0 @@ ---- -import type { Extension } from '@/types'; -import { getLatestRelease, sortReleasesDescending } from '@/types'; -import { formatRelativeTime } from '@/lib/format-date'; -import { Download } from '@lucide/astro'; - -interface Props { - ext?: Extension; -} - -const { ext } = Astro.props; - -const latest = ext ? getLatestRelease(ext) : undefined; -const latestThree = ext ? sortReleasesDescending(ext.releases).slice(0, 3) : []; ---- - -
- {latest ? ( - <> -
-
-

Latest Release

- - v{latest.tag} - -
-

- -

-
-
-
    - {latestThree.map((release) => ( -
  • - v{release.tag} - - - -
  • - ))} -
-
- - ) : ( -
-

Latest Release

-
- )} -
diff --git a/src/components/ReleasesTable.astro b/src/components/ReleasesTable.astro deleted file mode 100644 index 5e7aaafd..00000000 --- a/src/components/ReleasesTable.astro +++ /dev/null @@ -1,79 +0,0 @@ ---- -import type { Extension } from '@/types'; -import { sortReleasesDescending } from '@/types'; -import { formatDate } from '@/lib/format-date'; -import { Download, List } from '@lucide/astro'; - -interface Props { - ext: Extension; -} - -const { ext } = Astro.props; -const releases = sortReleasesDescending(ext.releases); ---- - -
-
-

Releases

- - {releases.length} - -
- -
- - - - - - - - - - - - - - {releases.map((release) => ( - - - - - - - - - ))} - -
Releases for{ext.name}
TagRelease dateFOSSBillingPHPModulesLinks
v{release.tag} - - {release.requires.fossbilling}{release.requires.php} - {Object.entries(release.requires.modules ?? {}) - .map(([id, range]) => `${id}: ${range}`) - .join(', ') || '—'} - -
- - - - {release.changelog_url ? ( - - - - ) : null} -
-
-
-
diff --git a/src/components/RequirementsFields.astro b/src/components/RequirementsFields.astro new file mode 100644 index 00000000..f7029715 --- /dev/null +++ b/src/components/RequirementsFields.astro @@ -0,0 +1,44 @@ +--- +import { formatModuleRequirements } from '@/lib/extension-form'; +import type { Requirements } from '@/types'; +interface Props { + requires?: Requirements; +} +const { requires } = Astro.props; +--- + + + + diff --git a/src/components/icons/GitHubMark.astro b/src/components/icons/GitHubMark.astro new file mode 100644 index 00000000..44aa4e99 --- /dev/null +++ b/src/components/icons/GitHubMark.astro @@ -0,0 +1,34 @@ + + + + diff --git a/src/layouts/Base.astro b/src/layouts/Base.astro index 4dbd3736..cc21fccb 100644 --- a/src/layouts/Base.astro +++ b/src/layouts/Base.astro @@ -128,7 +128,7 @@ const shareImageUrl = image ? new URL(image, Astro.site).href : undefined;