add Cloudflare Setup Service to bypass CORS restrictions

The Cloudflare API blocks direct browser requests due to CORS.
This setup service acts as a proxy, allowing browser-based
worker deployment.

Changes:
- Create cf-setup-service.js worker for CORS proxy
- Update CloudflareService to use setup service API
- Add deployment instructions in workers/README.md
- Improve token creation guide with Zone field explanation

Next steps:
1. Deploy setup service: npx wrangler deploy workers/cf-setup-service.js --name whattoplay-setup
2. Update CF_SETUP_SERVICE_URL in CloudflareService.ts with your URL
3. Commit and push

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-02-06 20:44:53 +01:00
parent 493072c3ff
commit 985c61ed74
4 changed files with 453 additions and 126 deletions

View File

@@ -173,9 +173,19 @@ export default function CloudflareSetupPage() {
<strong>"Use template"</strong>
</p>
<p>
<strong>Schritt 4:</strong> Bei "Account Resources" sollte "All
accounts" ausgewählt sein. Lass alle Einstellungen wie sie sind.
<strong>Schritt 4:</strong> Überprüfe die Permissions:
</p>
<ul>
<li>
<strong>Account Resources:</strong> "All accounts" sollte ausgewählt
sein
</li>
<li>
<strong>Zone Resources:</strong> Wähle "All zones" ODER ignoriere
dieses Feld (Workers sind Account-level, nicht Zone-level)
</li>
</ul>
<p>Lass alle anderen Einstellungen wie sie sind.</p>
<p>
<strong>Schritt 5:</strong> Scrolle nach unten und klicke auf{" "}
<strong>"Continue to summary"</strong>

View File

@@ -1,9 +1,15 @@
/**
* CloudflareService - API Wrapper für Cloudflare Workers Deployment
* Ermöglicht automatisches Worker Deployment direkt aus der PWA
*
* Uses a CORS proxy service to bypass browser restrictions when calling
* the Cloudflare API. You can deploy your own setup service using
* workers/cf-setup-service.js
*/
const CF_API_BASE = "https://api.cloudflare.com/client/v4";
// Default setup service URL - you can deploy your own instance
const CF_SETUP_SERVICE_URL =
"https://whattoplay-setup.YOUR-SUBDOMAIN.workers.dev";
export interface CFAccountInfo {
valid: boolean;
@@ -25,64 +31,41 @@ export class CloudflareService {
*/
static async validateToken(apiToken: string): Promise<CFAccountInfo> {
try {
// Get accounts
const accountsResponse = await fetch(`${CF_API_BASE}/accounts`, {
const response = await fetch(CF_SETUP_SERVICE_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
action: "validate",
apiToken,
}),
});
if (!accountsResponse.ok) {
if (!response.ok) {
return {
valid: false,
error: `API Error: ${accountsResponse.status} ${accountsResponse.statusText}`,
error: `Setup service error: ${response.status} ${response.statusText}`,
};
}
const accountsData = await accountsResponse.json();
if (
!accountsData.success ||
!accountsData.result ||
accountsData.result.length === 0
) {
return {
valid: false,
error: "No Cloudflare accounts found for this token",
};
}
const account = accountsData.result[0];
// Get Workers subdomain
const subdomainResponse = await fetch(
`${CF_API_BASE}/accounts/${account.id}/workers/subdomain`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
},
);
let subdomain: string | undefined;
if (subdomainResponse.ok) {
const subdomainData = await subdomainResponse.json();
subdomain = subdomainData.result?.subdomain || undefined;
}
const data = await response.json();
return {
valid: true,
accountId: account.id,
accountName: account.name,
subdomain,
valid: data.valid,
accountId: data.accountId,
accountName: data.accountName,
subdomain: data.subdomain,
error: data.error,
};
} catch (error) {
console.error("Token validation failed:", error);
return {
valid: false,
error:
error instanceof Error ? error.message : "Token validation failed",
error instanceof Error
? error.message
: "Failed to connect to setup service",
};
}
}
@@ -108,52 +91,38 @@ export class CloudflareService {
const scriptContent = await scriptResponse.text();
// Prepare multipart form data
const formData = new FormData();
formData.append(
"metadata",
JSON.stringify({
main_module: "steam-proxy.js",
compatibility_date: "2024-01-01",
bindings: [],
}),
);
formData.append(
"steam-proxy.js",
new Blob([scriptContent], {
type: "application/javascript+module",
}),
"steam-proxy.js",
);
// Deploy Worker
const deployResponse = await fetch(
`${CF_API_BASE}/accounts/${accountId}/workers/scripts/${scriptName}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${apiToken}`,
},
body: formData,
// Deploy via setup service
const response = await fetch(CF_SETUP_SERVICE_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
);
body: JSON.stringify({
action: "deploy",
apiToken,
accountId,
scriptName,
scriptContent,
}),
});
const deployData = await deployResponse.json();
if (!deployData.success) {
const errorMsg = deployData.errors?.[0]?.message || "Deployment failed";
if (!response.ok) {
return {
success: false,
error: errorMsg,
error: `Setup service error: ${response.status} ${response.statusText}`,
};
}
// Get subdomain for Worker URL
const accountInfo = await this.validateToken(apiToken);
const data = await response.json();
if (!accountInfo.subdomain) {
if (!data.success) {
return {
success: false,
error: data.error || "Deployment failed",
};
}
if (!data.workerUrl) {
return {
success: false,
error:
@@ -161,58 +130,18 @@ export class CloudflareService {
};
}
const workerUrl = `https://${scriptName}.${accountInfo.subdomain}.workers.dev`;
return {
success: true,
workerUrl,
workerUrl: data.workerUrl,
};
} catch (error) {
console.error("Worker deployment failed:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Deployment failed",
};
}
}
/**
* Enables workers.dev subdomain (if not already enabled)
*/
static async enableSubdomain(
apiToken: string,
accountId: string,
subdomain: string,
): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(
`${CF_API_BASE}/accounts/${accountId}/workers/subdomain`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ subdomain }),
},
);
const data = await response.json();
if (!data.success) {
return {
success: false,
error: data.errors?.[0]?.message || "Failed to enable subdomain",
};
}
return { success: true };
} catch (error) {
console.error("Failed to enable subdomain:", error);
return {
success: false,
error:
error instanceof Error ? error.message : "Failed to enable subdomain",
error instanceof Error
? error.message
: "Failed to connect to setup service",
};
}
}

