add automatic Cloudflare Worker deployment via in-app wizard
This commit is contained in:
28
README.md
28
README.md
@@ -23,9 +23,24 @@ Die App ist deployed unter: https://felixfoertsch.github.io/whattoplay/
|
||||
|
||||
## Steam API auf dem iPhone nutzen
|
||||
|
||||
Die App nutzt Cloudflare Workers als CORS-Proxy für die Steam API. Du kannst deinen eigenen Worker deployen (kostenlos im Free Tier).
|
||||
|
||||
### Option 1: Automatisches In-App Deployment (Empfohlen)
|
||||
|
||||
1. Öffne die App: `https://felixfoertsch.github.io/whattoplay/`
|
||||
2. Gehe zu **Settings → Cloudflare Worker**
|
||||
3. Folge dem Setup-Wizard:
|
||||
- Erstelle CF API Token im Dashboard
|
||||
- Füge Token in App ein
|
||||
- Klicke "Worker deployen"
|
||||
4. ✅ Fertig - Worker ist deployed!
|
||||
5. Gehe zu **Settings → Steam** und nutze die Steam API
|
||||
|
||||
### Option 2: Manuelles CLI Deployment
|
||||
|
||||
Da GitHub Pages statisch ist, kannst du die Steam API nicht direkt aufrufen. Deploye stattdessen deinen eigenen Cloudflare Worker (kostenlos):
|
||||
|
||||
### 1. Deploy deinen Worker
|
||||
**Deploy deinen Worker:**
|
||||
|
||||
[](https://deploy.workers.cloudflare.com/?url=https://github.com/felixfoertsch/whattoplay)
|
||||
|
||||
@@ -47,6 +62,9 @@ npx wrangler deploy
|
||||
|
||||
### 2. Worker URL in der App konfigurieren
|
||||
|
||||
**Bei In-App Deployment**: Worker URL wird automatisch gespeichert ✅
|
||||
|
||||
**Bei manuellem Deployment**:
|
||||
1. Öffne die App auf deinem iPhone
|
||||
2. Gehe zu **Settings → Steam**
|
||||
3. Gebe deine **Worker URL** ein (z.B. `https://whattoplay-api.username.workers.dev`)
|
||||
@@ -54,6 +72,14 @@ npx wrangler deploy
|
||||
5. Füge deinen **Steam API Key** und **Steam ID** hinzu
|
||||
6. Klicke auf **Refresh** → Deine Spiele werden geladen! 🎉
|
||||
|
||||
### Warum Cloudflare Workers?
|
||||
|
||||
- ✅ **100% Kostenlos** (100k requests/Tag im Free Tier)
|
||||
- ✅ **Kein eigenes Hosting** (CF hostet für dich)
|
||||
- ✅ **Automatisches Deployment** aus der App heraus
|
||||
- ✅ **CORS-Proxy** für Steam API
|
||||
- ✅ **Schnell deployed** (~2 Minuten)
|
||||
|
||||
### 3. Steam API Key bekommen
|
||||
|
||||
1. Gehe zu https://steamcommunity.com/dev/apikey
|
||||
|
||||
132
public/workers/steam-proxy.js
Normal file
132
public/workers/steam-proxy.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Cloudflare Worker: Steam API CORS Proxy
|
||||
* Erlaubt iPhone App, Steam Web API aufzurufen
|
||||
*/
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
// CORS preflight
|
||||
if (request.method === "OPTIONS") {
|
||||
return handleCORS();
|
||||
}
|
||||
|
||||
// Nur POST /api/steam/refresh erlauben
|
||||
const url = new URL(request.url);
|
||||
if (request.method === "POST" && url.pathname === "/api/steam/refresh") {
|
||||
return handleSteamRefresh(request);
|
||||
}
|
||||
|
||||
// 404 für alle anderen Routes
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles Steam API refresh request
|
||||
*/
|
||||
async function handleSteamRefresh(request) {
|
||||
try {
|
||||
// Parse request body
|
||||
const body = await request.json();
|
||||
const { apiKey, steamId } = body;
|
||||
|
||||
if (!apiKey || !steamId) {
|
||||
return jsonResponse(
|
||||
{ error: "apiKey and steamId are required" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch games from Steam API
|
||||
const { games, count } = await fetchSteamGames(apiKey, steamId);
|
||||
|
||||
return jsonResponse({ games, count });
|
||||
} catch (error) {
|
||||
console.error("Steam API Error:", error);
|
||||
return jsonResponse(
|
||||
{ error: error.message || "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches games from Steam Web API
|
||||
*/
|
||||
async function fetchSteamGames(apiKey, steamId) {
|
||||
// Build Steam API URL
|
||||
const url = new URL(
|
||||
"https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/",
|
||||
);
|
||||
url.searchParams.set("key", apiKey);
|
||||
url.searchParams.set("steamid", steamId);
|
||||
url.searchParams.set("include_appinfo", "true");
|
||||
url.searchParams.set("include_played_free_games", "true");
|
||||
|
||||
// Call Steam API
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Steam API Error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rawGames = data.response?.games ?? [];
|
||||
|
||||
// Format games
|
||||
const games = rawGames.map((game) => ({
|
||||
id: `steam-${game.appid}`,
|
||||
title: game.name,
|
||||
source: "steam",
|
||||
sourceId: String(game.appid),
|
||||
platform: "PC",
|
||||
lastPlayed: game.rtime_last_played
|
||||
? new Date(game.rtime_last_played * 1000).toISOString().slice(0, 10)
|
||||
: null,
|
||||
playtimeHours: Math.round((game.playtime_forever / 60) * 10) / 10,
|
||||
url: `https://store.steampowered.com/app/${game.appid}`,
|
||||
}));
|
||||
|
||||
return {
|
||||
games,
|
||||
count: games.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS preflight response
|
||||
*/
|
||||
function handleCORS() {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: getCORSHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON response with CORS headers
|
||||
*/
|
||||
function jsonResponse(data, options = {}) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getCORSHeaders(),
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CORS headers for GitHub Pages
|
||||
*/
|
||||
function getCORSHeaders() {
|
||||
return {
|
||||
"Access-Control-Allow-Origin": "*", // Allow all origins (user's own worker)
|
||||
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
"Access-Control-Max-Age": "86400", // 24 hours
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import LibraryPage from "./pages/Library/LibraryPage";
|
||||
import PlaylistsPage from "./pages/Playlists/PlaylistsPage";
|
||||
import SettingsPage from "./pages/Settings/SettingsPage";
|
||||
import SettingsDetailPage from "./pages/Settings/SettingsDetailPage";
|
||||
import CloudflareSetupPage from "./pages/Settings/CloudflareSetupPage";
|
||||
|
||||
import "./App.css";
|
||||
|
||||
@@ -37,6 +38,11 @@ export default function App() {
|
||||
<Route exact path="/playlists" component={PlaylistsPage} />
|
||||
<Route exact path="/discover" component={DiscoverPage} />
|
||||
<Route exact path="/settings" component={SettingsPage} />
|
||||
<Route
|
||||
exact
|
||||
path="/settings/cloudflare"
|
||||
component={CloudflareSetupPage}
|
||||
/>
|
||||
<Route
|
||||
exact
|
||||
path="/settings/:serviceId"
|
||||
|
||||
41
src/pages/Settings/CloudflareSetupPage.css
Normal file
41
src/pages/Settings/CloudflareSetupPage.css
Normal file
@@ -0,0 +1,41 @@
|
||||
.cloudflare-setup-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.cloudflare-setup-step {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cloudflare-setup-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
|
||||
.cloudflare-setup-step h2 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cloudflare-setup-step ul {
|
||||
text-align: left;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.cloudflare-setup-step a {
|
||||
color: var(--ion-color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.cloudflare-setup-step a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.cloudflare-setup-step .ion-padding {
|
||||
padding: 16px;
|
||||
}
|
||||
348
src/pages/Settings/CloudflareSetupPage.tsx
Normal file
348
src/pages/Settings/CloudflareSetupPage.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
IonBackButton,
|
||||
IonButton,
|
||||
IonButtons,
|
||||
IonContent,
|
||||
IonHeader,
|
||||
IonIcon,
|
||||
IonInput,
|
||||
IonItem,
|
||||
IonLabel,
|
||||
IonList,
|
||||
IonLoading,
|
||||
IonPage,
|
||||
IonText,
|
||||
IonTitle,
|
||||
IonToolbar,
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
checkmarkCircleOutline,
|
||||
cloudOutline,
|
||||
keyOutline,
|
||||
rocketOutline,
|
||||
} from "ionicons/icons";
|
||||
import { useHistory } from "react-router-dom";
|
||||
|
||||
import { CloudflareService } from "../../services/CloudflareService";
|
||||
import { ConfigService } from "../../services/ConfigService";
|
||||
import { db } from "../../services/Database";
|
||||
|
||||
import "./CloudflareSetupPage.css";
|
||||
|
||||
type WizardStep = "welcome" | "token" | "deploy" | "success";
|
||||
|
||||
export default function CloudflareSetupPage() {
|
||||
const history = useHistory();
|
||||
|
||||
const [step, setStep] = useState<WizardStep>("welcome");
|
||||
const [apiToken, setApiToken] = useState("");
|
||||
const [accountInfo, setAccountInfo] = useState<{
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
subdomain?: string;
|
||||
} | null>(null);
|
||||
const [workerName, setWorkerName] = useState("whattoplay-api");
|
||||
const [workerUrl, setWorkerUrl] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const handleValidateToken = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const result = await CloudflareService.validateToken(apiToken);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!result.valid) {
|
||||
setError(result.error || "Token validation failed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.accountId || !result.accountName) {
|
||||
setError("Failed to retrieve account information");
|
||||
return;
|
||||
}
|
||||
|
||||
setAccountInfo({
|
||||
accountId: result.accountId,
|
||||
accountName: result.accountName,
|
||||
subdomain: result.subdomain,
|
||||
});
|
||||
|
||||
setStep("deploy");
|
||||
};
|
||||
|
||||
const handleDeployWorker = async () => {
|
||||
if (!accountInfo) return;
|
||||
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
const result = await CloudflareService.deployWorker(
|
||||
apiToken,
|
||||
accountInfo.accountId,
|
||||
workerName,
|
||||
);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!result.success) {
|
||||
setError(result.error || "Deployment failed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to config
|
||||
const config = await ConfigService.loadConfig();
|
||||
config.workerUrl = result.workerUrl;
|
||||
config.cloudflare = {
|
||||
apiToken,
|
||||
accountId: accountInfo.accountId,
|
||||
accountName: accountInfo.accountName,
|
||||
subdomain: accountInfo.subdomain,
|
||||
workerName,
|
||||
lastDeployed: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await ConfigService.saveConfig(config);
|
||||
|
||||
setWorkerUrl(result.workerUrl || "");
|
||||
setStep("success");
|
||||
};
|
||||
|
||||
const renderWelcome = () => (
|
||||
<div className="cloudflare-setup-step">
|
||||
<div className="cloudflare-setup-icon">
|
||||
<IonIcon icon={cloudOutline} />
|
||||
</div>
|
||||
|
||||
<h2>Cloudflare Worker einrichten</h2>
|
||||
|
||||
<IonText color="medium">
|
||||
<p>
|
||||
Für die Nutzung der Steam API auf dem iPhone wird ein Cloudflare
|
||||
Worker benötigt. Dieser läuft kostenlos auf deinem eigenen Cloudflare
|
||||
Account (Free Tier).
|
||||
</p>
|
||||
<p>
|
||||
<strong>Vorteile:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>100% Kostenlos (100k Requests/Tag)</li>
|
||||
<li>Deployment in ~2 Minuten</li>
|
||||
<li>Du hostest nichts selbst</li>
|
||||
</ul>
|
||||
</IonText>
|
||||
|
||||
<IonButton expand="block" onClick={() => setStep("token")}>
|
||||
<IonIcon slot="start" icon={rocketOutline} />
|
||||
Jetzt einrichten
|
||||
</IonButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTokenStep = () => (
|
||||
<div className="cloudflare-setup-step">
|
||||
<div className="cloudflare-setup-icon">
|
||||
<IonIcon icon={keyOutline} />
|
||||
</div>
|
||||
|
||||
<h2>API Token erstellen</h2>
|
||||
|
||||
<IonText color="medium">
|
||||
<p>
|
||||
1. Öffne das{" "}
|
||||
<a
|
||||
href="https://dash.cloudflare.com/profile/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Cloudflare Dashboard
|
||||
</a>
|
||||
</p>
|
||||
<p>2. Klicke auf "Create Token"</p>
|
||||
<p>
|
||||
3. Wähle Template: <strong>"Edit Cloudflare Workers"</strong>
|
||||
</p>
|
||||
<p>4. Klicke "Continue to summary" → "Create Token"</p>
|
||||
<p>
|
||||
5. <strong>Kopiere den Token</strong> (wird nur einmal angezeigt!)
|
||||
</p>
|
||||
</IonText>
|
||||
|
||||
<IonList inset>
|
||||
<IonItem>
|
||||
<IonLabel position="stacked">Cloudflare API Token</IonLabel>
|
||||
<IonInput
|
||||
type="password"
|
||||
placeholder="Füge deinen API Token hier ein"
|
||||
value={apiToken}
|
||||
onIonChange={(e) => setApiToken(e.detail.value || "")}
|
||||
/>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
|
||||
{error && (
|
||||
<IonText color="danger">
|
||||
<p className="ion-padding">{error}</p>
|
||||
</IonText>
|
||||
)}
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
onClick={handleValidateToken}
|
||||
disabled={!apiToken || loading}
|
||||
>
|
||||
Token validieren
|
||||
</IonButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderDeployStep = () => (
|
||||
<div className="cloudflare-setup-step">
|
||||
<div className="cloudflare-setup-icon">
|
||||
<IonIcon icon={checkmarkCircleOutline} color="success" />
|
||||
</div>
|
||||
|
||||
<h2>Token validiert!</h2>
|
||||
|
||||
<IonList inset>
|
||||
<IonItem>
|
||||
<IonLabel>
|
||||
<h3>Account</h3>
|
||||
<p>{accountInfo?.accountName}</p>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
<IonItem>
|
||||
<IonLabel>
|
||||
<h3>Subdomain</h3>
|
||||
<p>
|
||||
{accountInfo?.subdomain ? (
|
||||
`${accountInfo.subdomain}.workers.dev`
|
||||
) : (
|
||||
<IonText color="warning">
|
||||
Nicht konfiguriert - bitte in CF Dashboard aktivieren
|
||||
</IonText>
|
||||
)}
|
||||
</p>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
<IonItem>
|
||||
<IonLabel position="stacked">Worker Name (optional)</IonLabel>
|
||||
<IonInput
|
||||
placeholder="whattoplay-api"
|
||||
value={workerName}
|
||||
onIonChange={(e) =>
|
||||
setWorkerName(e.detail.value || "whattoplay-api")
|
||||
}
|
||||
/>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
|
||||
{error && (
|
||||
<IonText color="danger">
|
||||
<p className="ion-padding">{error}</p>
|
||||
</IonText>
|
||||
)}
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
onClick={handleDeployWorker}
|
||||
disabled={loading || !accountInfo?.subdomain}
|
||||
>
|
||||
<IonIcon slot="start" icon={rocketOutline} />
|
||||
Worker jetzt deployen
|
||||
</IonButton>
|
||||
|
||||
{!accountInfo?.subdomain && (
|
||||
<IonText color="medium">
|
||||
<p className="ion-padding">
|
||||
<small>
|
||||
Um einen Worker zu deployen, aktiviere zuerst die workers.dev
|
||||
Subdomain in deinem{" "}
|
||||
<a
|
||||
href="https://dash.cloudflare.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Cloudflare Dashboard
|
||||
</a>
|
||||
.
|
||||
</small>
|
||||
</p>
|
||||
</IonText>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSuccess = () => (
|
||||
<div className="cloudflare-setup-step">
|
||||
<div className="cloudflare-setup-icon">
|
||||
<IonIcon icon={checkmarkCircleOutline} color="success" />
|
||||
</div>
|
||||
|
||||
<h2>Worker deployed!</h2>
|
||||
|
||||
<IonList inset>
|
||||
<IonItem>
|
||||
<IonLabel>
|
||||
<h3>Worker URL</h3>
|
||||
<p>{workerUrl}</p>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
<IonItem>
|
||||
<IonLabel>
|
||||
<h3>Status</h3>
|
||||
<p>
|
||||
<IonText color="success">✓ Aktiv</IonText>
|
||||
</p>
|
||||
</IonLabel>
|
||||
</IonItem>
|
||||
</IonList>
|
||||
|
||||
<IonText color="medium">
|
||||
<p className="ion-padding">
|
||||
Dein Worker ist jetzt einsatzbereit! Du kannst nun die Steam API auch
|
||||
auf dem iPhone nutzen.
|
||||
</p>
|
||||
</IonText>
|
||||
|
||||
<IonButton expand="block" onClick={() => history.push("/settings/steam")}>
|
||||
Zu Steam Settings
|
||||
</IonButton>
|
||||
|
||||
<IonButton
|
||||
expand="block"
|
||||
fill="clear"
|
||||
onClick={() => history.push("/settings")}
|
||||
>
|
||||
Zurück zu Settings
|
||||
</IonButton>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<IonPage>
|
||||
<IonHeader>
|
||||
<IonToolbar>
|
||||
<IonButtons slot="start">
|
||||
<IonBackButton defaultHref="/settings" />
|
||||
</IonButtons>
|
||||
<IonTitle>Cloudflare Worker</IonTitle>
|
||||
</IonToolbar>
|
||||
</IonHeader>
|
||||
|
||||
<IonContent fullscreen>
|
||||
<div className="cloudflare-setup-container">
|
||||
{step === "welcome" && renderWelcome()}
|
||||
{step === "token" && renderTokenStep()}
|
||||
{step === "deploy" && renderDeployStep()}
|
||||
{step === "success" && renderSuccess()}
|
||||
</div>
|
||||
|
||||
<IonLoading isOpen={loading} message="Bitte warten..." />
|
||||
</IonContent>
|
||||
</IonPage>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
cloudOutline,
|
||||
cloudUploadOutline,
|
||||
cogOutline,
|
||||
gameControllerOutline,
|
||||
} from "ionicons/icons";
|
||||
@@ -47,6 +48,15 @@ export default function SettingsPage() {
|
||||
|
||||
<IonList inset>
|
||||
<IonListHeader>Verwaltung</IonListHeader>
|
||||
<IonItem
|
||||
routerLink="/settings/cloudflare"
|
||||
routerDirection="forward"
|
||||
detail
|
||||
>
|
||||
<IonIcon slot="start" icon={cloudUploadOutline} />
|
||||
<IonLabel>Cloudflare Worker</IonLabel>
|
||||
<IonNote slot="end">Deployment</IonNote>
|
||||
</IonItem>
|
||||
<IonItem routerLink="/settings/data" routerDirection="forward" detail>
|
||||
<IonIcon slot="start" icon={cloudOutline} />
|
||||
<IonLabel>Datenverwaltung</IonLabel>
|
||||
|
||||
219
src/services/CloudflareService.ts
Normal file
219
src/services/CloudflareService.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* CloudflareService - API Wrapper für Cloudflare Workers Deployment
|
||||
* Ermöglicht automatisches Worker Deployment direkt aus der PWA
|
||||
*/
|
||||
|
||||
const CF_API_BASE = "https://api.cloudflare.com/client/v4";
|
||||
|
||||
export interface CFAccountInfo {
|
||||
valid: boolean;
|
||||
accountId?: string;
|
||||
accountName?: string;
|
||||
subdomain?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CFDeploymentResult {
|
||||
success: boolean;
|
||||
workerUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class CloudflareService {
|
||||
/**
|
||||
* Validates Cloudflare API Token and retrieves account info
|
||||
*/
|
||||
static async validateToken(apiToken: string): Promise<CFAccountInfo> {
|
||||
try {
|
||||
// Get accounts
|
||||
const accountsResponse = await fetch(`${CF_API_BASE}/accounts`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!accountsResponse.ok) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `API Error: ${accountsResponse.status} ${accountsResponse.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;
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
subdomain,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Token validation failed:", error);
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Token validation failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploys Worker script to Cloudflare
|
||||
*/
|
||||
static async deployWorker(
|
||||
apiToken: string,
|
||||
accountId: string,
|
||||
scriptName: string = "whattoplay-api",
|
||||
): Promise<CFDeploymentResult> {
|
||||
try {
|
||||
// Fetch Worker script content
|
||||
const scriptResponse = await fetch("/whattoplay/workers/steam-proxy.js");
|
||||
|
||||
if (!scriptResponse.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Failed to load Worker script",
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
const deployData = await deployResponse.json();
|
||||
|
||||
if (!deployData.success) {
|
||||
const errorMsg = deployData.errors?.[0]?.message || "Deployment failed";
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
};
|
||||
}
|
||||
|
||||
// Get subdomain for Worker URL
|
||||
const accountInfo = await this.validateToken(apiToken);
|
||||
|
||||
if (!accountInfo.subdomain) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Worker deployed, but subdomain not configured. Please enable workers.dev subdomain in Cloudflare Dashboard.",
|
||||
};
|
||||
}
|
||||
|
||||
const workerUrl = `https://${scriptName}.${accountInfo.subdomain}.workers.dev`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,14 @@
|
||||
|
||||
export interface DbConfig {
|
||||
workerUrl?: string; // Cloudflare Worker URL for API proxying
|
||||
cloudflare?: {
|
||||
apiToken?: string; // CF API Token (encrypted)
|
||||
accountId?: string; // CF Account ID
|
||||
accountName?: string; // CF Account Name
|
||||
subdomain?: string; // workers.dev Subdomain
|
||||
workerName?: string; // Deployed Worker Name
|
||||
lastDeployed?: string; // Timestamp
|
||||
};
|
||||
steam?: {
|
||||
apiKey?: string;
|
||||
steamId?: string;
|
||||
|
||||
Reference in New Issue
Block a user