Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ node_modules/
.env
.env.production
.dev.vars
.dev.vars.*

# logs
logs/
Expand Down
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,26 @@ We use [Cloudflare D1](https://developers.cloudflare.com/d1/) and [KV](https://d

### Environment Variables

Production B2 connection details and MXroute server/username are managed in the
Worker dashboard under **Settings > Variables and Secrets** as runtime variables.
Credentials are stored as secrets. Build environment variables do not configure
the running Worker. `keep_vars: true` preserves dashboard-managed variables on
Wrangler deployments; keep their values out of the committed `vars` map and docs.
Public URLs and deployment controls remain in `wrangler.jsonc`.

For local development, put operational values and credentials in the ignored
`.dev.vars` file. Document variable names and placeholders, not production values.
Package storage configuration is listed in [PACKAGING.md](src/services/extensions/v2/PACKAGING.md).

- `GITHUB_TOKEN`: A GitHub Personal Access Token (classic) with public repo read access.
- `ASSERTION_SIGNING_SECRET`: Shared HMAC secret used to verify the short-lived bearer assertions minted by the Extensions site. Configure the same value in both Workers; it is never sent to clients.
- `ASSERTION_SIGNING_SECRET_PREVIOUS`: Optional previous HMAC secret accepted during a signing-key rotation.
- `EXTENSIONS_REVALIDATE_SECRET`: Bearer token the API sends to the Extensions site's `POST /api/revalidate` (over the `EXTENSIONS_FRONTEND` service binding) to purge the site's CDN-cached extension pages after content mutations. Configure the same value in both Workers; it is never sent to clients.
- `EXTENSIONS_V2_EMAIL_PROVIDER`: `mxroute` or `resend` to send moderator notification emails, or unset/`disabled` to skip sending.
- `EXTENSIONS_V2_EMAIL_FROM`: Sender address for notification emails (default `noreply@fossbilling.org`).
- `EXTENSIONS_V2_EMAIL_REPLY_TO`: Optional Reply-To address for a monitored inbox. Omit when unset (no Reply-To header is sent) — notification emails do not invite replies.
- `EXTENSIONS_V2_MXROUTE_SERVER`, `EXTENSIONS_V2_MXROUTE_USERNAME`, `EXTENSIONS_V2_MXROUTE_PASSWORD`: Mailbox credentials for the MXroute SMTP API (`https://smtpapi.mxroute.com/`). Required when `EXTENSIONS_V2_EMAIL_PROVIDER=mxroute`.
- `EXTENSIONS_V2_MXROUTE_SERVER`, `EXTENSIONS_V2_MXROUTE_USERNAME`: Dashboard-managed runtime variables identifying the mailbox for the MXroute SMTP API (`https://smtpapi.mxroute.com/`). Required when `EXTENSIONS_V2_EMAIL_PROVIDER=mxroute`.
- `EXTENSIONS_V2_MXROUTE_PASSWORD`: Mailbox password, stored as a secret. Required when `EXTENSIONS_V2_EMAIL_PROVIDER=mxroute`.
- `EXTENSIONS_V2_RESEND_API_KEY`: API key for Resend. Required when `EXTENSIONS_V2_EMAIL_PROVIDER=resend`.
- `EXTENSIONS_RETENTION_MODE`: `dry-run` (report only) or `compact` (prune old revision bodies). See [extensions v2 README](src/services/extensions/v2/README.md#revisions-and-retention).
- `M2M_ISSUER`, `M2M_JWKS_URL`, `M2M_AUDIENCE`: Issuer, JWKS endpoint, and expected audience for machine-to-machine access tokens minted by the central auth service (`FOSSBilling/auth`). Defaults live in `wrangler.jsonc`; preview environments need them pointed at the issuing preview. See [extensions v2 README](src/services/extensions/v2/README.md#machine-publishing).
Expand All @@ -100,7 +112,7 @@ npm install

### Local Setup

1. Create a `.dev.vars` file for your secrets:
1. Create a `.dev.vars` file for local runtime variables and secrets:

```env
GITHUB_TOKEN="your-token"
Expand Down
29 changes: 29 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
"@octokit/graphql": "9.0.5",
"@octokit/request": "10.0.16",
"@scalar/hono-api-reference": "0.12.18",
"@zeropress/svg-hush": "0.1.1",
"@zip.js/zip.js": "2.24.0",
"aws4fetch": "1.0.20",
"badge-maker": "6.0.0",
"better-auth": "^1.7.14",
"drizzle-orm": "0.45.4",
Expand Down
12 changes: 12 additions & 0 deletions src/app/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
consumePackageJobs,
recoverPackageJobs
} from "../lib/adapters/cloudflare/package-jobs";
import { getExtensionsDb } from "../lib/db";
import {
maintainExtensionResources,
Expand Down Expand Up @@ -75,12 +79,20 @@ app.all("/*", (c) => {
});

export default {
queue: consumePackageJobs,
fetch: app.fetch,
request: app.request.bind(app),
scheduled: async (
_event: ScheduledController,
env: CloudflareBindings
): Promise<void> => {
try {
await recoverPackageJobs(env);
} catch (error) {
logError("extensions/packages", "Package job recovery failed", {
error: error instanceof Error ? error.message : String(error)
});
}
const db = getExtensionsDb(env.DB_EXTENSIONS);
await maintainExtensionResources(db, {
mode: env.EXTENSIONS_RETENTION_MODE
Expand Down
169 changes: 169 additions & 0 deletions src/lib/adapters/b2/package-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { AwsClient } from "aws4fetch";
import {
PACKAGE_LIMITS,
sha256
} from "../../../services/extensions/v2/package-builder";
import { PackageError } from "../../../services/extensions/v2/packages/errors";
import {
boundedBytes,
type PackageStore
} from "../../../services/extensions/v2/packages/store";

// Includes generated manifest and ZIP headers as well as the source contents.
const MAX_STORED_BYTES = PACKAGE_LIMITS.expandedBytes + 1024 * 1024;

export function b2ObjectStore(
getEnv: (name: string) => string | undefined,
kind: "packages" | "assets"
): PackageStore {
const get = (suffix: string) => {
const value = getEnv(`EXTENSION_${kind.toUpperCase()}_S3_${suffix}`);
if (!value)
throw unavailable("Package storage configuration is incomplete");
return value;
};
const origin = new URL(get("ENDPOINT"));
const bucket = get("BUCKET");
const prefix =
kind === "packages"
? (getEnv("EXTENSION_PACKAGES_S3_PREFIX") ?? "local")
: "";
if (
origin.protocol !== "https:" ||
origin.username ||
origin.password ||
origin.search ||
origin.hash ||
origin.pathname !== "/" ||
!/^[a-zA-Z0-9-]+$/.test(bucket) ||
(kind === "packages" && !/^[a-zA-Z0-9_-]+$/.test(prefix))
)
throw unavailable("Invalid package storage configuration");
if (kind === "assets") {
let base: URL;
try {
base = new URL(getEnv("EXTENSION_ASSETS_PUBLIC_URL") ?? "");
} catch {
throw unavailable("Invalid public asset URL configuration");
}
if (
base.protocol !== "https:" ||
base.username ||
base.password ||
base.search ||
base.hash
)
throw unavailable("Invalid public asset URL configuration");
}
const aws = new AwsClient({
accessKeyId: get("ACCESS_KEY_ID"),
secretAccessKey: get("SECRET_ACCESS_KEY"),
region: get("REGION"),
service: "s3",
retries: 0
});
const uuid = "[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}";
const pattern =
kind === "assets"
? new RegExp(`^extensions/${uuid}/icons/${uuid}\\.(?:svg|png|avif)$`)
: new RegExp(`^${uuid}$`);
function target(key: string) {
// Keys come from D1 reservations, never from caller-supplied paths.
if (!pattern.test(key)) throw unavailable("Invalid storage object ID");
return new URL(
`/${bucket}/${kind === "packages" ? `${prefix}/packages/` : ""}${key}`,
origin
);
}
async function request(url: URL, init: RequestInit) {
try {
return await fetch(
await aws.sign(url, {
...init,
redirect: "manual",
signal: AbortSignal.timeout(15000)
})
);
} catch {
throw unavailable("Package storage request failed");
}
}
return {
async put(key, bytes, checksum) {
if (
bytes.byteLength > MAX_STORED_BYTES ||
(await sha256(bytes)) !== checksum
)
throw unavailable("Package object failed checksum or size validation");
const response = await request(target(key), {
method: "PUT",
body: bytes,
headers: {
"Content-Type":
kind === "packages"
? "application/zip"
: key.endsWith(".svg")
? "image/svg+xml"
: key.endsWith(".avif")
? "image/avif"
: "image/png",
"Cache-Control":
kind === "packages"
? "private, no-store"
: "public, max-age=31536000, immutable",
"x-amz-content-sha256": checksum,
"x-amz-meta-sha256": checksum
}
});
await response.body?.cancel();
if (!response.ok) throw unavailable("Package upload failed");
},
async get(key, checksum) {
const response = await request(target(key), { method: "GET" });
if (response.status === 404) {
await response.body?.cancel();
return null;
}
if (
response.status !== 200 ||
(checksum && response.headers.get("x-amz-meta-sha256") !== checksum)
) {
await response.body?.cancel();
throw unavailable(
"Stored package is unavailable or has a different checksum"
);
}
// Verify actual bytes before returning an object. The size
// bound also applies to responses without Content-Length (e.g. chunked).
let bytes: Uint8Array<ArrayBuffer>;
try {
bytes = await boundedBytes(response.body, MAX_STORED_BYTES);
} catch {
throw unavailable(
"Stored package could not be read within its size limit"
);
}
if (checksum && (await sha256(bytes)) !== checksum)
throw unavailable("Stored package failed checksum verification");
return new Blob([bytes]).stream();
},
async delete(key) {
const url = target(key);
const head = await request(url, { method: "HEAD" });
await head.body?.cancel();
if (head.status === 404) return;
const version = head.headers.get("x-amz-version-id");
if (!head.ok || !version)
throw unavailable("Unable to identify package version for cleanup");
// B2 DELETE without versionId only adds a marker and keeps billable bytes.
url.searchParams.set("versionId", version);
const response = await request(url, { method: "DELETE" });
await response.body?.cancel();
if (!response.ok && response.status !== 404)
throw unavailable("Package cleanup failed");
}
};
}
function unavailable(message: string) {
return new PackageError("PACKAGE_UNAVAILABLE", message, 503);
}
8 changes: 8 additions & 0 deletions src/lib/adapters/cloudflare/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CloudflareQueueAdapter } from "./queue";
import { IPlatformBindings } from "../../interfaces";
import { CloudflareKVAdapter } from "./cache";
import { CloudflareEnvironmentAdapter } from "./environment";
Expand All @@ -18,6 +19,13 @@ export function createCloudflareBindings(
let bindings = bindingsCache.get(env);
if (!bindings) {
bindings = {
queues: env.EXTENSION_PACKAGE_QUEUE
? {
EXTENSION_PACKAGE_QUEUE: new CloudflareQueueAdapter(
env.EXTENSION_PACKAGE_QUEUE
)
}
: {},
caches: {
CACHE_KV: new CloudflareKVAdapter(env.CACHE_KV),
AUTH_KV: new CloudflareKVAdapter(env.AUTH_KV)
Expand Down
63 changes: 63 additions & 0 deletions src/lib/adapters/cloudflare/package-assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { PackageBuildDatabase } from "../../../services/extensions/v2/db/package-builds";
import { logError } from "../../logger";
import { revalidateExtensions } from "../../../services/extensions/v2/revalidate";
import type { Context } from "hono";
import { getPlatform } from "../../middleware";
import { PackageError } from "../../../services/extensions/v2/packages/errors";

// Publishing changes the listing URL only; icons are already stored in B2.
async function updatePublishedIconUrl(
c: Context<{ Bindings: CloudflareBindings }>,
extensionId: string
) {
extensionId = extensionId.toLowerCase();
const platform = getPlatform(c);
const repository = new PackageBuildDatabase(c.env.DB_EXTENSIONS);
const row = await repository.publishedIcon(extensionId);
if (!row?.object_key) return;
const base = platform.getEnv("EXTENSION_ASSETS_PUBLIC_URL");
if (!base)
throw new PackageError(
"ASSETS_UNAVAILABLE",
"Public asset URL is not configured",
503
);
const publicBase = new URL(base);
if (
publicBase.protocol !== "https:" ||
publicBase.username ||
publicBase.password ||
publicBase.search ||
publicBase.hash
)
throw new PackageError(
"ASSETS_UNAVAILABLE",
"Invalid public asset URL",
503
);
const iconUrl = `${publicBase.href.replace(/\/$/, "")}/${row.object_key}`;
return repository.updatePublishedIcon(
extensionId,
row.published_revision_id,
iconUrl
);
}

export function schedulePublishedIconUpdate(
c: Context<{ Bindings: CloudflareBindings }>,
extensionId: string
) {
if (!getPlatform(c).getEnv("EXTENSION_ASSETS_S3_ENDPOINT")) return;
c.executionCtx.waitUntil(
updatePublishedIconUrl(c, extensionId)
.then((changed) => {
if (changed) revalidateExtensions(c);
})
.catch((error) => {
logError("extensions/packages", "Published icon URL update failed", {
extensionId,
error: error instanceof Error ? error.message : String(error)
});
})
);
}
Loading
Loading