109
workers/README.md Normal file
View File

@@ -0,0 +1,109 @@
# Cloudflare Setup Service
This is a CORS proxy service that allows browser-based deployment of Cloudflare Workers. Deploy this once on your Cloudflare account, and all users can use it to deploy their own workers without CORS restrictions.
## Why is this needed?
The Cloudflare API doesn't allow direct browser requests due to CORS restrictions. This service acts as a proxy, forwarding requests from the browser to the Cloudflare API.
## Security
- **API tokens are NOT stored or logged** - they are only used to validate and deploy workers
- All communication happens over HTTPS
- The service only accepts POST requests with specific actions
- CORS is enabled to allow browser access
## Deployment
### Option 1: Quick Deploy with Wrangler
```bash
cd workers
npx wrangler deploy cf-setup-service.js --name whattoplay-setup
```
This will deploy the service and give you a URL like:
`https://whattoplay-setup.YOUR-SUBDOMAIN.workers.dev`
### Option 2: Deploy via Dashboard
1. Go to https://dash.cloudflare.com
2. Navigate to Workers & Pages
3. Click "Create Application" → "Create Worker"
4. Name it `whattoplay-setup`
5. Click "Deploy"
6. Click "Edit Code"
7. Replace the default code with the contents of `cf-setup-service.js`
8. Click "Save and Deploy"
## Configuration
After deployment, update the service URL in your app:
1. Open `src/services/CloudflareService.ts`
2. Replace `YOUR-SUBDOMAIN` with your actual subdomain:
```typescript
const CF_SETUP_SERVICE_URL =
"https://whattoplay-setup.YOUR-SUBDOMAIN.workers.dev";
```
3. Commit and deploy your app
## API
### POST /
**Validate Token:**
```json
{
"action": "validate",
"apiToken": "your-cf-api-token"
}
```
Response:
```json
{
"valid": true,
"accountId": "...",
"accountName": "...",
"subdomain": "..."
}
```
**Deploy Worker:**
```json
{
"action": "deploy",
"apiToken": "your-cf-api-token",
"accountId": "your-account-id",
"scriptName": "worker-name",
"scriptContent": "export default { async fetch(...) { ... } }"
}
```
Response:
```json
{
"success": true,
"workerUrl": "https://worker-name.your-subdomain.workers.dev"
}
```
## Usage Limits
Cloudflare Workers Free Tier:
- 100,000 requests/day
- 10ms CPU time per request
This should be more than enough for personal use. If you expect heavy usage, consider upgrading to the Workers Paid plan ($5/month for 10M requests).
## Sharing
You can share this service URL with other users of your app. They will use your deployment to deploy their own workers on their own Cloudflare accounts.
**Note:** This service does NOT store any data. Each user's worker is deployed on their own Cloudflare account, using their own API token.

