2026-02-15 15:13:12 +00:00
|
|
|
|
const express = require('express');
|
|
|
|
|
|
const bodyParser = require('body-parser');
|
|
|
|
|
|
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
2026-02-21 10:35:59 +00:00
|
|
|
|
const { createClient } = require('redis');
|
2026-02-15 15:13:12 +00:00
|
|
|
|
require('dotenv').config();
|
|
|
|
|
|
|
|
|
|
|
|
const app = express();
|
|
|
|
|
|
const PORT = process.env.PORT || 8080;
|
|
|
|
|
|
const API_KEY = process.env.GEMINI_API_KEY;
|
2026-02-16 02:34:00 +00:00
|
|
|
|
const AUTH_TOKEN = process.env.PROXY_AUTH_TOKEN;
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
2026-02-21 10:35:59 +00:00
|
|
|
|
// Rate Limiting Configuration
|
2026-02-15 15:13:12 +00:00
|
|
|
|
const DAILY_LIMIT = parseInt(process.env.DAILY_LIMIT || '50', 10);
|
2026-02-21 10:35:59 +00:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
})();
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
2026-02-16 02:34:00 +00:00
|
|
|
|
// 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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-21 14:35:36 +00:00
|
|
|
|
// Global middleware: Body parser first, then auth (skip /health)
|
|
|
|
|
|
app.use(bodyParser.json({ limit: '10mb' }));
|
2026-02-16 02:34:00 +00:00
|
|
|
|
app.use((req, res, next) => {
|
|
|
|
|
|
if (req.path === '/health') return next();
|
|
|
|
|
|
authMiddleware(req, res, next);
|
|
|
|
|
|
});
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
2026-02-21 10:35:59 +00:00
|
|
|
|
// Gemini Client with JSON response configuration
|
2026-02-15 15:13:12 +00:00
|
|
|
|
const genAI = new GoogleGenerativeAI(API_KEY);
|
2026-02-21 10:35:59 +00:00
|
|
|
|
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側と統一)
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
|
|
|
|
|
// Helper: Get Today's Date String (YYYY-MM-DD)
|
|
|
|
|
|
function getTodayString() {
|
|
|
|
|
|
return new Date().toISOString().split('T')[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-21 10:35:59 +00:00
|
|
|
|
// Helper: Check & Update Rate Limit (Redis-based)
|
|
|
|
|
|
async function checkRateLimit(deviceId) {
|
2026-02-15 15:13:12 +00:00
|
|
|
|
const today = getTodayString();
|
2026-02-21 10:35:59 +00:00
|
|
|
|
const redisKey = `usage:${deviceId}:${today}`;
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
2026-02-21 10:35:59 +00:00
|
|
|
|
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'
|
|
|
|
|
|
};
|
2026-02-15 15:13:12 +00:00
|
|
|
|
}
|
2026-02-21 10:35:59 +00:00
|
|
|
|
}
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
2026-02-21 10:35:59 +00:00
|
|
|
|
// 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);
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
2026-02-21 10:35:59 +00:00
|
|
|
|
await redisClient.expire(redisKey, secondsUntilMidnight);
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
2026-02-21 10:35:59 +00:00
|
|
|
|
return newCount;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('[Redis] Error incrementing usage:', err);
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
2026-02-15 15:13:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-16 02:34:00 +00:00
|
|
|
|
// API Endpoint (authentication enforced by global middleware)
|
2026-02-15 15:13:12 +00:00
|
|
|
|
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' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-21 10:35:59 +00:00
|
|
|
|
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 }
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
2026-02-21 10:35:59 +00:00
|
|
|
|
console.log(`[Request] Device: ${device_id} | Images: ${images ? images.length : 0} | Current: ${limitStatus.current}/${limitStatus.limit}`);
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
|
|
|
|
|
// 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 ... ```)
|
2026-02-21 10:35:59 +00:00
|
|
|
|
console.log(`[Debug] Gemini raw response (first 200 chars): ${text.substring(0, 200)}`);
|
|
|
|
|
|
|
2026-02-15 15:13:12 +00:00
|
|
|
|
const jsonMatch = text.match(/```json\n([\s\S]*?)\n```/) || text.match(/```([\s\S]*?)```/);
|
|
|
|
|
|
let jsonData;
|
|
|
|
|
|
|
|
|
|
|
|
if (jsonMatch) {
|
2026-02-21 10:35:59 +00:00
|
|
|
|
console.log('[Debug] Found JSON in code block');
|
2026-02-15 15:13:12 +00:00
|
|
|
|
jsonData = JSON.parse(jsonMatch[1]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Try parsing raw text if no code blocks
|
2026-02-21 10:35:59 +00:00
|
|
|
|
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)}`);
|
|
|
|
|
|
}
|
2026-02-15 15:13:12 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-21 10:35:59 +00:00
|
|
|
|
// 4. Increment Usage (Redis-based)
|
|
|
|
|
|
const newCount = await incrementUsage(device_id);
|
|
|
|
|
|
console.log(`[Success] Device: ${device_id} | New Count: ${newCount}/${DAILY_LIMIT}`);
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
|
|
|
|
|
// 5. Send Response
|
|
|
|
|
|
res.json({
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
data: jsonData,
|
|
|
|
|
|
usage: {
|
2026-02-21 10:35:59 +00:00
|
|
|
|
today: newCount,
|
2026-02-15 15:13:12 +00:00
|
|
|
|
limit: DAILY_LIMIT
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
2026-02-21 10:35:59 +00:00
|
|
|
|
console.error('[Error] Gemini API or Redis Error:', error);
|
2026-02-15 15:13:12 +00:00
|
|
|
|
res.status(500).json({
|
|
|
|
|
|
success: false,
|
|
|
|
|
|
error: error.message || 'Internal Server Error'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Health Check
|
|
|
|
|
|
app.get('/health', (req, res) => {
|
|
|
|
|
|
res.send('OK');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// Start Server
|
2026-02-21 14:35:36 +00:00
|
|
|
|
app.listen(PORT, '0.0.0.0', () => {
|
2026-02-15 15:13:12 +00:00
|
|
|
|
console.log(`Proxy Server running on port ${PORT}`);
|
|
|
|
|
|
if (!API_KEY) console.error('WARNING: GEMINI_API_KEY is not set!');
|
2026-02-16 02:34:00 +00:00
|
|
|
|
if (!AUTH_TOKEN) console.error('WARNING: PROXY_AUTH_TOKEN is not set! Authentication is disabled.');
|
|
|
|
|
|
else console.log('Authentication: Bearer Token enabled');
|
2026-02-15 15:13:12 +00:00
|
|
|
|
});
|