const express = require('express'); const bodyParser = require('body-parser'); const { GoogleGenerativeAI } = require('@google/generative-ai'); const { createClient } = require('redis'); const crypto = require('crypto'); 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); const APP_SUPPORT_EMAIL = process.env.APP_SUPPORT_EMAIL || 'support@posimai.soar-enrich.com'; // ========== 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}`); }); (async () => { try { await redisClient.connect(); } catch (err) { console.error('[Redis] Failed to connect:', err); process.exit(1); } })(); // ========== Gemini Client ========== const genAI = new GoogleGenerativeAI(API_KEY); const model = genAI.getGenerativeModel({ model: "gemini-2.5-flash", systemInstruction: "あなたは画像内のテキストを一字一句正確に読み取る専門家です。" + "ラベルに記載された銘柄名・蔵元名は絶対に変更・補完しないでください。" + "あなたの知識でラベルの文字を上書きすることは厳禁です。" + "ラベルに「東魁」とあれば、「東魁盛」を知っていても必ず「東魁」と出力してください。", generationConfig: { responseMimeType: "application/json", temperature: 0, } }); // ========== Authentication Middleware ========== function authMiddleware(req, res, next) { if (!AUTH_TOKEN) { console.error('[Auth] FATAL: PROXY_AUTH_TOKEN is not set.'); return res.status(503).json({ success: false, error: 'Server misconfigured: authentication token not set' }); } 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); if (token !== AUTH_TOKEN) { console.log(`[Auth] Rejected: Invalid token`); return res.status(403).json({ success: false, error: 'Invalid authentication token' }); } next(); } // ========== Global Middleware (JSON body parser + auth) ========== app.use(bodyParser.json({ limit: '10mb' })); app.use((req, res, next) => { const publicPaths = ['/health']; if (publicPaths.includes(req.path)) return next(); authMiddleware(req, res, next); }); // ========== Helper Functions ========== function getTodayString() { return new Date().toISOString().split('T')[0]; } async function checkRateLimit(deviceId) { const today = getTodayString(); const redisKey = `usage:${deviceId}:${today}`; try { 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, redisKey }; } catch (err) { console.error('[Redis] Error checking rate limit:', err); return { allowed: false, current: 0, limit: DAILY_LIMIT, remaining: 0, error: 'Rate limit check failed' }; } } async function incrementUsage(deviceId) { const today = getTodayString(); const redisKey = `usage:${deviceId}:${today}`; const newCount = await redisClient.incr(redisKey); 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; } // ========== API Endpoints ========== // 既存: AI解析 (認証必須) 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 { 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} | Count: ${limitStatus.current}/${limitStatus.limit}`); 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(); 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) { jsonData = JSON.parse(jsonMatch[1]); } else { 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)}`); } } const newCount = await incrementUsage(device_id); console.log(`[Success] Device: ${device_id} | New Count: ${newCount}/${DAILY_LIMIT}`); 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' }); } }); // ヘルスチェック app.get('/health', (req, res) => { res.send('OK'); }); // ========== Server Start ========== if (!AUTH_TOKEN) { console.error('[FATAL] PROXY_AUTH_TOKEN is not set. Refusing to start.'); process.exit(1); } if (!API_KEY) { console.error('[FATAL] GEMINI_API_KEY is not set. Refusing to start.'); process.exit(1); } app.listen(PORT, '0.0.0.0', () => { console.log(`[Server] Ponshu Room Proxy running on port ${PORT}`); console.log(`[Server] Auth: Bearer Token enabled`); console.log(`[Server] Daily Limit: ${DAILY_LIMIT} requests per device`); console.log(`[Server] License validation: enabled`); });