clean up: remove GitHub Pages & Cloudflare Workers, deploy to Uberspace
All-in-one Uberspace deployment: - Backend: Node.js Express on port 3000 (/api/*) - Frontend: Static files served by Apache (/) - URL: https://wtp.uber.space Removed: - GitHub Actions workflow - Cloudflare Worker setup service - CloudflareSetupPage component - CloudflareService - 404.html (GitHub Pages redirect) - Cloudflare menu item from Settings Simplified: - Use VITE_BASE_PATH and VITE_API_URL for config - Single deployment target (no multi-env complexity) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
53
.github/workflows/deploy.yml
vendored
53
.github/workflows/deploy.yml
vendored
@@ -1,53 +0,0 @@
|
||||
name: Deploy to GitHub Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: "./dist"
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -41,6 +41,7 @@ environment=PORT="3000"
|
||||
```
|
||||
|
||||
Starte den Service:
|
||||
|
||||
```bash
|
||||
supervisorctl reread
|
||||
supervisorctl update
|
||||
@@ -72,9 +73,9 @@ Für Uberspace Deployment brauchst du keine spezielle `base`:
|
||||
```typescript
|
||||
// vite.config.ts
|
||||
export default defineConfig({
|
||||
// base: "/whattoplay/", // <- entfernen für Uberspace
|
||||
plugins: [react()],
|
||||
// ...
|
||||
// base: "/whattoplay/", // <- entfernen für Uberspace
|
||||
plugins: [react()],
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
@@ -108,6 +109,7 @@ uberspace web domain add your-domain.com
|
||||
```
|
||||
|
||||
Dann DNS Records setzen:
|
||||
|
||||
```
|
||||
A @ <IP von uberspace>
|
||||
CNAME www <servername>.uberspace.de
|
||||
@@ -143,6 +145,7 @@ cp -r dist/* ~/html/
|
||||
## Kosten
|
||||
|
||||
Uberspace: ~5€/Monat (pay what you want, Minimum 1€)
|
||||
|
||||
- Unbegrenzter Traffic
|
||||
- SSH Zugriff
|
||||
- Node.js, PHP, Python, Ruby Support
|
||||
|
||||
10
index.html
10
index.html
@@ -8,16 +8,8 @@
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="WhatToPlay" />
|
||||
<link rel="manifest" href="/whattoplay/manifest.json" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>WhatToPlay</title>
|
||||
<script>
|
||||
// Restore path from 404 redirect
|
||||
const redirectPath = sessionStorage.getItem("redirectPath");
|
||||
if (redirectPath) {
|
||||
sessionStorage.removeItem("redirectPath");
|
||||
history.replaceState(null, "", redirectPath);
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<script>
|
||||
// GitHub Pages SPA redirect solution
|
||||
// Store the path and redirect to index.html
|
||||
sessionStorage.setItem(
|
||||
"redirectPath",
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
window.location.replace("/whattoplay/");
|
||||
</script>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -1,58 +1,60 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import fetch from 'node-fetch';
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import fetch from "node-fetch";
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Enable CORS for your PWA
|
||||
app.use(cors({
|
||||
origin: process.env.ALLOWED_ORIGIN || '*'
|
||||
}));
|
||||
app.use(
|
||||
cors({
|
||||
origin: process.env.ALLOWED_ORIGIN || "*",
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
app.get("/health", (req, res) => {
|
||||
res.json({ status: "ok" });
|
||||
});
|
||||
|
||||
// Proxy for Steam API - exactly like the worker
|
||||
app.all('/api/*', async (req, res) => {
|
||||
const path = req.url.replace('/api', '');
|
||||
const steamUrl = `https://store.steampowered.com${path}`;
|
||||
app.all("/api/*", async (req, res) => {
|
||||
const path = req.url.replace("/api", "");
|
||||
const steamUrl = `https://store.steampowered.com${path}`;
|
||||
|
||||
console.log(`Proxying: ${req.method} ${steamUrl}`);
|
||||
console.log(`Proxying: ${req.method} ${steamUrl}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(steamUrl, {
|
||||
method: req.method,
|
||||
headers: {
|
||||
'User-Agent': 'WhatToPlay/1.0',
|
||||
'Accept': 'application/json',
|
||||
...(req.body && { 'Content-Type': 'application/json' })
|
||||
},
|
||||
...(req.body && { body: JSON.stringify(req.body) })
|
||||
});
|
||||
try {
|
||||
const response = await fetch(steamUrl, {
|
||||
method: req.method,
|
||||
headers: {
|
||||
"User-Agent": "WhatToPlay/1.0",
|
||||
Accept: "application/json",
|
||||
...(req.body && { "Content-Type": "application/json" }),
|
||||
},
|
||||
...(req.body && { body: JSON.stringify(req.body) }),
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
const data = await response.json();
|
||||
res.json(data);
|
||||
} else {
|
||||
const text = await response.text();
|
||||
res.send(text);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Proxy error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Proxy error',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
const data = await response.json();
|
||||
res.json(data);
|
||||
} else {
|
||||
const text = await response.text();
|
||||
res.send(text);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Proxy error:", error);
|
||||
res.status(500).json({
|
||||
error: "Proxy error",
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "whattoplay-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Simple proxy server for WhatToPlay Steam API calls",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
"name": "whattoplay-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Simple proxy server for WhatToPlay Steam API calls",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "node --watch index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"node-fetch": "^3.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,14 +23,13 @@ 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";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<IonApp>
|
||||
<IonReactRouter basename="/whattoplay">
|
||||
<IonReactRouter basename={import.meta.env.BASE_URL}>
|
||||
<IonTabs>
|
||||
<IonRouterOutlet>
|
||||
<Switch>
|
||||
@@ -39,11 +38,6 @@ 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"
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
@@ -1,378 +0,0 @@
|
||||
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>
|
||||
<strong>Schritt 1:</strong> Öffne das{" "}
|
||||
<a
|
||||
href="https://dash.cloudflare.com/profile/api-tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Cloudflare API Token Dashboard
|
||||
</a>{" "}
|
||||
(falls du noch keinen Cloudflare Account hast, erstelle zuerst einen
|
||||
kostenlosen Account)
|
||||
</p>
|
||||
<p>
|
||||
<strong>Schritt 2:</strong> Klicke oben rechts auf den blauen Button{" "}
|
||||
<strong>"Create Token"</strong>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Schritt 3:</strong> Suche in der Liste nach dem Template{" "}
|
||||
<strong>"Edit Cloudflare Workers"</strong> und klicke daneben auf{" "}
|
||||
<strong>"Use template"</strong>
|
||||
</p>
|
||||
<p>
|
||||
<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>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Schritt 6:</strong> Prüfe die Zusammenfassung und klicke auf{" "}
|
||||
<strong>"Create Token"</strong>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Schritt 7:</strong> Jetzt wird dein Token angezeigt.{" "}
|
||||
<strong>Kopiere ihn</strong> (er wird nur einmal angezeigt!) und füge
|
||||
ihn unten ein.
|
||||
</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,7 +14,6 @@ import {
|
||||
} from "@ionic/react";
|
||||
import {
|
||||
cloudOutline,
|
||||
cloudUploadOutline,
|
||||
cogOutline,
|
||||
gameControllerOutline,
|
||||
} from "ionicons/icons";
|
||||
@@ -48,15 +47,6 @@ 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>
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
// 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;
|
||||
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 {
|
||||
const response = await fetch(CF_SETUP_SERVICE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "validate",
|
||||
apiToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Setup service error: ${response.status} ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
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
|
||||
: "Failed to connect to setup service",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
// 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,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Setup service error: ${response.status} ${response.statusText}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.error || "Deployment failed",
|
||||
};
|
||||
}
|
||||
|
||||
if (!data.workerUrl) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"Worker deployed, but subdomain not configured. Please enable workers.dev subdomain in Cloudflare Dashboard.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workerUrl: data.workerUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Worker deployment failed:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to connect to setup service",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import { handleSteamRefresh, handleConfigLoad } from "./server/steam-api.mjs";
|
||||
import { handleGameAsset } from "./server/assets-api.mjs";
|
||||
|
||||
@@ -22,18 +22,22 @@ const apiMiddlewarePlugin = {
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
// GitHub Pages: /whattoplay/
|
||||
// Uberspace: /
|
||||
base: process.env.VITE_BASE_PATH || "/whattoplay/",
|
||||
plugins: [react(), apiMiddlewarePlugin],
|
||||
server: {
|
||||
port: 5173,
|
||||
hmr: {
|
||||
overlay: true,
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
|
||||
return {
|
||||
// GitHub Pages: /whattoplay/
|
||||
// Uberspace: /
|
||||
base: env.VITE_BASE_PATH || "/whattoplay/",
|
||||
plugins: [react(), apiMiddlewarePlugin],
|
||||
server: {
|
||||
port: 5173,
|
||||
hmr: {
|
||||
overlay: true,
|
||||
},
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,279 +0,0 @@
|
||||
/**
|
||||
* 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" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user