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
41 changes: 9 additions & 32 deletions .github/workflows/deploy-workers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,6 @@ jobs:
- name: Checkout
uses: actions/checkout@v7

- name: Require Wrangler environment
if: vars.CLOUDFLARE_ENV == ''
run: |
echo "::error::Set CLOUDFLARE_ENV in the selected GitHub environment."
exit 1

- name: Install dependencies
uses: ./.github/actions/pnpm-install

Expand All @@ -48,43 +42,26 @@ jobs:
PUBLIC_API_URL: https://sky.shiiyu.moe/api/
SERVER_API_TOKEN: ${{ secrets.SERVER_API_TOKEN }}

- name: Validate Worker bundle
run: pnpm exec wrangler deploy --dry-run --no-x-provision --env "$CLOUDFLARE_ENV"

- name: Get preview alias
id: ref
if: github.ref != 'refs/heads/dev'
run: echo "ref_name=${GITHUB_REF_NAME//[^a-zA-Z0-9-]/-}" >> "$GITHUB_OUTPUT"

- name: Initialize Preview Worker
if: github.ref != 'refs/heads/dev'
run: |
if pnpm exec wrangler versions list --env "$CLOUDFLARE_ENV" --json > "$RUNNER_TEMP/worker-versions.json" 2> "$RUNNER_TEMP/worker-versions-error.log"; then
exit 0
fi
if ! grep -Fq '[code: 10007]' "$RUNNER_TEMP/worker-versions-error.log"; then
cat "$RUNNER_TEMP/worker-versions-error.log" >&2
exit 1
fi
pnpm exec wrangler deploy --no-x-provision --env "$CLOUDFLARE_ENV"
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

- name: Deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
gitHubToken: ${{ github.token }}
environment: ${{ vars.CLOUDFLARE_ENV }}
command: ${{ github.ref == 'refs/heads/dev' && 'deploy --no-x-provision' || format('versions upload --preview-alias {0} --tag {1}-{2}', steps.ref.outputs.ref_name, github.run_id, github.run_attempt) }}
command: deploy --no-x-provision

# Preview uploads do not apply tail consumers or observability settings.
- name: Deploy Preview Version
- name: Upload Preview
if: github.ref != 'refs/heads/dev'
run: pnpm exec wrangler versions deploy --version-tag "$VERSION_TAG@100%" --env "$CLOUDFLARE_ENV" --yes --no-x-provision
env:
VERSION_TAG: ${{ github.run_id }}-${{ github.run_attempt }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
gitHubToken: ${{ github.token }}
environment: ${{ vars.CLOUDFLARE_ENV }}
command: versions upload --preview-alias ${{ steps.ref.outputs.ref_name }} --tag ${{ github.run_id }}-${{ github.run_attempt }} --no-x-provision
39 changes: 23 additions & 16 deletions src/lib/shared/api/mutator/custom-instance.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,10 @@
import { getRequestEvent } from "$app/server";
import { env as envPrivate } from "$env/dynamic/private";
import { env as envPublic } from "$env/dynamic/public";
import { readApiResponse } from "./readApiResponse";

const { PUBLIC_SERVER_API_URL } = envPublic;

// NOTE: Supports cases where `content-type` is other than `json`
const getBody = <T>(c: Response | Request): Promise<T> => {
const contentType = c.headers.get("content-type");

if (contentType && contentType.includes("application/json")) {
return c.json();
}

return c.text() as Promise<T>;
};

// NOTE: Update just base url
const getUrl = (contextUrl: string): string => {
// Remove the trailing /api/ if present
Expand Down Expand Up @@ -43,17 +33,34 @@ export const customFetch = async <T>(url: string, options: RequestInit): Promise
(envPrivate.SERVER_API_TOKEN as App.Platform["env"]["SERVER_API_TOKEN"]) ?? event?.platform?.env.SERVER_API_TOKEN;
const serverApiToken = typeof token === "string" ? token : ((await token?.get()) ?? "");

const headers = new Headers(options.headers);
// Only resource-pack preferences belong to the API. Forwarding browser cookies and
// Access JWT headers can exceed the upstream request-header limit.
const enabledPacksCookie = event?.request.headers
.get("cookie")
?.split(";")
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith("enabledPacks="));
if (enabledPacksCookie && !headers.has("cookie")) {
headers.set("Cookie", enabledPacksCookie);
}
// Replace any caller token regardless of its header casing.
headers.delete("X-API-Token");

const requestInit: RequestInit = {
...options,
// Prevent SvelteKit's fetch from adding browser cookies/authorization back.
credentials: "omit",
headers: {
...(event ? Object.fromEntries(event.request.headers) : {}),
...options.headers,
"X-API-Token": serverApiToken
...Object.fromEntries(headers),
"X-API-Token": serverApiToken,
"User-Agent": "Lunar Client (skycrypt-embed.lunarclient.com)"
}
};

const response = await (event?.fetch ?? fetch)(getUrl(url), requestInit);
const data = await getBody<T>(response);
const requestUrl = getUrl(url);
const response = await (event?.fetch ?? fetch)(requestUrl, requestInit);
const data = await readApiResponse(response, requestUrl, requestInit.method);

return { status: response.status, data, headers: response.headers } as T;
};
59 changes: 59 additions & 0 deletions src/lib/shared/api/mutator/readApiResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { error } from "@sveltejs/kit";

/** Reject upstream error pages before they can be used or prerendered as API data. */
export async function readApiResponse(response: Response, requestUrl: string, method = "GET"): Promise<unknown> {
const { origin, pathname } = new URL(requestUrl);
const contentType = response.headers.get("content-type");
const mediaType = contentType?.split(";", 1)[0].trim().toLowerCase();
const rayId = response.headers.get("cf-ray");
// Omit query strings, credentials, and response bodies from diagnostics.
const details = [
`upstream HTTP ${response.status} ${response.statusText}`.trim(),
`Content-Type: ${contentType || "missing"}`,
...(rayId ? [`CF-Ray: ${rayId}`] : [])
].join("; ");

const fail = (reason: string, status = 502): never => {
error(status, `SkyCrypt API ${method} ${origin}${pathname} failed: ${reason} (${details})`);
};

if (response.headers.get("cf-mitigated") === "challenge") {
await response.body?.cancel();
fail("Cloudflare challenged the request (cf-mitigated: challenge). Check the upstream WAF/bot protection rules.");
}

const isJson =
mediaType === "application/json" || (mediaType?.startsWith("application/") && mediaType.endsWith("+json"));
if (!isJson) {
// The generated client also exposes image-rendering endpoints that return PNG bytes.
if (response.ok && mediaType === "image/png") {
return response.blob();
}

await response.body?.cancel();
const hint =
mediaType === "text/html"
? " HTML may indicate a WAF challenge, an Access login page, or an upstream proxy error."
: "";
fail(`Expected a JSON API response.${hint}`);
}

let data: unknown;
try {
data = await response.json();
} catch {
fail("The upstream API returned invalid JSON.");
}

const apiError =
typeof data === "object" && data !== null && "error" in data && typeof data.error === "string"
? data.error
: undefined;

if (!response.ok || apiError) {
const status = response.status >= 400 && response.status < 600 ? response.status : 502;
fail(apiError || "The upstream API returned an unsuccessful response.", status);
}

return data;
}
Loading