279
workers/cf-setup-service.js Normal file
View File

@@ -0,0 +1,279 @@
/**
* Cloudflare Worker Setup Service
*
* This worker acts as a CORS proxy for the Cloudflare API, allowing
* browser-based deployment of Workers without exposing API tokens.
*
* Deploy this once on your Cloudflare account, then share the URL.
* All users can use this service to deploy their own workers.
*
* SECURITY: Tokens are NOT stored, only validated and used for deployment.
*/
export default {
async fetch(request, env, ctx) {
// Enable CORS
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
// Handle CORS preflight
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
// Only allow POST
if (request.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const { action, apiToken, accountId, scriptName, scriptContent } =
await request.json();
// Validate required fields
if (!action || !apiToken) {
return new Response(
JSON.stringify({ error: "Missing required fields" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Route to appropriate handler
if (action === "validate") {
return await handleValidateToken(apiToken, corsHeaders);
} else if (action === "deploy") {
if (!accountId || !scriptName || !scriptContent) {
return new Response(
JSON.stringify({ error: "Missing deployment fields" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
return await handleDeployWorker(
apiToken,
accountId,
scriptName,
scriptContent,
corsHeaders,
);
} else {
return new Response(JSON.stringify({ error: "Unknown action" }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
} catch (error) {
console.error("Setup service error:", error);
return new Response(
JSON.stringify({
error: "Internal server error",
message: error.message,
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
},
};
/**
* Validates API token and retrieves account info
*/
async function handleValidateToken(apiToken, corsHeaders) {
try {
// Get accounts
const accountsResponse = await fetch(
"https://api.cloudflare.com/client/v4/accounts",
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
},
);
if (!accountsResponse.ok) {
const errorData = await accountsResponse.json();
return new Response(
JSON.stringify({
valid: false,
error:
errorData.errors?.[0]?.message ||
`API Error: ${accountsResponse.status}`,
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const accountsData = await accountsResponse.json();
if (
!accountsData.success ||
!accountsData.result ||
accountsData.result.length === 0
) {
return new Response(
JSON.stringify({
valid: false,
error: "No Cloudflare accounts found for this token",
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
const account = accountsData.result[0];
// Get Workers subdomain
const subdomainResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${account.id}/workers/subdomain`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
},
);
let subdomain;
if (subdomainResponse.ok) {
const subdomainData = await subdomainResponse.json();
subdomain = subdomainData.result?.subdomain || undefined;
}
return new Response(
JSON.stringify({
valid: true,
accountId: account.id,
accountName: account.name,
subdomain,
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
} catch (error) {
return new Response(
JSON.stringify({
valid: false,
error: error.message,
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
}
/**
* Deploys Worker script to Cloudflare
*/
async function handleDeployWorker(
apiToken,
accountId,
scriptName,
scriptContent,
corsHeaders,
) {
try {
// Prepare multipart form data
const formData = new FormData();
formData.append(
"metadata",
JSON.stringify({
main_module: "script.js",
compatibility_date: "2024-01-01",
bindings: [],
}),
);
formData.append(
"script.js",
new Blob([scriptContent], { type: "application/javascript+module" }),
"script.js",
);
// Deploy Worker
const deployResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/scripts/${scriptName}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${apiToken}`,
},
body: formData,
},
);
const deployData = await deployResponse.json();
if (!deployData.success) {
const errorMsg = deployData.errors?.[0]?.message || "Deployment failed";
return new Response(
JSON.stringify({
success: false,
error: errorMsg,
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Get subdomain for Worker URL
const subdomainResponse = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/subdomain`,
{
headers: {
Authorization: `Bearer ${apiToken}`,
},
},
);
let workerUrl;
if (subdomainResponse.ok) {
const subdomainData = await subdomainResponse.json();
const subdomain = subdomainData.result?.subdomain;
if (subdomain) {
workerUrl = `https://${scriptName}.${subdomain}.workers.dev`;
}
}
return new Response(
JSON.stringify({
success: true,
workerUrl,
}),
{
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
} catch (error) {
return new Response(
JSON.stringify({
success: false,
error: error.message,
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
}