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_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)
# Callback URL: https://your-domain.vercel.app/api/auth/callback/github
GITHUB_CLIENT_ID=

16
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "cf-ssh-launcher",
"version": "0.1.0",
"dependencies": {
"@upstash/redis": "^1.34.9",
"jose": "^5.10.0",
"lucide-react": "^0.487.0",
"next": "^15.3.1",
@ -1026,6 +1027,15 @@
"@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": {
"version": "1.0.30001787",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz",
@ -1828,6 +1838,12 @@
"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": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",

View File

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

View File

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

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!,
});