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-04-10 15:05:53 +00:00
|
|
|
const crypto = require('crypto');
|
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);
|
|
|
|
|
|
2026-04-10 15:05:53 +00:00
|
|
|
const APP_SUPPORT_EMAIL = process.env.APP_SUPPORT_EMAIL || 'support@posimai.soar-enrich.com';
|
|
|
|
|
|
|
|
|
|
// ========== Redis Client Setup ==========
|
2026-02-21 10:35:59 +00:00
|
|
|
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);
|
2026-04-10 15:05:53 +00:00
|
|
|
process.exit(1);
|
2026-02-21 10:35:59 +00:00
|
|
|
}
|
|
|
|
|
})();
|
2026-02-15 15:13:12 +00:00
|
|
|
|
2026-04-10 15:05:53 +00:00
|
|
|
// ========== Gemini Client ==========
|
|
|
|
|
const genAI = new GoogleGenerativeAI(API_KEY);
|
|
|
|
|
const model = genAI.getGenerativeModel({
|
|
|
|
|
model: "gemini-2.5-flash",
|
|
|
|
|
generationConfig: {
|
|
|
|
|
responseMimeType: "application/json",
|
|
|
|
|
temperature: 0.2,
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ========== Authentication Middleware ==========
|
2026-02-16 02:34:00 +00:00
|
|
|
function authMiddleware(req, res, next) {
|
|
|
|
|
if (!AUTH_TOKEN) {
|
2026-04-10 15:05:53 +00:00
|
|
|
console.error('[Auth] FATAL: PROXY_AUTH_TOKEN is not set.');
|
|
|
|
|
return res.status(503).json({ success: false, error: 'Server misconfigured: authentication token not set' });
|
2026-02-16 02:34:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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' });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:05:53 +00:00
|
|
|
const token = authHeader.substring(7);
|
2026-02-16 02:34:00 +00:00
|
|
|
if (token !== AUTH_TOKEN) {
|
|
|
|
|
console.log(`[Auth] Rejected: Invalid token`);
|
|
|
|
|
return res.status(403).json({ success: false, error: 'Invalid authentication token' });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
next();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:05:53 +00:00
|
|
|
// ========== Global Middleware (JSON body parser + auth) ==========
|
2026-02-21 14:35:36 +00:00
|
|
|
app.use(bodyParser.json({ limit: '10mb' }));
|
2026-04-10 15:05:53 +00:00
|
|
|
|
2026-02-16 02:34:00 +00:00
|
|
|
app.use((req, res, next) => {
|
2026-04-10 15:16:52 +00:00
|
|
|
const publicPaths = ['/health'];
|
2026-04-10 15:05:53 +00:00
|
|
|
if (publicPaths.includes(req.path)) return next();
|
2026-02-16 02:34:00 +00:00
|
|
|
authMiddleware(req, res, next);
|
|
|
|
|
});
|
2026-02-15 15:13:12 +00:00
|
|
|
|
2026-04-10 15:05:53 +00:00
|
|
|
// ========== Helper Functions ==========
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
|
|
|
function getTodayString() {
|
|
|
|
|
return new Date().toISOString().split('T')[0];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 10:35:59 +00:00
|
|
|
async function checkRateLimit(deviceId) {
|
2026-04-10 15:05:53 +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 {
|
|
|
|
|
const currentCount = await redisClient.get(redisKey);
|
2026-04-10 15:05:53 +00:00
|
|
|
const count = currentCount ? parseInt(currentCount, 10) : 0;
|
|
|
|
|
const remaining = DAILY_LIMIT - count;
|
|
|
|
|
|
|
|
|
|
return { allowed: remaining > 0, current: count, limit: DAILY_LIMIT, remaining, redisKey };
|
2026-02-21 10:35:59 +00:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('[Redis] Error checking rate limit:', err);
|
2026-04-10 15:05:53 +00:00
|
|
|
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
|
|
|
async function incrementUsage(deviceId) {
|
2026-04-10 15:05:53 +00:00
|
|
|
const today = getTodayString();
|
2026-02-21 10:35:59 +00:00
|
|
|
const redisKey = `usage:${deviceId}:${today}`;
|
|
|
|
|
|
2026-04-10 15:05:53 +00:00
|
|
|
const newCount = await redisClient.incr(redisKey);
|
2026-02-21 10:35:59 +00:00
|
|
|
|
2026-04-10 15:05:53 +00:00
|
|
|
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);
|
2026-02-15 15:13:12 +00:00
|
|
|
|
2026-04-10 15:05:53 +00:00
|
|
|
return newCount;
|
2026-02-15 15:13:12 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-10 15:05:53 +00:00
|
|
|
// ========== API Endpoints ==========
|
|
|
|
|
|
|
|
|
|
// 既存: AI解析 (認証必須)
|
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 {
|
|
|
|
|
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-04-10 15:05:53 +00:00
|
|
|
console.log(`[Request] Device: ${device_id} | Images: ${images ? images.length : 0} | Count: ${limitStatus.current}/${limitStatus.limit}`);
|
2026-02-15 15:13:12 +00:00
|
|
|
|
|
|
|
|
const imageParts = (images || []).map(base64 => ({
|
2026-04-10 15:05:53 +00:00
|
|
|
inlineData: { data: base64, mimeType: "image/jpeg" }
|
2026-02-15 15:13:12 +00:00
|
|
|
}));
|
|
|
|
|
|
2026-04-10 15:05:53 +00:00
|
|
|
const result = await model.generateContent([prompt, ...imageParts]);
|
2026-02-15 15:13:12 +00:00
|
|
|
const response = await result.response;
|
2026-04-10 15:05:53 +00:00
|
|
|
const text = response.text();
|
2026-02-15 15:13:12 +00:00
|
|
|
|
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) {
|
|
|
|
|
jsonData = JSON.parse(jsonMatch[1]);
|
|
|
|
|
} else {
|
2026-02-21 10:35:59 +00:00
|
|
|
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
|
|
|
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
|
|
|
|
|
|
|
|
res.json({
|
|
|
|
|
success: true,
|
|
|
|
|
data: jsonData,
|
2026-04-10 15:05:53 +00:00
|
|
|
usage: { today: newCount, limit: DAILY_LIMIT }
|
2026-02-15 15:13:12 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
2026-02-21 10:35:59 +00:00
|
|
|
console.error('[Error] Gemini API or Redis Error:', error);
|
2026-04-10 15:05:53 +00:00
|
|
|
res.status(500).json({ success: false, error: error.message || 'Internal Server Error' });
|
2026-02-15 15:13:12 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 15:05:53 +00:00
|
|
|
// ヘルスチェック
|
2026-02-15 15:13:12 +00:00
|
|
|
app.get('/health', (req, res) => {
|
|
|
|
|
res.send('OK');
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-10 15:05:53 +00:00
|
|
|
// ========== 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 14:35:36 +00:00
|
|
|
app.listen(PORT, '0.0.0.0', () => {
|
2026-04-10 15:05:53 +00:00
|
|
|
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`);
|
2026-02-15 15:13:12 +00:00
|
|
|
});
|