feat: Upstash Redis for per-user CF credential storage

This commit is contained in:
chunzhimoe 2026-04-12 16:34:48 +08:00
parent 18b4bb853d
commit 948fe3935e
5 changed files with 58 additions and 32 deletions

View File

@ -13,6 +13,11 @@ CLOUDFLARE_API_TOKEN=your_api_token_here
CF_SERVICE_CLIENT_ID= CF_SERVICE_CLIENT_ID=
CF_SERVICE_CLIENT_SECRET= CF_SERVICE_CLIENT_SECRET=
# Upstash Redis — stores per-user CF credentials (multi-user)
# Create a free database at https://console.upstash.com
UPSTASH_REDIS_URL=https://your-db.upstash.io
UPSTASH_REDIS_TOKEN=your_upstash_token_here
# GitHub OAuth (create at https://github.com/settings/developers) # GitHub OAuth (create at https://github.com/settings/developers)
# Callback URL: https://your-domain.vercel.app/api/auth/callback/github # Callback URL: https://your-domain.vercel.app/api/auth/callback/github
GITHUB_CLIENT_ID= GITHUB_CLIENT_ID=

16
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "cf-ssh-launcher", "name": "cf-ssh-launcher",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@upstash/redis": "^1.34.9",
"jose": "^5.10.0", "jose": "^5.10.0",
"lucide-react": "^0.487.0", "lucide-react": "^0.487.0",
"next": "^15.3.1", "next": "^15.3.1",
@ -1026,6 +1027,15 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@upstash/redis": {
"version": "1.37.0",
"resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.37.0.tgz",
"integrity": "sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw==",
"license": "MIT",
"dependencies": {
"uncrypto": "^0.1.3"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001787", "version": "1.0.30001787",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
@ -1828,6 +1838,12 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/uncrypto": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz",
"integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==",
"license": "MIT"
},
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",

View File

@ -15,7 +15,8 @@
"lucide-react": "^0.487.0", "lucide-react": "^0.487.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"jose": "^5.10.0", "jose": "^5.10.0",
"next-auth": "^4.24.11" "next-auth": "^4.24.11",
"@upstash/redis": "^1.34.9"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.14.1", "@types/node": "^22.14.1",

View File

@ -1,58 +1,56 @@
import { cookies } from "next/headers"; import { getServerSession } from "next-auth";
import { jwtDecrypt, EncryptJWT } from "jose"; import { authOptions } from "@/lib/authOptions";
import { redis } from "@/lib/redis";
export interface CFCreds { export interface CFCreds {
accountId: string; accountId: string;
apiToken: string; apiToken: string;
} }
function encKey(): Uint8Array { /** Redis key for a given user's CF credentials */
const s = process.env.NEXTAUTH_SECRET ?? process.env.AUTH_SECRET ?? "dev-secret-change-me"; function credsKey(userId: string) {
const buf = Buffer.from(s.padEnd(32, "0").slice(0, 32), "utf8"); return `cf_creds:${userId}`;
return new Uint8Array(buf); }
/** Derive a stable user key from the next-auth session */
async function currentUserId(): Promise<string | null> {
const session = await getServerSession(authOptions);
if (!session?.user) return null;
const user = session.user as { id?: string; email?: string | null };
return user.id ?? user.email ?? null;
} }
export async function saveCFCreds(creds: CFCreds): Promise<void> { export async function saveCFCreds(creds: CFCreds): Promise<void> {
const token = await new EncryptJWT({ ...creds }) // Env-var mode — nothing to save
.setProtectedHeader({ alg: "dir", enc: "A256GCM" }) if (process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_API_TOKEN) return;
.setExpirationTime("90d")
.encrypt(encKey());
const jar = await cookies(); const uid = await currentUserId();
jar.set("cf_creds", token, { if (!uid) throw new Error("Not authenticated");
httpOnly: true,
secure: process.env.NODE_ENV === "production", await redis.set(credsKey(uid), creds);
sameSite: "lax",
maxAge: 60 * 60 * 24 * 90,
path: "/",
});
} }
export async function loadCFCreds(): Promise<CFCreds | null> { export async function loadCFCreds(): Promise<CFCreds | null> {
// Env vars take priority (Vercel config / self-hosted)
if (process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_API_TOKEN) { if (process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_API_TOKEN) {
return { return {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID, accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
apiToken: process.env.CLOUDFLARE_API_TOKEN, apiToken: process.env.CLOUDFLARE_API_TOKEN,
}; };
} }
try {
const jar = await cookies(); const uid = await currentUserId();
const raw = jar.get("cf_creds")?.value; if (!uid) return null;
if (!raw) return null;
const { payload } = await jwtDecrypt(raw, encKey()); return redis.get<CFCreds>(credsKey(uid));
return {
accountId: payload.accountId as string,
apiToken: payload.apiToken as string,
};
} catch {
return null;
}
} }
export async function requireCFCreds(): Promise<CFCreds> { export async function requireCFCreds(): Promise<CFCreds> {
const creds = await loadCFCreds(); const creds = await loadCFCreds();
if (!creds) { if (!creds) {
throw new Error("Cloudflare credentials not configured. Visit /settings to set them up."); throw new Error(
"Cloudflare credentials not configured. Visit /settings to set them up."
);
} }
return creds; return creds;
} }

6
src/lib/redis.ts Normal file
View File

@ -0,0 +1,6 @@
import { Redis } from "@upstash/redis";
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});