add automatic Cloudflare Worker deployment via in-app wizard

This commit is contained in:
2026-02-06 19:25:14 +01:00
parent 247410226d
commit ceabfa4848
8 changed files with 791 additions and 1 deletions

View File

@@ -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:**
[![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](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

View 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
};
}

View File

@@ -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"

View 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;
}

View 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>
);
}

View File

@@ -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>

View 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",
};
}
}
}

View File

@@ -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;