import Fastify from "fastify"; import Redis from "ioredis"; const BLOCKED_COMMANDS = new Set([ "flushall", "config", "shutdown", "save", "bgsave", "client", "monitor", "debug", "cluster", "replicaof", "slaveof", "replconf", "pfdebug", ]); const ALLOWED_COMMANDS = new Set([ "get", "set", "del", "exists", "expire", "ttl", "incr", "decr", "mget", "mset", "hget", "hset", "hdel", "hgetall", "lpush", "rpush", "lpop", "rpop", "lrange", "sadd", "srem", "smembers", "zadd", "zrem", "zrange", "ping", "scan", "keys", "flushdb", "setex", "psetex", "getdel", "getex", "zcard", "zscore", "zrevrange", "xadd", "xread", "setnx", "getset", "append", "strlen", "incrby", "decrby", "expireat", "pexpire", "pttl", "persist", "rename", "type", "lindex", "llen", "lrem", "ltrim", "hexists", "hincrby", "hincrbyfloat", "hkeys", "hvals", "hlen", "hmget", "hmset", "sismember", "scard", "sdiff", "sinter", "sunion", "zrank", "zcount", "zincrby", "zremrangebyrank", "zremrangebyscore", "srandmember", "spop", ]); const API_TOKEN = process.env.REDIS_REST_TOKEN || process.env.API_TOKEN; const REDIS_URL = process.env.INTERNAL_REDIS_URL || "redis://127.0.0.1:6379"; const PORT = parseInt(process.env.PORT || "7860", 10); const HOST = process.env.HOST || "0.0.0.0"; const BODY_LIMIT = parseInt(process.env.MAX_BODY_SIZE || "5242880", 10); const redis = new Redis(REDIS_URL, { lazyConnect: true, maxRetriesPerRequest: 3, retryStrategy: (times) => Math.min(times * 200, 3000), commandTimeout: 5000, }); redis.on("error", (err) => console.error("[redis]", err.message)); const app = Fastify({ bodyLimit: BODY_LIMIT, logger: { level: "info", serializers: { req: (req) => ({ method: req.method, url: req.url, remoteAddress: req.ip }), }, }, }); function formatResult(raw) { if (raw === null || raw === undefined) return null; if (typeof raw === "bigint") return Number(raw); if (Buffer.isBuffer(raw)) return raw.toString("utf-8"); if (Array.isArray(raw)) return raw.map(formatResult); // ioredis returns JS objects for HGETALL etc; flatten to [key,val,...] for upstash compat if (typeof raw === "object" && raw.constructor === Object) { const flat = []; for (const [k, v] of Object.entries(raw)) { flat.push(k, String(v)); } return flat; } return raw; } async function executeCommand(raw) { if (!Array.isArray(raw) || raw.length === 0) return { error: "Invalid command format" }; const cmd = String(raw[0]).toLowerCase(); const args = raw.slice(1).map(String); if (BLOCKED_COMMANDS.has(cmd)) return { error: `Command '${cmd.toUpperCase()}' is blocked` }; if (!ALLOWED_COMMANDS.has(cmd)) return { error: `Unsupported command: ${cmd.toUpperCase()}` }; try { const result = await redis.call(cmd, ...args); return { result: formatResult(result) }; } catch (err) { return { error: err.message }; } } function isAuthed(request) { if (!API_TOKEN) return true; const header = request.headers.authorization; if (!header) return false; const m = header.match(/^Bearer\s+(.+)$/i); return m ? m[1] === API_TOKEN : false; } function send401(reply) { reply.code(401); return { error: "Unauthorized" }; } // Health check — no auth app.get("/health", async () => { try { await redis.ping(); return { status: "ok", redis: "connected" }; } catch { return { status: "error", redis: "disconnected" }; } }); // Root GET — service info, no auth app.get("/", async () => ({ service: "hf-redis-rest", status: "running" })); // Path-style: GET /get/key, GET /set/key/value, etc. app.get("/:command/*", async (request, reply) => { if (!isAuthed(request)) return send401(reply); const parts = request.url.slice(1).split("/").filter(Boolean); return executeCommand(parts); }); // Pipeline: POST /pipeline app.post("/pipeline", async (request, reply) => { if (!isAuthed(request)) return send401(reply); const body = request.body; if (!Array.isArray(body)) { reply.code(400); return { error: "Request body must be an array of commands" }; } const results = []; for (const cmd of body) { results.push(await executeCommand(cmd)); } return results; }); // Main command endpoint: POST / app.post("/", async (request, reply) => { if (!isAuthed(request)) return send401(reply); const body = request.body; if (!body) return { service: "hf-redis-rest", status: "running" }; if (!Array.isArray(body)) { reply.code(400); return { error: "Request body must be a JSON array" }; } // Upstash pipeline: array of command arrays if (Array.isArray(body[0])) { const results = []; for (const cmd of body) results.push(await executeCommand(cmd)); return results; } return executeCommand(body); }); async function start() { try { await redis.connect(); console.log("[redis] connected to", REDIS_URL); await app.listen({ port: PORT, host: HOST }); console.log("[server] listening on", `${HOST}:${PORT}`); } catch (err) { console.error("[fatal]", err); process.exit(1); } } start();