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

278 lines
9.6 KiB
JavaScript
Raw Normal View History

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",
generationConfig: {
responseMimeType: "application/json",
temperature: 0.2,
}
});
// ========== 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', '/license/validate'];
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.post('/license/validate', async (req, res) => {
const { license_key, device_id } = req.body;
if (!license_key || !device_id) {
return res.status(400).json({ valid: false, error: 'Missing parameters' });
}
try {
const license = await redisClient.hGetAll(`license:${license_key}`);
if (!license || Object.keys(license).length === 0) {
return res.json({ valid: false, error: 'ライセンスキーが見つかりません' });
}
if (license.status === 'revoked') {
return res.json({ valid: false, error: 'このライセンスは無効化されています。サポートにお問い合わせください。' });
}
// 初回アクティベート
if (!license.deviceId || license.deviceId === '') {
await redisClient.hSet(`license:${license_key}`, 'deviceId', device_id);
await redisClient.hSet(`license:${license_key}`, 'activatedAt', new Date().toISOString());
console.log(`[License] Activated: ${license_key} → Device: ${device_id.substring(0, 8)}...`);
return res.json({ valid: true, plan: license.plan, activated: true });
}
// 既存デバイスの照合
if (license.deviceId !== device_id) {
console.log(`[License] Device mismatch: ${license_key}`);
return res.json({
valid: false,
error: '別のデバイスで登録済みです。端末変更の場合はサポートまでご連絡ください。',
supportEmail: APP_SUPPORT_EMAIL,
});
}
return res.json({ valid: true, plan: license.plan });
} catch (err) {
console.error('[License] Validate error:', err);
res.status(500).json({ valid: false, error: 'サーバーエラーが発生しました' });
}
});
// 管理用 — ライセンス失効 (認証必須)
app.post('/admin/license/revoke', async (req, res) => {
const { license_key } = req.body;
if (!license_key) {
return res.status(400).json({ success: false, error: 'license_key required' });
}
try {
const exists = await redisClient.hExists(`license:${license_key}`, 'status');
if (!exists) {
return res.json({ success: false, error: 'License not found' });
}
await redisClient.hSet(`license:${license_key}`, 'status', 'revoked');
await redisClient.hSet(`license:${license_key}`, 'revokedAt', new Date().toISOString());
console.log(`[Admin] License revoked: ${license_key}`);
return res.json({ success: true, message: `License ${license_key} has been revoked` });
} catch (err) {
console.error('[Admin] Revoke error:', err);
res.status(500).json({ success: false, error: '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`);
});