ponshu-room-lite/tools/proxy/server.js

233 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const express = require('express');
const bodyParser = require('body-parser');
const { GoogleGenerativeAI } = require('@google/generative-ai');
const { createClient } = require('redis');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 8080;
const API_KEY = process.env.GEMINI_API_KEY;
const AUTH_TOKEN = process.env.PROXY_AUTH_TOKEN;
// Rate Limiting Configuration
const DAILY_LIMIT = parseInt(process.env.DAILY_LIMIT || '50', 10);
const REDIS_HOST = process.env.REDIS_HOST || 'localhost';
const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10);
// Redis Client Setup
const redisClient = createClient({
socket: {
host: REDIS_HOST,
port: REDIS_PORT
}
});
redisClient.on('error', (err) => {
console.error('[Redis] Connection Error:', err);
});
redisClient.on('connect', () => {
console.log(`[Redis] Connected to ${REDIS_HOST}:${REDIS_PORT}`);
});
// Initialize Redis connection
(async () => {
try {
await redisClient.connect();
} catch (err) {
console.error('[Redis] Failed to connect:', err);
process.exit(1); // Exit if Redis is unavailable
}
})();
// Authentication Middleware (skip for /health)
function authMiddleware(req, res, next) {
if (!AUTH_TOKEN) {
// If no token configured, skip auth (backward compatibility)
console.warn('[Auth] WARNING: PROXY_AUTH_TOKEN is not set. Authentication disabled.');
return next();
}
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
console.log(`[Auth] Rejected: Missing or invalid Authorization header`);
return res.status(401).json({ success: false, error: 'Authentication required' });
}
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
if (token !== AUTH_TOKEN) {
console.log(`[Auth] Rejected: Invalid token`);
return res.status(403).json({ success: false, error: 'Invalid authentication token' });
}
next();
}
// Global middleware: Body parser first, then auth (skip /health)
app.use(bodyParser.json({ limit: '10mb' }));
app.use((req, res, next) => {
if (req.path === '/health') return next();
authMiddleware(req, res, next);
});
// Gemini Client with JSON response configuration
const genAI = new GoogleGenerativeAI(API_KEY);
const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash", // Flutter側(gemini_service.dart)と統一
generationConfig: {
responseMimeType: "application/json", // Force JSON-only output
temperature: 0.2, // チャート一貫性向上のためFlutter側と統一
}
});
// Helper: Get Today's Date String (YYYY-MM-DD)
function getTodayString() {
return new Date().toISOString().split('T')[0];
}
// Helper: Check & Update Rate Limit (Redis-based)
async function checkRateLimit(deviceId) {
const today = getTodayString();
const redisKey = `usage:${deviceId}:${today}`;
try {
// Get current usage count
const currentCount = await redisClient.get(redisKey);
const count = currentCount ? parseInt(currentCount, 10) : 0;
const remaining = DAILY_LIMIT - count;
return {
allowed: remaining > 0,
current: count,
limit: DAILY_LIMIT,
remaining: remaining,
redisKey: redisKey
};
} catch (err) {
console.error('[Redis] Error checking rate limit:', err);
// Fallback: deny request if Redis is down
return {
allowed: false,
current: 0,
limit: DAILY_LIMIT,
remaining: 0,
error: 'Rate limit check failed'
};
}
}
// Helper: Increment Usage Count (Redis-based)
async function incrementUsage(deviceId) {
const today = getTodayString();
const redisKey = `usage:${deviceId}:${today}`;
try {
// Increment count
const newCount = await redisClient.incr(redisKey);
// Set expiration to end of day (86400 seconds = 24 hours)
const now = new Date();
const midnight = new Date(now);
midnight.setHours(24, 0, 0, 0);
const secondsUntilMidnight = Math.floor((midnight - now) / 1000);
await redisClient.expire(redisKey, secondsUntilMidnight);
return newCount;
} catch (err) {
console.error('[Redis] Error incrementing usage:', err);
throw err;
}
}
// API Endpoint (authentication enforced by global middleware)
app.post('/analyze', async (req, res) => {
const { device_id, images, prompt } = req.body;
if (!device_id) {
return res.status(400).json({ success: false, error: 'Device ID is required' });
}
try {
// 1. Check Rate Limit (Redis-based)
const limitStatus = await checkRateLimit(device_id);
if (!limitStatus.allowed) {
console.log(`[Limit Reached] Device: ${device_id} (${limitStatus.current}/${limitStatus.limit})`);
return res.status(429).json({
success: false,
error: limitStatus.error || 'Daily limit reached',
usage: { today: limitStatus.current, limit: limitStatus.limit }
});
}
console.log(`[Request] Device: ${device_id} | Images: ${images ? images.length : 0} | Current: ${limitStatus.current}/${limitStatus.limit}`);
// 2. Prepare Gemini Request
// Base64 images to GenerativeContentBlob
const imageParts = (images || []).map(base64 => ({
inlineData: {
data: base64,
mimeType: "image/jpeg"
}
}));
const result = await model.generateContent([prompt, ...imageParts]);
const response = await result.response;
const text = response.text();
// 3. Parse JSON from Markdown (e.g. ```json ... ```)
console.log(`[Debug] Gemini raw response (first 200 chars): ${text.substring(0, 200)}`);
const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/) || text.match(/```([\s\S]*?)```/);
let jsonData;
if (jsonMatch) {
console.log('[Debug] Found JSON in code block');
jsonData = JSON.parse(jsonMatch[1]);
} else {
// Try parsing raw text if no code blocks
console.log('[Debug] Attempting to parse raw text as JSON');
try {
jsonData = JSON.parse(text);
} catch (parseError) {
console.error('[Error] JSON parse failed. Raw response:', text);
throw new Error(`Failed to parse Gemini response as JSON. Response starts with: ${text.substring(0, 100)}`);
}
}
// 4. Increment Usage (Redis-based)
const newCount = await incrementUsage(device_id);
console.log(`[Success] Device: ${device_id} | New Count: ${newCount}/${DAILY_LIMIT}`);
// 5. Send Response
res.json({
success: true,
data: jsonData,
usage: {
today: newCount,
limit: DAILY_LIMIT
}
});
} catch (error) {
console.error('[Error] Gemini API or Redis Error:', error);
res.status(500).json({
success: false,
error: error.message || 'Internal Server Error'
});
}
});
// Health Check
app.get('/health', (req, res) => {
res.send('OK');
});
// Start Server
app.listen(PORT, '0.0.0.0', () => {
console.log(`Proxy Server running on port ${PORT}`);
if (!API_KEY) console.error('WARNING: GEMINI_API_KEY is not set!');
if (!AUTH_TOKEN) console.error('WARNING: PROXY_AUTH_TOKEN is not set! Authentication is disabled.');
else console.log('Authentication: Bearer Token enabled');
});