feat: Migrate Arbiter and Modpack Version Checker to monorepo
WHAT WAS DONE: - Migrated Arbiter (discord-oauth-arbiter) code to services/arbiter/ - Migrated Modpack Version Checker code to services/modpack-version-checker/ - Created .env.example for Arbiter with all required environment variables - Moved systemd service file to services/arbiter/deploy/ - Organized directory structure per Gemini monorepo recommendations WHY: - Consolidate all service code in one repository - Prepare for Gemini code review (Panel v1.12 compatibility check) - Enable service-prefixed Git tagging (arbiter-v2.1.0, modpack-v1.0.0) - Support npm workspaces for shared dependencies SERVICES MIGRATED: 1. Arbiter (Discord OAuth bot) - Originally written by Gemini + Claude - Full source code from ops-manual docs/implementation/ - Created comprehensive .env.example - Ready for Panel v1.12 compatibility verification 2. Modpack Version Checker (Python CLI tool) - Full source code from ops-manual docs/tasks/ - Written for Panel v1.11, needs Gemini review for v1.12 - Never had code review before STILL TODO: - Whitelist Manager - Pull from Billing VPS (38.68.14.188) - Currently deployed and running - Needs Panel v1.12 API compatibility fix (Task #86) - Requires SSH access to pull code NEXT STEPS: - Gemini code review for Panel v1.12 API compatibility - Create package.json for each service - Test npm workspaces integration - Deploy after verification FILES: - services/arbiter/ (25 new files, full application) - services/modpack-version-checker/ (21 new files, full application) Signed-off-by: The Golden Chronicler <claude@firefrostgaming.com>
This commit is contained in:
57
services/arbiter/src/cmsService.js
Normal file
57
services/arbiter/src/cmsService.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// src/cmsService.js
|
||||
// Ghost CMS Admin API integration for member management
|
||||
|
||||
const api = require('@tryghost/admin-api');
|
||||
|
||||
const cms = new api({
|
||||
url: process.env.CMS_URL,
|
||||
key: process.env.CMS_ADMIN_KEY,
|
||||
version: 'v5.0'
|
||||
});
|
||||
|
||||
/**
|
||||
* Find a Ghost member by their email address
|
||||
* @param {string} email - Email address to search for
|
||||
* @returns {Promise<Object>} - Ghost member object
|
||||
* @throws {Error} - If member not found
|
||||
*/
|
||||
async function findMemberByEmail(email) {
|
||||
// We use the browse method with a filter to find the exact match
|
||||
const members = await cms.members.browse({ filter: `email:'${email}'` });
|
||||
|
||||
if (members.length === 0) {
|
||||
throw new Error('Member not found');
|
||||
}
|
||||
|
||||
// Return the first match
|
||||
return members[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a Ghost member's discord_id custom field
|
||||
* @param {string} email - Member's email address
|
||||
* @param {string} discordId - Discord user ID (snowflake)
|
||||
* @returns {Promise<Object>} - Updated member object
|
||||
*/
|
||||
async function updateMemberDiscordId(email, discordId) {
|
||||
const members = await cms.members.browse({ filter: `email:'${email}'` });
|
||||
|
||||
if (members.length === 0) {
|
||||
throw new Error('Member not found in CMS');
|
||||
}
|
||||
|
||||
const updated = await cms.members.edit({
|
||||
id: members[0].id,
|
||||
custom_fields: [
|
||||
{ name: 'discord_id', value: discordId }
|
||||
]
|
||||
});
|
||||
|
||||
console.log(`[Ghost] Updated discord_id for ${email}`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findMemberByEmail,
|
||||
updateMemberDiscordId
|
||||
};
|
||||
46
services/arbiter/src/database.js
Normal file
46
services/arbiter/src/database.js
Normal file
@@ -0,0 +1,46 @@
|
||||
// src/database.js
|
||||
// SQLite database initialization and maintenance for Firefrost Arbiter
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database('linking.db');
|
||||
|
||||
// Create tables if they don't exist
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS link_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
tier TEXT NOT NULL,
|
||||
subscription_id TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
used INTEGER DEFAULT 0
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
admin_id TEXT NOT NULL,
|
||||
target_user TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Cleanup function - removes tokens older than 24 hours
|
||||
function cleanupExpiredTokens() {
|
||||
const stmt = db.prepare(`
|
||||
DELETE FROM link_tokens
|
||||
WHERE created_at < datetime('now', '-1 day')
|
||||
`);
|
||||
const info = stmt.run();
|
||||
console.log(`[Database] Cleaned up ${info.changes} expired tokens.`);
|
||||
}
|
||||
|
||||
// Run cleanup once every 24 hours (86400000 ms)
|
||||
setInterval(cleanupExpiredTokens, 86400000);
|
||||
|
||||
// Run cleanup on startup to clear any that expired while app was down
|
||||
cleanupExpiredTokens();
|
||||
|
||||
module.exports = db;
|
||||
104
services/arbiter/src/discordService.js
Normal file
104
services/arbiter/src/discordService.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// src/discordService.js
|
||||
// Discord bot client initialization and role management functions
|
||||
|
||||
const { Client, GatewayIntentBits } = require('discord.js');
|
||||
const rolesConfig = require('../config/roles.json');
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers]
|
||||
});
|
||||
|
||||
// Initialize the Discord bot login
|
||||
client.login(process.env.DISCORD_BOT_TOKEN);
|
||||
|
||||
client.on('ready', () => {
|
||||
console.log(`[Discord] Bot logged in as ${client.user.tag}`);
|
||||
});
|
||||
|
||||
/**
|
||||
* Assign a Discord role to a user based on their subscription tier
|
||||
* @param {string} userId - Discord user ID (snowflake)
|
||||
* @param {string} tier - Subscription tier name (e.g., 'awakened', 'fire_elemental')
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
async function assignDiscordRole(userId, tier) {
|
||||
try {
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
if (!guild) throw new Error('Guild not found.');
|
||||
|
||||
// Fetch the member. If they aren't in the server, this throws an error.
|
||||
const member = await guild.members.fetch(userId);
|
||||
|
||||
const roleId = rolesConfig[tier];
|
||||
if (!roleId) throw new Error(`No role mapping found for tier: ${tier}`);
|
||||
|
||||
const role = guild.roles.cache.get(roleId);
|
||||
if (!role) throw new Error(`Role ID ${roleId} not found in server.`);
|
||||
|
||||
await member.roles.add(role);
|
||||
console.log(`[Discord] Assigned role ${tier} to user ${userId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Discord] Failed to assign role to ${userId}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all subscription roles from a user (used for cancellations or before upgrades)
|
||||
* @param {string} userId - Discord user ID (snowflake)
|
||||
* @returns {Promise<boolean>} - Success status
|
||||
*/
|
||||
async function removeAllSubscriptionRoles(userId) {
|
||||
try {
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
if (!guild) throw new Error('Guild not found.');
|
||||
|
||||
const member = await guild.members.fetch(userId);
|
||||
|
||||
// Extract all role IDs from the config
|
||||
const allRoleIds = Object.values(rolesConfig);
|
||||
|
||||
// discord.js allows removing an array of role IDs at once
|
||||
await member.roles.remove(allRoleIds);
|
||||
console.log(`[Discord] Removed all subscription roles from ${userId}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[Discord] Failed to remove roles for ${userId}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update subscription roles (remove old, add new) - used for tier changes
|
||||
* @param {string} userId - Discord user ID
|
||||
* @param {string|null} newTier - New tier name, or null for cancellation
|
||||
*/
|
||||
async function updateSubscriptionRoles(userId, newTier = null) {
|
||||
try {
|
||||
const guild = client.guilds.cache.get(process.env.GUILD_ID);
|
||||
const member = await guild.members.fetch(userId);
|
||||
|
||||
// 1. Remove ALL possible subscription roles
|
||||
const allRoleIds = Object.values(rolesConfig);
|
||||
await member.roles.remove(allRoleIds);
|
||||
|
||||
// 2. Add the new role (if not cancelled)
|
||||
if (newTier && rolesConfig[newTier]) {
|
||||
const newRole = guild.roles.cache.get(rolesConfig[newTier]);
|
||||
if (newRole) await member.roles.add(newRole);
|
||||
}
|
||||
|
||||
console.log(`[Discord] Updated roles for ${userId} to ${newTier || 'none'}`);
|
||||
} catch (error) {
|
||||
console.error(`[Discord] Role update failed for ${userId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
client,
|
||||
rolesConfig,
|
||||
assignDiscordRole,
|
||||
removeAllSubscriptionRoles,
|
||||
updateSubscriptionRoles
|
||||
};
|
||||
49
services/arbiter/src/email.js
Normal file
49
services/arbiter/src/email.js
Normal file
@@ -0,0 +1,49 @@
|
||||
// src/email.js
|
||||
// Email service using Nodemailer for subscription linking notifications
|
||||
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: 587,
|
||||
secure: false, // Use STARTTLS
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Send Discord linking email to subscriber
|
||||
* @param {string} name - Customer name
|
||||
* @param {string} email - Customer email address
|
||||
* @param {string} token - Secure linking token
|
||||
* @returns {Promise} - Nodemailer send result
|
||||
*/
|
||||
async function sendLinkingEmail(name, email, token) {
|
||||
const link = `${process.env.APP_URL}/link?token=${token}`;
|
||||
|
||||
const textBody = `Hi ${name},
|
||||
|
||||
Thanks for subscribing to Firefrost Gaming!
|
||||
|
||||
To access your game servers, please connect your Discord account:
|
||||
|
||||
${link}
|
||||
|
||||
This link expires in 24 hours. Once connected, you'll see your server channels in Discord with IPs pinned at the top.
|
||||
|
||||
Questions? Join us in Discord: https://firefrostgaming.com/discord
|
||||
|
||||
- The Firefrost Team
|
||||
🔥❄️`;
|
||||
|
||||
return transporter.sendMail({
|
||||
from: `"Firefrost Gaming" <${process.env.SMTP_USER}>`,
|
||||
to: email,
|
||||
subject: 'Welcome to Firefrost Gaming! 🔥❄️ One More Step...',
|
||||
text: textBody
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { sendLinkingEmail };
|
||||
101
services/arbiter/src/index.js
Normal file
101
services/arbiter/src/index.js
Normal file
@@ -0,0 +1,101 @@
|
||||
// Firefrost Arbiter v2.0.0
|
||||
// Discord Role Management & OAuth Gateway
|
||||
// Built: March 30, 2026
|
||||
|
||||
const VERSION = '2.0.0';
|
||||
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const SQLiteStore = require('connect-sqlite3')(session);
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { client } = require('./discordService');
|
||||
const db = require('./database');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3500;
|
||||
|
||||
// Trust reverse proxy (Nginx) for secure cookies
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Middleware - Body Parsers
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Middleware - Session Configuration
|
||||
app.use(session({
|
||||
store: new SQLiteStore({ db: 'sessions.db', dir: './' }),
|
||||
secret: process.env.SESSION_SECRET,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production', // true if HTTPS
|
||||
httpOnly: true,
|
||||
maxAge: 1000 * 60 * 60 * 24 * 7 // 1 week
|
||||
}
|
||||
}));
|
||||
|
||||
// Middleware - Rate Limiting
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // Limit each IP to 100 requests per window
|
||||
message: 'Too many requests from this IP, please try again after 15 minutes',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Apply rate limiting to specific routes
|
||||
app.use('/auth', apiLimiter);
|
||||
app.use('/webhook', apiLimiter);
|
||||
app.use('/admin/api', apiLimiter);
|
||||
|
||||
// Routes
|
||||
app.use('/webhook', require('./routes/webhook'));
|
||||
app.use('/auth', require('./routes/oauth'));
|
||||
app.use('/admin', require('./routes/adminAuth'));
|
||||
app.use('/admin', require('./routes/admin'));
|
||||
|
||||
// Health Check Endpoint
|
||||
app.get('/health', async (req, res) => {
|
||||
let dbStatus = 'down';
|
||||
try {
|
||||
db.prepare('SELECT 1').get();
|
||||
dbStatus = 'ok';
|
||||
} catch (e) {
|
||||
console.error('[Health] Database check failed:', e);
|
||||
}
|
||||
|
||||
const status = {
|
||||
uptime: process.uptime(),
|
||||
discord: client.isReady() ? 'ok' : 'down',
|
||||
database: dbStatus,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const httpStatus = (status.discord === 'ok' && status.database === 'ok') ? 200 : 503;
|
||||
res.status(httpStatus).json(status);
|
||||
});
|
||||
|
||||
// Root endpoint
|
||||
app.get('/', (req, res) => {
|
||||
res.send('Firefrost Arbiter - Discord Role Management System');
|
||||
});
|
||||
|
||||
// Start Server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[Server] Listening on port ${PORT}`);
|
||||
console.log(`[Server] Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(`[Server] Health check: http://localhost:${PORT}/health`);
|
||||
});
|
||||
|
||||
// Discord Bot Ready Event
|
||||
client.on('ready', () => {
|
||||
console.log(`[Discord] Bot ready as ${client.user.tag}`);
|
||||
});
|
||||
|
||||
// Graceful Shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('[Server] SIGTERM received, shutting down gracefully...');
|
||||
client.destroy();
|
||||
process.exit(0);
|
||||
});
|
||||
27
services/arbiter/src/middleware/auth.js
Normal file
27
services/arbiter/src/middleware/auth.js
Normal file
@@ -0,0 +1,27 @@
|
||||
// src/middleware/auth.js
|
||||
// Authentication middleware for admin panel access control
|
||||
|
||||
/**
|
||||
* Require admin authentication - checks if logged-in user is in Trinity whitelist
|
||||
* @param {Object} req - Express request
|
||||
* @param {Object} res - Express response
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function requireAdmin(req, res, next) {
|
||||
// This assumes your existing OAuth flow stores the logged-in user's ID in a session
|
||||
const userId = req.session?.discordId;
|
||||
|
||||
if (!userId) {
|
||||
return res.redirect('/admin/login');
|
||||
}
|
||||
|
||||
const adminIds = process.env.ADMIN_DISCORD_IDS.split(',');
|
||||
|
||||
if (adminIds.includes(userId)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).send('Forbidden: You do not have admin access.');
|
||||
}
|
||||
|
||||
module.exports = { requireAdmin };
|
||||
33
services/arbiter/src/middleware/validateWebhook.js
Normal file
33
services/arbiter/src/middleware/validateWebhook.js
Normal file
@@ -0,0 +1,33 @@
|
||||
// src/middleware/validateWebhook.js
|
||||
// Zod-based payload validation for Paymenter webhooks
|
||||
|
||||
const { z } = require('zod');
|
||||
|
||||
const webhookSchema = z.object({
|
||||
event: z.string(),
|
||||
customer_email: z.string().email(),
|
||||
customer_name: z.string().optional(),
|
||||
tier: z.string(),
|
||||
product_id: z.string().optional(),
|
||||
subscription_id: z.string().optional(),
|
||||
discord_id: z.string().optional().nullable()
|
||||
});
|
||||
|
||||
/**
|
||||
* Validate webhook payload structure using Zod
|
||||
* @param {Object} req - Express request
|
||||
* @param {Object} res - Express response
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function validateBillingPayload(req, res, next) {
|
||||
try {
|
||||
req.body = webhookSchema.parse(req.body);
|
||||
next();
|
||||
} catch (error) {
|
||||
// Log the validation error for debugging, but return 400
|
||||
console.error('[Webhook] Validation Error:', error.errors);
|
||||
return res.status(400).json({ error: 'Invalid payload structure' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = validateBillingPayload;
|
||||
35
services/arbiter/src/middleware/verifyWebhook.js
Normal file
35
services/arbiter/src/middleware/verifyWebhook.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// src/middleware/verifyWebhook.js
|
||||
// HMAC SHA256 webhook signature verification for Paymenter webhooks
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Verify webhook signature to prevent unauthorized requests
|
||||
* @param {Object} req - Express request
|
||||
* @param {Object} res - Express response
|
||||
* @param {Function} next - Express next function
|
||||
*/
|
||||
function verifyBillingWebhook(req, res, next) {
|
||||
const signature = req.headers['x-signature']; // Check your provider's exact header name
|
||||
const payload = JSON.stringify(req.body);
|
||||
const secret = process.env.WEBHOOK_SECRET;
|
||||
|
||||
if (!signature || !secret) {
|
||||
console.error('[Webhook] Missing signature or secret');
|
||||
return res.status(401).json({ error: 'Invalid webhook signature' });
|
||||
}
|
||||
|
||||
const expectedSignature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(payload)
|
||||
.digest('hex');
|
||||
|
||||
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
|
||||
console.error('[Webhook] Signature verification failed');
|
||||
return res.status(401).json({ error: 'Invalid webhook signature' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = verifyBillingWebhook;
|
||||
79
services/arbiter/src/routes/admin.js
Normal file
79
services/arbiter/src/routes/admin.js
Normal file
@@ -0,0 +1,79 @@
|
||||
// src/routes/admin.js
|
||||
// Admin panel routes for manual role assignment and audit logs
|
||||
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const { requireAdmin } = require('../middleware/auth');
|
||||
const db = require('../database');
|
||||
const { assignDiscordRole, removeAllSubscriptionRoles, rolesConfig } = require('../discordService');
|
||||
const { findMemberByEmail } = require('../cmsService');
|
||||
|
||||
// Apply admin protection to all routes
|
||||
router.use(requireAdmin);
|
||||
|
||||
// 1. Render the admin UI
|
||||
router.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../views/admin.html'));
|
||||
});
|
||||
|
||||
// 2. Get tier list for dropdown population
|
||||
router.get('/api/tiers', (req, res) => {
|
||||
res.json(rolesConfig);
|
||||
});
|
||||
|
||||
// 3. Search API endpoint - find user by email
|
||||
router.get('/api/search', async (req, res) => {
|
||||
const { email } = req.query;
|
||||
try {
|
||||
const cmsUser = await findMemberByEmail(email);
|
||||
res.json(cmsUser);
|
||||
} catch (error) {
|
||||
console.error('[Admin] Search failed:', error);
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Manual role assignment endpoint
|
||||
router.post('/api/assign', async (req, res) => {
|
||||
const { targetDiscordId, action, tier, reason } = req.body;
|
||||
const adminId = req.session.discordId;
|
||||
|
||||
try {
|
||||
if (action === 'add') {
|
||||
await assignDiscordRole(targetDiscordId, tier);
|
||||
} else if (action === 'remove_all') {
|
||||
await removeAllSubscriptionRoles(targetDiscordId);
|
||||
}
|
||||
|
||||
// Log the action to audit log
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO audit_logs (admin_id, target_user, action, reason)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(adminId, targetDiscordId, `${action}_${tier || 'all'}`, reason);
|
||||
|
||||
console.log(`[Admin] ${adminId} performed ${action} on ${targetDiscordId}`);
|
||||
res.json({ success: true, message: 'Action completed and logged.' });
|
||||
} catch (error) {
|
||||
console.error('[Admin] Assignment failed:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Audit log retrieval endpoint
|
||||
router.get('/api/audit-log', async (req, res) => {
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM audit_logs
|
||||
ORDER BY timestamp DESC LIMIT 50
|
||||
`);
|
||||
const logs = stmt.all();
|
||||
res.json(logs);
|
||||
} catch (error) {
|
||||
console.error('[Admin] Failed to fetch audit logs:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch logs' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
55
services/arbiter/src/routes/adminAuth.js
Normal file
55
services/arbiter/src/routes/adminAuth.js
Normal file
@@ -0,0 +1,55 @@
|
||||
// src/routes/adminAuth.js
|
||||
// Discord OAuth authentication for admin panel access
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Admin login - redirect to Discord OAuth
|
||||
router.get('/login', (req, res) => {
|
||||
const redirectUri = encodeURIComponent(`${process.env.APP_URL}/admin/callback`);
|
||||
res.redirect(`https://discord.com/api/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=identify`);
|
||||
});
|
||||
|
||||
// OAuth callback - set session and redirect to dashboard
|
||||
router.get('/callback', async (req, res) => {
|
||||
const { code } = req.query;
|
||||
|
||||
try {
|
||||
// Exchange code for Discord access token
|
||||
const tokenRes = await fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.DISCORD_CLIENT_ID,
|
||||
client_secret: process.env.DISCORD_CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: `${process.env.APP_URL}/admin/callback`,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
const tokenData = await tokenRes.json();
|
||||
|
||||
// Get Discord user profile
|
||||
const userRes = await fetch('https://discord.com/api/users/@me', {
|
||||
headers: { authorization: `Bearer ${tokenData.access_token}` },
|
||||
});
|
||||
const userData = await userRes.json();
|
||||
|
||||
// Set session
|
||||
req.session.discordId = userData.id;
|
||||
|
||||
console.log(`[Admin Auth] ${userData.username} logged in`);
|
||||
res.redirect('/admin');
|
||||
} catch (error) {
|
||||
console.error('[Admin Auth] Login failed:', error);
|
||||
res.status(500).send('Admin login failed. Please try again.');
|
||||
}
|
||||
});
|
||||
|
||||
// Logout - destroy session
|
||||
router.get('/logout', (req, res) => {
|
||||
req.session.destroy();
|
||||
res.redirect('/');
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
92
services/arbiter/src/routes/oauth.js
Normal file
92
services/arbiter/src/routes/oauth.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// src/routes/oauth.js
|
||||
// Discord OAuth linking flow for subscription users
|
||||
|
||||
const express = require('express');
|
||||
const db = require('../database');
|
||||
const { updateMemberDiscordId } = require('../cmsService');
|
||||
const { assignDiscordRole } = require('../discordService');
|
||||
const templates = require('../utils/templates');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// 1. The entry point from the email link
|
||||
router.get('/link', (req, res) => {
|
||||
const { token } = req.query;
|
||||
|
||||
const tokenData = db.prepare(`
|
||||
SELECT * FROM link_tokens
|
||||
WHERE token = ? AND used = 0 AND created_at >= datetime('now', '-1 day')
|
||||
`).get(token);
|
||||
|
||||
if (!tokenData) {
|
||||
// Check if token exists but is expired or used
|
||||
const expiredToken = db.prepare('SELECT * FROM link_tokens WHERE token = ?').get(token);
|
||||
if (expiredToken && expiredToken.used === 1) {
|
||||
return res.status(400).send(templates.getUsedPage());
|
||||
}
|
||||
if (expiredToken) {
|
||||
return res.status(400).send(templates.getExpiredPage());
|
||||
}
|
||||
return res.status(400).send(templates.getInvalidPage());
|
||||
}
|
||||
|
||||
const redirectUri = encodeURIComponent(`${process.env.APP_URL}/auth/callback`);
|
||||
const authUrl = `https://discord.com/api/oauth2/authorize?client_id=${process.env.DISCORD_CLIENT_ID}&redirect_uri=${redirectUri}&response_type=code&scope=identify&state=${token}`;
|
||||
|
||||
res.redirect(authUrl);
|
||||
});
|
||||
|
||||
// 2. The callback from Discord OAuth
|
||||
router.get('/callback', async (req, res) => {
|
||||
const { code, state: token } = req.query;
|
||||
|
||||
const tokenData = db.prepare('SELECT * FROM link_tokens WHERE token = ? AND used = 0').get(token);
|
||||
if (!tokenData) {
|
||||
return res.status(400).send(templates.getInvalidPage());
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for Discord access token
|
||||
const tokenRes = await fetch('https://discord.com/api/oauth2/token', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({
|
||||
client_id: process.env.DISCORD_CLIENT_ID,
|
||||
client_secret: process.env.DISCORD_CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: `${process.env.APP_URL}/auth/callback`,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
||||
});
|
||||
|
||||
const oauthData = await tokenRes.json();
|
||||
|
||||
// Get Discord user profile
|
||||
const userRes = await fetch('https://discord.com/api/users/@me', {
|
||||
headers: { authorization: `Bearer ${oauthData.access_token}` },
|
||||
});
|
||||
const userData = await userRes.json();
|
||||
|
||||
// Update Ghost CMS with Discord ID
|
||||
await updateMemberDiscordId(tokenData.email, userData.id);
|
||||
|
||||
// Assign Discord role
|
||||
const roleAssigned = await assignDiscordRole(userData.id, tokenData.tier);
|
||||
|
||||
if (!roleAssigned) {
|
||||
// User not in server
|
||||
return res.status(400).send(templates.getNotInServerPage());
|
||||
}
|
||||
|
||||
// Mark token as used
|
||||
db.prepare('UPDATE link_tokens SET used = 1 WHERE token = ?').run(token);
|
||||
|
||||
console.log(`[OAuth] Successfully linked ${userData.id} to ${tokenData.email}`);
|
||||
res.send(templates.getSuccessPage());
|
||||
} catch (error) {
|
||||
console.error('[OAuth] Callback Error:', error);
|
||||
res.status(500).send(templates.getServerErrPage());
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
62
services/arbiter/src/routes/webhook.js
Normal file
62
services/arbiter/src/routes/webhook.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// src/routes/webhook.js
|
||||
// Paymenter webhook handler for subscription events
|
||||
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const db = require('../database');
|
||||
const { sendLinkingEmail } = require('../email');
|
||||
const { assignDiscordRole, updateSubscriptionRoles } = require('../discordService');
|
||||
const verifyBillingWebhook = require('../middleware/verifyWebhook');
|
||||
const validateBillingPayload = require('../middleware/validateWebhook');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/billing', verifyBillingWebhook, validateBillingPayload, async (req, res) => {
|
||||
const { event, discord_id, tier, customer_email, customer_name, subscription_id } = req.body;
|
||||
|
||||
console.log(`[Webhook] Received ${event} for ${customer_email}`);
|
||||
|
||||
try {
|
||||
if (event === 'subscription.created') {
|
||||
if (discord_id) {
|
||||
// User already linked - assign role immediately
|
||||
await assignDiscordRole(discord_id, tier);
|
||||
return res.status(200).json({ message: 'Role assigned' });
|
||||
} else {
|
||||
// User not linked yet - generate token and send email
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO link_tokens (token, email, tier, subscription_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
stmt.run(token, customer_email, tier, subscription_id);
|
||||
|
||||
await sendLinkingEmail(customer_name || 'Subscriber', customer_email, token);
|
||||
console.log(`[Webhook] Sent linking email to ${customer_email}`);
|
||||
return res.status(200).json({ message: 'Email sent' });
|
||||
}
|
||||
}
|
||||
|
||||
if (event === 'subscription.upgraded' || event === 'subscription.downgraded') {
|
||||
if (discord_id) {
|
||||
await updateSubscriptionRoles(discord_id, tier);
|
||||
return res.status(200).json({ message: 'Roles updated' });
|
||||
}
|
||||
}
|
||||
|
||||
if (event === 'subscription.cancelled') {
|
||||
if (discord_id) {
|
||||
await updateSubscriptionRoles(discord_id, null); // Remove all roles
|
||||
return res.status(200).json({ message: 'Roles removed' });
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({ message: 'Event acknowledged' });
|
||||
} catch (error) {
|
||||
console.error('[Webhook] Processing failed:', error);
|
||||
return res.status(500).json({ error: 'Internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
65
services/arbiter/src/utils/templates.js
Normal file
65
services/arbiter/src/utils/templates.js
Normal file
@@ -0,0 +1,65 @@
|
||||
// src/utils/templates.js
|
||||
// HTML templates for user-facing success and error pages
|
||||
|
||||
const baseHtml = (title, content) => `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${title} - Firefrost Gaming</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="container" style="text-align: center; margin-top: 10vh;">
|
||||
<article>
|
||||
${content}
|
||||
</article>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const getSuccessPage = () => baseHtml('Success', `
|
||||
<h1>🔥 Account Linked Successfully! ❄️</h1>
|
||||
<p>Your Discord account has been connected and your roles are assigned.</p>
|
||||
<p>You can close this window and head back to Discord to see your new channels!</p>
|
||||
`);
|
||||
|
||||
const getExpiredPage = () => baseHtml('Link Expired', `
|
||||
<h2 style="color: #ffb703;">⏳ This Link Has Expired</h2>
|
||||
<p>For security, linking URLs expire after 24 hours.</p>
|
||||
<p>Please log in to the website to request a new Discord linking email, or contact support.</p>
|
||||
`);
|
||||
|
||||
const getInvalidPage = () => baseHtml('Invalid Link', `
|
||||
<h2 style="color: #e63946;">❌ Invalid Link</h2>
|
||||
<p>We couldn't recognize this secure token. The URL might be malformed or incomplete.</p>
|
||||
<p>Please make sure you copied the entire link from your email.</p>
|
||||
`);
|
||||
|
||||
const getUsedPage = () => baseHtml('Already Linked', `
|
||||
<h2 style="color: #8eca91;">✅ Already Linked</h2>
|
||||
<p>This specific token has already been used to link an account.</p>
|
||||
<p>If you do not see your roles in Discord, please open a support ticket.</p>
|
||||
`);
|
||||
|
||||
const getServerErrPage = () => baseHtml('System Error', `
|
||||
<h2 style="color: #e63946;">⚠️ System Error</h2>
|
||||
<p>Something went wrong communicating with the Discord or CMS servers.</p>
|
||||
<p>Please try clicking the link in your email again in a few minutes.</p>
|
||||
`);
|
||||
|
||||
const getNotInServerPage = () => baseHtml('Join Server First', `
|
||||
<h2 style="color: #ffb703;">👋 One Quick Thing...</h2>
|
||||
<p>It looks like you aren't in our Discord server yet!</p>
|
||||
<p>Please <a href="https://firefrostgaming.com/discord">click here to join the server</a>, then click the secure link in your email again to receive your roles.</p>
|
||||
`);
|
||||
|
||||
module.exports = {
|
||||
getSuccessPage,
|
||||
getExpiredPage,
|
||||
getInvalidPage,
|
||||
getUsedPage,
|
||||
getServerErrPage,
|
||||
getNotInServerPage
|
||||
};
|
||||
188
services/arbiter/src/views/admin.html
Normal file
188
services/arbiter/src/views/admin.html
Normal file
@@ -0,0 +1,188 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin Panel - Firefrost Gaming</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1/css/pico.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="container">
|
||||
<nav>
|
||||
<ul><li><strong>🔥❄️ Firefrost Admin Panel</strong></li></ul>
|
||||
<ul><li><a href="/admin/logout">Logout</a></li></ul>
|
||||
</nav>
|
||||
|
||||
<!-- Search User Section -->
|
||||
<section>
|
||||
<h2>Search User</h2>
|
||||
<form id="searchForm">
|
||||
<input type="email" id="searchEmail" placeholder="Enter subscriber email from Ghost CMS" required>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<div id="searchResults"></div>
|
||||
</section>
|
||||
|
||||
<!-- Manual Role Assignment Section -->
|
||||
<section>
|
||||
<h2>Manual Role Assignment</h2>
|
||||
<form id="assignForm">
|
||||
<input type="text" id="targetId" placeholder="Discord User ID (snowflake)" required>
|
||||
|
||||
<select id="action" required>
|
||||
<option value="" disabled selected>Select Action...</option>
|
||||
<option value="add">Add Role</option>
|
||||
<option value="remove_all">Remove All Subscription Roles</option>
|
||||
</select>
|
||||
|
||||
<select id="tier" required>
|
||||
<!-- Populated dynamically by JavaScript -->
|
||||
</select>
|
||||
|
||||
<input type="text" id="reason" placeholder="Reason (e.g., 'Support ticket #123', 'Refund')" required>
|
||||
<button type="submit">Execute & Log</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Audit Log Section -->
|
||||
<section>
|
||||
<h2>Recent Actions (Audit Log)</h2>
|
||||
<figure>
|
||||
<table role="grid">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Timestamp</th>
|
||||
<th scope="col">Admin ID</th>
|
||||
<th scope="col">Target User</th>
|
||||
<th scope="col">Action</th>
|
||||
<th scope="col">Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="auditLogs">
|
||||
<tr><td colspan="5">Loading...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</figure>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// --- 1. Load Tier Dropdown ---
|
||||
async function loadTiers() {
|
||||
try {
|
||||
const response = await fetch('/admin/api/tiers');
|
||||
const tiers = await response.json();
|
||||
const tierSelect = document.getElementById('tier');
|
||||
|
||||
tierSelect.innerHTML = Object.keys(tiers).map(tierKey =>
|
||||
`<option value="${tierKey}">${tierKey.replace(/_/g, ' ').toUpperCase()}</option>`
|
||||
).join('');
|
||||
} catch (error) {
|
||||
console.error('Failed to load tiers:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. Search Functionality ---
|
||||
document.getElementById('searchForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const email = document.getElementById('searchEmail').value;
|
||||
const resultsDiv = document.getElementById('searchResults');
|
||||
|
||||
resultsDiv.innerHTML = '<em>Searching...</em>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/search?email=${encodeURIComponent(email)}`);
|
||||
if (!response.ok) throw new Error('User not found in CMS.');
|
||||
|
||||
const user = await response.json();
|
||||
|
||||
// Assuming Ghost CMS custom field is named 'discord_id'
|
||||
const discordId = user.labels?.find(l => l.name === 'discord_id')?.value ||
|
||||
user.custom_fields?.find(f => f.name === 'discord_id')?.value ||
|
||||
'Not Linked';
|
||||
|
||||
resultsDiv.innerHTML = `
|
||||
<article>
|
||||
<p><strong>Name:</strong> ${user.name || 'Unknown'}</p>
|
||||
<p><strong>Email:</strong> ${user.email}</p>
|
||||
<p><strong>Discord ID:</strong> ${discordId}</p>
|
||||
</article>
|
||||
`;
|
||||
|
||||
// Auto-fill assignment form if Discord ID exists
|
||||
if (discordId !== 'Not Linked') {
|
||||
document.getElementById('targetId').value = discordId;
|
||||
}
|
||||
} catch (error) {
|
||||
resultsDiv.innerHTML = `<p style="color: #ff6b6b;">${error.message}</p>`;
|
||||
}
|
||||
});
|
||||
|
||||
// --- 3. Role Assignment Functionality ---
|
||||
document.getElementById('assignForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const submitBtn = e.target.querySelector('button[type="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Processing...';
|
||||
|
||||
const payload = {
|
||||
targetDiscordId: document.getElementById('targetId').value,
|
||||
action: document.getElementById('action').value,
|
||||
tier: document.getElementById('tier').value,
|
||||
reason: document.getElementById('reason').value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api/assign', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'Assignment failed');
|
||||
|
||||
alert('✅ Success: ' + result.message);
|
||||
e.target.reset();
|
||||
loadAuditLogs(); // Refresh logs
|
||||
} catch (error) {
|
||||
alert('❌ Error: ' + error.message);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Execute & Log';
|
||||
}
|
||||
});
|
||||
|
||||
// --- 4. Audit Log Display ---
|
||||
async function loadAuditLogs() {
|
||||
const logContainer = document.getElementById('auditLogs');
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api/audit-log');
|
||||
const logs = await response.json();
|
||||
|
||||
if (logs.length === 0) {
|
||||
logContainer.innerHTML = '<tr><td colspan="5">No audit logs yet.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
logContainer.innerHTML = logs.map(log => `
|
||||
<tr>
|
||||
<td>${new Date(log.timestamp).toLocaleString()}</td>
|
||||
<td>${log.admin_id}</td>
|
||||
<td>${log.target_user}</td>
|
||||
<td>${log.action}</td>
|
||||
<td>${log.reason}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
logContainer.innerHTML = '<tr><td colspan="5">Failed to load logs.</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
loadTiers();
|
||||
loadAuditLogs();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user